/** * @blockcast/fec-worker — Main-Thread Client * * Wraps one Worker per track instance. Provides typed API for the * FecWorkerCommand/FecWorkerEvent protocol with zero-copy ArrayBuffer transfers. * * Constructor accepts either a URL (creates Worker internally) or a pre-created * Worker instance (for testing via constructor injection). */ import type { FecWorkerCommand, FecSymbolCommand, FecWorkerEvent, FecTrackConfig, FecTrackStats, FecBlockSnapshot, FrameMeta, FecWorkerErrorCode, } from "./fec-worker-types.js"; export class FecWorkerClient { readonly #worker: Worker; #onFrame: ((data: ArrayBuffer, meta: FrameMeta) => void) | null = null; #onSnapshot: ((stats: FecTrackStats) => void) | null = null; #onBlockUpdate: ((block: FecBlockSnapshot) => void) | null = null; #onCleanupComplete: ((sbn: number) => void) | null = null; #onError: ((message: string, code?: FecWorkerErrorCode) => void) | null = null; #cleanupInFlight: Set | null = null; #queuedCleanupSbns = new Set(); #pendingResets = 0; #resetFailed = false; #disposed = false; // Symbols fed during the current task, sent as one feedSymbols message // when the task's microtasks run (BLO-36599). A packet stream delivers many // symbols per task, and each postMessage is a structured-clone round trip // on the page thread; the batch boundary is the task, not a size or timer. #pendingSymbols: FecSymbolCommand[] = []; #pendingTransfer: Transferable[] = []; #flushQueued = false; // Batching starts only once the Worker announced it accepts feedSymbols. #batchSymbols = false; // The Worker posts blockUpdate events only to a client that subscribed, // which this client does once a callback is registered and the Worker // announced the command (BLO-36599). #blockUpdatesAnnounced = false; #blockUpdatesSubscribed = false; /** * Create a FecWorkerClient. * @param workerOrUrl - Either a Worker instance (for testing) or a URL to create one */ constructor(workerOrUrl: Worker | URL) { if (workerOrUrl instanceof URL) { this.#worker = new Worker(workerOrUrl, { type: "module" }); } else { this.#worker = workerOrUrl; } this.#worker.onmessage = (e: MessageEvent) => this.#handleEvent(e.data); this.#worker.onerror = (e) => { this.#onError?.(formatWorkerError(e), "worker-runtime"); }; this.#worker.onmessageerror = (e) => { this.#onError?.(formatWorkerMessageError(e), "worker-message"); }; } /** * Configure the Worker with FEC parameters and WASM module. * `interleaveMs: 0` is a valid no-span catalog value; in that mode the * caller supplies a positive receiver-local `deliveryWindowMs`. * wasmModule is transferred (not neutered — WebAssembly.Module is shareable). */ configure( config: FecTrackConfig, wasmModule: WebAssembly.Module, ): void { if (this.#disposed) throw new Error("[FecWorkerClient] disposed"); this.#flushSymbols(); const cmd: FecWorkerCommand = { type: "configure", config, wasmModule, }; // WebAssembly.Module is structured-cloneable, NOT transferable. // Pass it in the message body (Chrome clones it automatically). this.#worker.postMessage(cmd); } /** * Feed a source symbol to the Worker. data ArrayBuffer is transferred (zero-copy). * Caller must not access data after this call — it is transferred with the * task's symbol batch, before any later command of this client. */ feedSource( ssId: number, data: ArrayBuffer, ts: number, ): void { if (this.#disposed) throw new Error("[FecWorkerClient] feedSource on a disposed client"); this.#queueSymbol({ type: "feedSource", ssId, data, ts }, data); } /** * Feed a repair symbol to the Worker. data ArrayBuffer is transferred (zero-copy). * Caller must not access data after this call — it is detached. * * Carries the wire-level Repair FEC Payload ID fields (ISO 23008-1 §C.5.3): * - ssStart: SS_ID of first source symbol in the block (any uint32; the block key) * - ssbLength: authored source-symbol count (1..configured K) * - rsId: repair symbol index within the block */ feedRepair( ssStart: number, ssbLength: number, rsId: number, data: ArrayBuffer, ts: number, ): void { if (this.#disposed) throw new Error("[FecWorkerClient] feedRepair on a disposed client"); this.#queueSymbol({ type: "feedRepair", ssStart, ssbLength, rsId, data, ts }, data); } #queueSymbol(symbol: FecSymbolCommand, data: ArrayBuffer): void { if (!this.#batchSymbols) { this.#worker.postMessage(symbol, [data]); return; } this.#pendingSymbols.push(symbol); this.#pendingTransfer.push(data); if (this.#flushQueued) return; this.#flushQueued = true; queueMicrotask(() => { this.#flushQueued = false; this.#flushSymbols(); }); } /** * Send the symbols fed so far. Every other command calls this first, so a * cleanup barrier, reset or signature still follows all earlier symbols. */ #flushSymbols(): void { if (this.#pendingSymbols.length === 0 || this.#disposed) return; const symbols = this.#pendingSymbols; const transfer = this.#pendingTransfer; this.#pendingSymbols = []; this.#pendingTransfer = []; this.#worker.postMessage( { type: "feedSymbols", symbols } satisfies FecWorkerCommand, transfer, ); } /** * Submit relay block signature for verification. sig and hash are transferred. */ relayBlockSig(sbn: number, sig: ArrayBuffer, hash: ArrayBuffer): void { if (this.#disposed) throw new Error("[FecWorkerClient] relayBlockSig on a disposed client"); this.#flushSymbols(); this.#worker.postMessage( { type: "relayBlockSig", sbn, sig, hash } satisfies FecWorkerCommand, [sig, hash], ); } /** * Trigger cleanup after a block's inactivity deadline. `currentSbn` is the * block key, the block's SS_Start. * * While one queue-drain barrier is in flight, later SBNs are coalesced into * the next command without losing their individual acknowledgement identity. */ cleanup(currentSbn: number): void { if (this.#disposed) throw new Error("[FecWorkerClient] cleanup on a disposed client"); if ( !Number.isInteger(currentSbn) || currentSbn < 0 || currentSbn > 0xffff_ffff ) { throw new Error("[FecWorkerClient] cleanup: SBN must be a uint32"); } if (this.#cleanupInFlight) { this.#queuedCleanupSbns.add(currentSbn); return; } this.#postCleanup(new Set([currentSbn])); } /** Reset this track's decoder epoch while preserving Worker configuration. */ reset(): void { if (this.#disposed) throw new Error("[FecWorkerClient] reset on a disposed client"); this.#flushSymbols(); this.#pendingResets++; this.#cleanupInFlight = null; this.#queuedCleanupSbns.clear(); this.#worker.postMessage({ type: "reset" } satisfies FecWorkerCommand); } #postCleanup(sbns: Set): void { // The cleanup is a queue-drain barrier: every symbol fed before it is // posted must reach the Worker first. this.#flushSymbols(); this.#cleanupInFlight = sbns; const orderedSbns = [...sbns].sort((a, b) => a - b); this.#worker.postMessage( { type: "cleanup", sbns: orderedSbns } satisfies FecWorkerCommand, ); } /** Register callback for decoded/recovered frames (FEC-03). */ onFrame(cb: (data: ArrayBuffer, meta: FrameMeta) => void): void { this.#onFrame = cb; } /** Register callback for periodic stats snapshots (FEC-04). */ onSnapshot(cb: (stats: FecTrackStats) => void): void { this.#onSnapshot = cb; } /** * Register callback for individual block state changes. The Worker posts * them only once asked, so registering subscribes (after the Worker * announces `subscribeBlockUpdates`). An older Worker that does not * announce it posts them unasked. */ onBlockUpdate(cb: (block: FecBlockSnapshot) => void): void { this.#onBlockUpdate = cb; this.#subscribeBlockUpdates(); } #subscribeBlockUpdates(): void { if ( this.#disposed || this.#blockUpdatesSubscribed || !this.#blockUpdatesAnnounced || !this.#onBlockUpdate ) return; this.#blockUpdatesSubscribed = true; this.#worker.postMessage( { type: "subscribeBlockUpdates" } satisfies FecWorkerCommand, ); } /** Register callback fired after the Worker drains all commands queued before cleanup. */ onCleanupComplete(cb: (sbn: number) => void): void { this.#onCleanupComplete = cb; } /** Register callback for Worker errors. */ onError(cb: (message: string, code?: FecWorkerErrorCode) => void): void { this.#onError = cb; } /** * True once dispose() ran. A disposed client cannot decode, fill, or * acknowledge anything, so routing symbols to it discards them; every * command method (feedSource, feedRepair, relayBlockSig, cleanup, reset) * throws rather than pretend otherwise. Callers check this first. */ get disposed(): boolean { return this.#disposed; } /** * Dispose the Worker. Sends dispose command then terminates. * Idempotent — safe to call multiple times. */ dispose(): void { if (this.#disposed) return; this.#disposed = true; // A terminated Worker decodes nothing; symbols still batched are dropped. this.#pendingSymbols = []; this.#pendingTransfer = []; this.#cleanupInFlight = null; this.#queuedCleanupSbns.clear(); this.#worker.postMessage({ type: "dispose" } satisfies FecWorkerCommand); this.#worker.terminate(); } #handleEvent(event: FecWorkerEvent): void { if (event.type === "capabilities") { if (Array.isArray(event.commands) && event.commands.includes("feedSymbols")) { this.#batchSymbols = true; } if (Array.isArray(event.commands) && event.commands.includes("subscribeBlockUpdates")) { this.#blockUpdatesAnnounced = true; this.#subscribeBlockUpdates(); } return; } if (event.type === "resetComplete") { this.#pendingResets = Math.max(0, this.#pendingResets - 1); this.#resetFailed = false; return; } if (event.type === "error") { if (event.code === "reset-failed") { this.#pendingResets = Math.max(0, this.#pendingResets - 1); this.#resetFailed = true; } if (event.code) this.#onError?.(event.message, event.code); else this.#onError?.(event.message); return; } if (this.#pendingResets > 0 || this.#resetFailed) return; switch (event.type) { case "frame": this.#onFrame?.(event.data, event.meta); break; case "snapshot": this.#onSnapshot?.(event.stats); break; case "blockUpdate": this.#onBlockUpdate?.(event.block); break; case "cleanupComplete": for (const sbn of event.sbns) { this.#onCleanupComplete?.(sbn); } this.#cleanupInFlight = null; if (this.#queuedCleanupSbns.size > 0) { const next = this.#queuedCleanupSbns; this.#queuedCleanupSbns = new Set(); this.#postCleanup(next); } break; } } } function formatWorkerError(event: ErrorEvent): string { const parts = ["Worker error"]; if (event.message) parts.push(event.message); if (event.filename) { const location = [event.filename]; if (event.lineno) location.push(String(event.lineno)); if (event.colno) location.push(String(event.colno)); parts.push(`at ${location.join(":")}`); } if (event.error instanceof Error) { if (event.error.message && event.error.message !== event.message) { parts.push(event.error.message); } if (event.error.stack) parts.push(event.error.stack); } else if (event.error != null) { parts.push(String(event.error)); } return parts.join(": "); } function formatWorkerMessageError(event: MessageEvent): string { const data = event.data; const detail = data == null ? "" : data instanceof Error ? `: ${data.message}` : `: ${Object.prototype.toString.call(data)}`; return `Worker messageerror: failed to deserialize worker message${detail}`; }