File size: 7,068 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | /**
* NTP-inspired clock synchronisation engine.
*
* Maintains a running estimate of the offset between the local clock
* (`Date.now()`) and the server clock so that `getServerTime()` returns
* a value aligned with the server's `Date.now()`.
*
* Algorithm:
* 1. Client sends NTP_PING with `clientPingId` (= monotonic counter).
* 2. Server immediately replies NTP_PONG with `{ clientPingId, serverTime }`.
* 3. Client records `t0` (when ping was sent, via `performance.now()`)
* and `t2` (when pong arrived, via `performance.now()`).
* 4. RTT = t2 - t0
* offset = serverTime - (clientLocalTimeAtPing + RTT / 2)
* 5. We keep a sliding window of samples and compute a **time-decayed
* weighted median** offset — recent samples contribute more, giving
* both the robustness of median filtering and fast convergence when
* network conditions change (e.g. WiFi → cellular handoff).
*/
import { NTP } from '@music-together/shared'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NTPSample {
rttMs: number
offsetMs: number
/** `Date.now()` when the sample was recorded */
timestamp: number
}
interface PendingPing {
/** `performance.now()` when the ping was sent */
sentAt: number
/** `Date.now()` when the ping was sent – used for offset calculation */
localTime: number
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Half-life (ms) for exponential decay weighting of NTP samples.
* Samples older than ~30s contribute roughly half the weight of fresh ones. */
const DECAY_HALF_LIFE_MS = 30_000
// ---------------------------------------------------------------------------
// State (module-level singleton – one clock per app)
// ---------------------------------------------------------------------------
const samples: NTPSample[] = []
const pending = new Map<number, PendingPing>()
let pingCounter = 0
let medianOffset = 0
let calibrated = false
// Anchor pair for monotonic getServerTime().
// `performance.now()` is monotonic and immune to system clock adjustments
// (NTP sync, manual time change, sleep/wake). We anchor a known server-time
// to a performance.now() reading and derive future server-times from the
// elapsed monotonic time, eliminating Date.now() jitter from getServerTime().
let anchorPerfNow = performance.now()
let anchorServerTime = Date.now() // uncalibrated initially; updated on each pong
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Compute time-decayed weighted median of offset values.
* Each sample's weight = exp(-age / halfLife), so recent samples dominate.
* We find the offset where cumulative weight first exceeds 50% of total.
*/
function computeWeightedMedian(now: number): number {
if (samples.length === 0) return 0
if (samples.length === 1) return samples[0].offsetMs
// Build (offset, weight) pairs
const pairs: { offset: number; weight: number }[] = []
for (const s of samples) {
const age = now - s.timestamp
const weight = Math.exp((-age * Math.LN2) / DECAY_HALF_LIFE_MS)
pairs.push({ offset: s.offsetMs, weight })
}
// Sort by offset ascending
pairs.sort((a, b) => a.offset - b.offset)
// Find weighted median
let totalWeight = 0
for (const p of pairs) totalWeight += p.weight
const halfWeight = totalWeight / 2
let cumWeight = 0
for (const p of pairs) {
cumWeight += p.weight
if (cumWeight >= halfWeight) return p.offset
}
// Fallback (should not reach here)
return pairs[pairs.length - 1].offset
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Returns the current server time (ms) compensated by the measured offset.
* Uses a monotonic `performance.now()` anchor so the result is immune to
* system clock adjustments (NTP sync, manual time change, sleep/wake). */
export function getServerTime(): number {
return anchorServerTime + (performance.now() - anchorPerfNow)
}
/** Whether the initial calibration phase has completed. */
export function isCalibrated(): boolean {
return calibrated
}
/** Current smoothed RTT (ms) – the median of all stored samples. */
export function getMedianRTT(): number {
if (samples.length === 0) return 0
const sorted = samples.map((s) => s.rttMs).sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
/** Current clock offset (ms). Positive = local clock is behind server. */
export function getClockOffset(): number {
return medianOffset
}
// ---------------------------------------------------------------------------
// Ping / Pong helpers (called by useClockSync hook)
// ---------------------------------------------------------------------------
/**
* Record a pending ping. Returns the `clientPingId` to send to the server.
*/
export function recordPing(): number {
const id = ++pingCounter
const now = Date.now()
pending.set(id, {
sentAt: performance.now(),
localTime: now,
})
// Purge stale entries older than 10 seconds (server never responded)
const staleThreshold = now - 10_000
for (const [k, v] of pending) {
if (v.localTime < staleThreshold) pending.delete(k)
else break // Map preserves insertion order; once we hit a fresh entry, stop
}
return id
}
/**
* Process a pong response from the server.
* Returns the computed RTT so the caller can forward it to the server.
*/
export function processPong(clientPingId: number, serverTime: number): number | null {
const ping = pending.get(clientPingId)
if (!ping) return null
pending.delete(clientPingId)
const t2 = performance.now()
const rttMs = t2 - ping.sentAt
// Discard obviously bad samples (negative or huge RTT)
if (rttMs < 0 || rttMs > 10_000) return null
const now = Date.now()
const oneWay = rttMs / 2
const offsetMs = serverTime - (ping.localTime + oneWay)
samples.push({ rttMs, offsetMs, timestamp: now })
// Keep sliding window bounded
if (samples.length > NTP.MAX_MEASUREMENTS) {
samples.shift()
}
// Recalculate time-decayed weighted median offset
medianOffset = computeWeightedMedian(now)
// Refresh the monotonic anchor so getServerTime() stays accurate
anchorPerfNow = performance.now()
anchorServerTime = Date.now() + medianOffset
if (!calibrated && samples.length >= NTP.MAX_INITIAL_SAMPLES) {
calibrated = true
}
return rttMs
}
/** Reset all state (useful on disconnect). */
export function resetClockSync(): void {
samples.length = 0
pending.clear()
pingCounter = 0
medianOffset = 0
calibrated = false
anchorPerfNow = performance.now()
anchorServerTime = Date.now()
}
|