115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
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<number, number>();
|
||
|
|
private timer: ReturnType<typeof setInterval> | 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<CommandEvent, "tsMs"> & { tsMs?: number }): void {
|
||
|
|
if (this.playSessionId === null) return;
|
||
|
|
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
|
||
|
|
this.buffer.push({ ...event, tsMs });
|
||
|
|
}
|
||
|
|
|
||
|
|
async flush(): Promise<void> {
|
||
|
|
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();
|