87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
|
|
* NTP-style estimate:
|
|
* t0 = client ms before request
|
|
* s = server ms from JSON
|
|
* t1 = client ms after response
|
|
* offset ≈ ((t0 + t1)/2) - s
|
|
* Positive offset => client is AHEAD by that many ms.
|
|
*/
|
|
export function useTimeSkew(opts?: {
|
|
endpoint?: string; // e.g., "/api/time" or `${import.meta.env.BASE_URL}api/time`
|
|
intervalMs?: number; // how often to recheck; default 5 min
|
|
samples?: number; // take N measurements and median; default 1
|
|
enabled?: boolean; // allow turning off; default true
|
|
}) {
|
|
const {
|
|
endpoint = "/api/time",
|
|
intervalMs = 5 * 60_000,
|
|
samples = 1,
|
|
enabled = true,
|
|
} = opts || {};
|
|
|
|
const [skewMs, setSkewMs] = useState<number | null>(null);
|
|
const [rttMs, setRttMs] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const timerRef = useRef<number | null>(null);
|
|
|
|
const measureOnce = async () => {
|
|
const t0 = Date.now();
|
|
const res = await fetch(endpoint, { cache: "no-store" });
|
|
const t1 = Date.now();
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
|
|
if (typeof data?.now !== "number") {
|
|
throw new Error("Bad time payload (expecting { now: number })");
|
|
}
|
|
const serverMs = data.now;
|
|
const rtt = t1 - t0;
|
|
const offset = Math.round(((t0 + t1) / 2) - serverMs);
|
|
return { offset, rtt };
|
|
};
|
|
|
|
const measure = async () => {
|
|
try {
|
|
setError(null);
|
|
if (samples <= 1) {
|
|
const { offset, rtt } = await measureOnce();
|
|
setSkewMs(offset);
|
|
setRttMs(rtt);
|
|
return;
|
|
}
|
|
// multiple samples → median offset, min RTT (more stable)
|
|
const offsets: number[] = [];
|
|
const rtts: number[] = [];
|
|
for (let i = 0; i < samples; i++) {
|
|
const { offset, rtt } = await measureOnce();
|
|
offsets.push(offset);
|
|
rtts.push(rtt);
|
|
if (i < samples - 1) await new Promise(r => setTimeout(r, 120)); // tiny gap
|
|
}
|
|
offsets.sort((a, b) => a - b);
|
|
const median = offsets[Math.floor(offsets.length / 2)];
|
|
const minRtt = Math.min(...rtts);
|
|
setSkewMs(median);
|
|
setRttMs(minRtt);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Time sync failed");
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
measure(); // initial check
|
|
if (intervalMs > 0) {
|
|
timerRef.current = window.setInterval(measure, intervalMs);
|
|
}
|
|
return () => {
|
|
if (timerRef.current) window.clearInterval(timerRef.current);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [endpoint, intervalMs, samples, enabled]);
|
|
|
|
return { skewMs, rttMs, error, recheck: measure };
|
|
} |