import type { CandidateIdentity, MainToWorkerMessage, ProctoringConfig, PartialEvent, SessionEvent, WorkerToMainMessage, } from "@a4anthony/proctorkit-types"; import { EventQueue, sessionDbName } from "../queue/event-queue.js"; import { DeliveryReadinessError } from "../delivery-readiness.js"; import { Uploader } from "../uploader/uploader.js"; import { newId } from "../internal/ids.js"; import { SDK_BUILD_INFO } from "../build-info.js"; const DELIVERY_CANARY_TIMEOUT_MS = 8_000; export interface WorkerHost { postMessage(msg: WorkerToMainMessage): void; onMessage(handler: (msg: MainToWorkerMessage) => void): void; } export interface WorkerCoreDeps { queueFactory?: (sessionId: string) => EventQueue; uploaderFactory?: (queue: EventQueue, config: ProctoringConfig) => Uploader; newEventId?: () => string; now?: () => number; /** Internal test seam; never exposed through ProctoringConfig. */ deliveryCanaryTimeoutMs?: number; } export class WorkerCore { private queue: EventQueue | null = null; private uploader: Uploader | null = null; private config: ProctoringConfig | null = null; private candidate: CandidateIdentity | undefined; private readonly deps: Required; constructor( private readonly host: WorkerHost, deps: WorkerCoreDeps = {}, ) { this.deps = { queueFactory: deps.queueFactory ?? ((sessionId) => new EventQueue({ dbName: sessionDbName(sessionId) })), uploaderFactory: deps.uploaderFactory ?? ((queue, config) => new Uploader({ queue, ingestUrl: config.ingestUrl, sessionId: config.sessionId, appId: config.appId, candidate: () => this.candidate, batchSize: config.batchSize, batchIntervalMs: config.batchIntervalMs, maxRetries: config.maxRetries, })), newEventId: deps.newEventId ?? (() => newId("evt")), now: deps.now ?? Date.now, deliveryCanaryTimeoutMs: deps.deliveryCanaryTimeoutMs ?? DELIVERY_CANARY_TIMEOUT_MS, }; this.host.onMessage((msg) => { void this.handle(msg); }); } private async handle(msg: MainToWorkerMessage): Promise { switch (msg.type) { case "init": await this.init(msg.config); return; case "identify": this.candidate = msg.candidate; return; case "emit": await this.emit(msg.event); return; case "flush": await this.uploader?.flush({ keepalive: true }); return; case "stop": await this.stop(); return; } } private async init(config: ProctoringConfig): Promise { try { this.config = config; this.queue = this.deps.queueFactory(config.sessionId); await this.queue.open(); this.uploader = this.deps.uploaderFactory(this.queue, config); this.uploader.on({ onUploaded: (batchId, count, accepted, rejected) => this.host.postMessage({ type: "uploaded", batchId, count, accepted, rejected, }), onUploadFailed: (batchId, attempt, error) => this.host.postMessage({ type: "upload-failed", batchId, attempt, error: error.message, }), }); const deliveryCanary = this.createEvent({ kind: "sdk.delivery.ready", payload: { phase: "startup", ...SDK_BUILD_INFO, }, }); await this.uploader.verifyDelivery(deliveryCanary, this.deps.deliveryCanaryTimeoutMs); this.uploader.start(); this.host.postMessage({ type: "ready" }); if (this.queue.storageMode() === "memory") { this.host.postMessage({ type: "storage-fallback", mode: "memory", reason: this.queue.storageFallbackReason() ?? "IndexedDB unavailable; using in-memory queue", }); } // Reclaim per-session databases leaked by earlier sessions on // this origin (each session opens its own `proctoring-sess-*` // DB and only the current one is in use). Fire-and-forget and // never throwing, so it can't delay or fail an otherwise-healthy // init. Keep the current session's DB. This is what unwinds an // already-accumulated leak that would otherwise eventually push // the origin over its storage quota and make open() throw. void EventQueue.sweepStale(new Set([sessionDbName(config.sessionId)])); } catch (err) { const message = err instanceof Error ? err.message : String(err); await this.uploader?.stop().catch(() => undefined); await this.queue?.close().catch(() => undefined); this.config = null; this.queue = null; this.uploader = null; this.host.postMessage({ type: "init-failed", error: message, ...(err instanceof DeliveryReadinessError ? { code: err.code } : {}), }); } } private async emit(event: PartialEvent): Promise { if (!this.queue || !this.config) return; const stored = this.createEvent(event); const { dropped } = await this.queue.enqueue(stored); this.host.postMessage({ type: "queued", eventId: stored.id }); if (dropped > 0) { this.host.postMessage({ type: "dropped", reason: "overflow", count: dropped }); } } private createEvent(event: PartialEvent): SessionEvent { if (!this.config) { throw new Error("worker is not configured"); } return { id: event.id ?? this.deps.newEventId(), sessionId: this.config.sessionId, kind: event.kind, timestamp: event.timestamp ?? this.deps.now(), ...(event.payload !== undefined ? { payload: event.payload } : {}), // Stamp the per-device fingerprint on every event so the // server can render multi-device timelines. The // fingerprint is computed once on the main thread and // forwarded via the init message; the worker never // recomputes it because every event in a single SDK // session has the same device id by definition. ...(this.config.fingerprintId !== undefined ? { fingerprintId: this.config.fingerprintId } : {}), }; } private async stop(): Promise { // Drain the queue to the server *before* stopping the uploader. // stop() sets `stopped` and aborts in-flight work, after which // sendBatch() short-circuits on `stopped && !keepalive` — so a // flush after stop() never POSTs. The client's teardown enqueues // a terminal `session.ended` immediately before this stop; draining // first is what carries it to the server. drain() loops until the // queue is empty, so a single trailing event is delivered. await this.uploader?.drain(); await this.uploader?.stop(); // If drain delivered everything, reclaim the per-session database // so it doesn't linger and contribute to origin-storage exhaustion. // If anything is still queued (offline teardown), keep it — a later // page load can still flush it — so close() without deleting. const pending = (await this.queue?.size()) ?? 0; if (this.queue && pending === 0) { await this.queue.destroy(); } else { await this.queue?.close(); } this.host.postMessage({ type: "stopped" }); } }