95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
import { Injectable, signal, computed } from '@angular/core';
|
||
|
||
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
||
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
||
|
||
/**
|
||
* Maintains a clock-offset between the local system time and the
|
||
* remote signaling server.
|
||
*
|
||
* The offset is estimated using a simple NTP-style round-trip
|
||
* measurement and is stored as a reactive Angular signal so that
|
||
* any dependent computed value auto-updates when a new sync occurs.
|
||
*/
|
||
@Injectable({ providedIn: 'root' })
|
||
export class TimeSyncService {
|
||
/**
|
||
* Internal offset signal:
|
||
* `serverTime = Date.now() + offset`.
|
||
*/
|
||
private readonly _offset = signal<number>(0);
|
||
|
||
/** Epoch timestamp of the most recent successful sync. */
|
||
private lastSyncTimestamp = 0;
|
||
|
||
/** Reactive read-only offset (milliseconds). */
|
||
readonly offset = computed(() => this._offset());
|
||
|
||
/**
|
||
* Return a server-adjusted "now" timestamp.
|
||
*
|
||
* @returns Epoch milliseconds aligned to the server clock.
|
||
*/
|
||
now(): number {
|
||
return Date.now() + this._offset();
|
||
}
|
||
|
||
/**
|
||
* Set the offset from a known server timestamp.
|
||
*
|
||
* @param serverTime - Epoch timestamp reported by the server.
|
||
* @param receiveTimestamp - Local epoch timestamp when the server time was
|
||
* observed. Defaults to `Date.now()` if omitted.
|
||
*/
|
||
setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
|
||
const observedAt = receiveTimestamp ?? Date.now();
|
||
this._offset.set(serverTime - observedAt);
|
||
this.lastSyncTimestamp = Date.now();
|
||
}
|
||
|
||
/**
|
||
* Perform an HTTP-based clock synchronisation using a simple
|
||
* NTP-style round-trip.
|
||
*
|
||
* 1. Record `clientSendTime` (`t0`).
|
||
* 2. Fetch `GET {baseApiUrl}/time`.
|
||
* 3. Record `clientReceiveTime` (`t2`).
|
||
* 4. Estimate one-way latency as `(t2 − t0) / 2`.
|
||
* 5. Compute offset: `serverNow − midpoint(t0, t2)`.
|
||
*
|
||
* Any network or parsing error is silently ignored so that the
|
||
* last known offset (or zero) is retained.
|
||
*
|
||
* @param baseApiUrl - API base URL (e.g. `http://host:3001/api`).
|
||
* @param timeoutMs - Maximum time to wait for the response.
|
||
*/
|
||
async syncWithEndpoint(
|
||
baseApiUrl: string,
|
||
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS,
|
||
): Promise<void> {
|
||
try {
|
||
const controller = new AbortController();
|
||
const clientSendTime = Date.now();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
|
||
const response = await fetch(`${baseApiUrl}/time`, {
|
||
signal: controller.signal,
|
||
});
|
||
|
||
const clientReceiveTime = Date.now();
|
||
clearTimeout(timer);
|
||
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const serverNow = Number(data?.now) || Date.now();
|
||
const midpoint = (clientSendTime + clientReceiveTime) / 2;
|
||
|
||
this._offset.set(serverNow - midpoint);
|
||
this.lastSyncTimestamp = Date.now();
|
||
} catch {
|
||
// Sync failure is non-fatal; retain the previous offset.
|
||
}
|
||
}
|
||
}
|