watch-party/frontend/src/hooks/useTimeSkew.ts
2025-11-11 18:06:45 +09:00

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 };
}