import type { CommandEvent } from "./types"; const FLUSH_INTERVAL_MS = 4000; interface ApiEvent { sessionDeviceId: number; tsMs: number; commandType: CommandEvent["commandType"]; featureIndex: number; value: number; durationMs?: number; } /** * Buffers every dispatched command (live or replay) and flushes it in * batches to the play-session's events endpoint, so a dragged slider never * fires one HTTP request per tick. Every command is always recorded here * regardless of whether the session is later saved as a named recording - * "recording" is a save decision made after the fact, not a separate * capture pipeline (see lib/db/queries/recordings.ts). */ class EventBuffer { private buffer: CommandEvent[] = []; private playSessionId: number | null = null; private sessionStartedAt = 0; private sessionDeviceIdByDeviceIndex = new Map(); private timer: ReturnType | null = null; start(playSessionId: number, sessionStartedAt: number): void { this.playSessionId = playSessionId; this.sessionStartedAt = sessionStartedAt; this.sessionDeviceIdByDeviceIndex = new Map(); this.buffer = []; if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS); if (typeof window !== "undefined") { window.addEventListener("beforeunload", this.flushBeacon); document.addEventListener("visibilitychange", this.onVisibilityChange); } } registerSessionDevice(deviceIndex: number, sessionDeviceId: number): void { this.sessionDeviceIdByDeviceIndex.set(deviceIndex, sessionDeviceId); } record(event: Omit & { tsMs?: number }): void { if (this.playSessionId === null) return; const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt; this.buffer.push({ ...event, tsMs }); } async flush(): Promise { if (this.buffer.length === 0 || this.playSessionId === null) return; const events = this.drain(); try { await fetch(`/api/play-sessions/${this.playSessionId}/events`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events }), keepalive: true, }); } catch { // Best-effort telemetry - dropping a batch on network hiccup is // preferable to blocking or crashing live device control. } } stop(): void { void this.flush(); if (this.timer) clearInterval(this.timer); this.timer = null; this.playSessionId = null; if (typeof window !== "undefined") { window.removeEventListener("beforeunload", this.flushBeacon); document.removeEventListener("visibilitychange", this.onVisibilityChange); } } private onVisibilityChange = (): void => { if (document.visibilityState === "hidden") this.flushBeacon(); }; private flushBeacon = (): void => { if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return; const events = this.drain(); navigator.sendBeacon( `/api/play-sessions/${this.playSessionId}/events`, new Blob([JSON.stringify({ events })], { type: "application/json" }), ); }; private drain(): ApiEvent[] { const events = this.buffer; this.buffer = []; return events .map((e): ApiEvent | null => { const sessionDeviceId = this.sessionDeviceIdByDeviceIndex.get(e.deviceIndex); if (sessionDeviceId === undefined) return null; return { sessionDeviceId, tsMs: e.tsMs, commandType: e.commandType, featureIndex: e.featureIndex, value: e.value, durationMs: e.durationMs, }; }) .filter((e): e is ApiEvent => e !== null); } } export const eventBuffer = new EventBuffer();