rspace-online/server/clock-service.ts

148 lines
3.6 KiB
TypeScript

/**
* System Clock / Heartbeat Service
*
* Broadcasts consistent time signals to all connected canvas clients.
* Single server-authoritative source — clients subscribe via CanvasEventBus.
* Clock events are ephemeral (WebSocket only, not persisted in CRDT).
*/
export interface ClockPayload {
timestamp: number;
isoString: string;
hour: number;
minute: number;
second: number;
dayOfWeek: number;
tickCount: number;
}
export interface ClockConfig {
enabled: boolean;
/** Timezone for daily boundary (default: UTC) */
timezone: string;
/** Tick interval in seconds (minimum 10, default 60) */
tickInterval: number;
/** Which channels to emit */
channels: ("tick" | "minute:5" | "hourly" | "daily")[];
}
const DEFAULT_CONFIG: ClockConfig = {
enabled: true,
timezone: "UTC",
tickInterval: 60,
channels: ["tick", "minute:5", "hourly", "daily"],
};
export type ClockBroadcastFn = (channel: string, payload: ClockPayload) => void;
export class SystemClock {
#config: ClockConfig;
#broadcast: ClockBroadcastFn;
#timer: ReturnType<typeof setInterval> | null = null;
#tickCount = 0;
#lastHour = -1;
#lastDay = -1;
constructor(broadcast: ClockBroadcastFn, config?: Partial<ClockConfig>) {
this.#config = { ...DEFAULT_CONFIG, ...config };
// Enforce minimum tick interval
if (this.#config.tickInterval < 10) this.#config.tickInterval = 10;
this.#broadcast = broadcast;
}
/** Start the clock. Idempotent. */
start(): void {
if (this.#timer || !this.#config.enabled) return;
console.log(
`[SystemClock] Started (interval: ${this.#config.tickInterval}s, channels: ${this.#config.channels.join(", ")})`,
);
// Fire first tick immediately
this.#tick();
this.#timer = setInterval(
() => this.#tick(),
this.#config.tickInterval * 1000,
);
}
/** Stop the clock. */
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
console.log("[SystemClock] Stopped");
}
}
/** Check if the clock is running. */
get running(): boolean {
return this.#timer !== null;
}
/** Update config and restart if running. */
updateConfig(config: Partial<ClockConfig>): void {
const wasRunning = this.running;
this.stop();
this.#config = { ...this.#config, ...config };
if (this.#config.tickInterval < 10) this.#config.tickInterval = 10;
if (wasRunning && this.#config.enabled) this.start();
}
#tick(): void {
this.#tickCount++;
const now = new Date();
const payload = this.#makePayload(now);
// Primary tick
if (this.#config.channels.includes("tick")) {
this.#broadcast("clock:tick", payload);
}
// 5-minute tick
if (
this.#config.channels.includes("minute:5") &&
this.#tickCount > 1 &&
now.getMinutes() % 5 === 0 &&
now.getSeconds() < this.#config.tickInterval
) {
this.#broadcast("clock:minute:5", payload);
}
// Hourly tick
const currentHour = now.getUTCHours();
if (
this.#config.channels.includes("hourly") &&
this.#lastHour !== -1 &&
currentHour !== this.#lastHour
) {
this.#broadcast("clock:hourly", payload);
}
this.#lastHour = currentHour;
// Daily tick
const currentDay = now.getUTCDay();
if (
this.#config.channels.includes("daily") &&
this.#lastDay !== -1 &&
currentDay !== this.#lastDay
) {
this.#broadcast("clock:daily", payload);
}
this.#lastDay = currentDay;
}
#makePayload(now: Date): ClockPayload {
return {
timestamp: now.getTime(),
isoString: now.toISOString(),
hour: now.getUTCHours(),
minute: now.getUTCMinutes(),
second: now.getUTCSeconds(),
dayOfWeek: now.getUTCDay(),
tickCount: this.#tickCount,
};
}
}