Files
sexy/lib/buttplug/event-buffer.ts
T

114 lines
3.7 KiB
TypeScript
Raw Permalink Normal View History

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 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 named - any completed session
* can be replayed directly, there's no separate "save as recording" step.
*/
class EventBuffer {
private buffer: CommandEvent[] = [];
private sessionId: number | null = null;
private sessionStartedAt = 0;
private sessionDeviceIdByDeviceIndex = new Map<number, number>();
private timer: ReturnType<typeof setInterval> | null = null;
start(sessionId: number, sessionStartedAt: number): void {
this.sessionId = sessionId;
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.sessionId === 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.sessionId === null) return;
const events = this.drain();
try {
await fetch(`/api/sessions/${this.sessionId}/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.sessionId = 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.sessionId === null || typeof navigator === "undefined") return;
const events = this.drain();
navigator.sendBeacon(
`/api/sessions/${this.sessionId}/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();