@blockcast/fec-worker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +213 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/fec-worker-client.d.ts +80 -0
  4. package/dist/fec-worker-client.d.ts.map +1 -0
  5. package/dist/fec-worker-client.js +270 -0
  6. package/dist/fec-worker-client.js.map +1 -0
  7. package/dist/fec-worker-types.d.ts +252 -0
  8. package/dist/fec-worker-types.d.ts.map +1 -0
  9. package/dist/fec-worker-types.js +11 -0
  10. package/dist/fec-worker-types.js.map +1 -0
  11. package/dist/fec-worker.d.ts +14 -0
  12. package/dist/fec-worker.d.ts.map +1 -0
  13. package/dist/fec-worker.js +565 -0
  14. package/dist/fec-worker.js.map +7 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/shred-fec-worker.d.ts +20 -0
  20. package/dist/shred-fec-worker.d.ts.map +1 -0
  21. package/dist/shred-fec-worker.js +349 -0
  22. package/dist/shred-fec-worker.js.map +7 -0
  23. package/dist/shred-worker-client.d.ts +104 -0
  24. package/dist/shred-worker-client.d.ts.map +1 -0
  25. package/dist/shred-worker-client.js +181 -0
  26. package/dist/shred-worker-client.js.map +1 -0
  27. package/dist/shred-worker-types.d.ts +179 -0
  28. package/dist/shred-worker-types.d.ts.map +1 -0
  29. package/dist/shred-worker-types.js +47 -0
  30. package/dist/shred-worker-types.js.map +1 -0
  31. package/dist/transfer.d.ts +9 -0
  32. package/dist/transfer.d.ts.map +1 -0
  33. package/dist/transfer.js +13 -0
  34. package/dist/transfer.js.map +1 -0
  35. package/package.json +54 -0
  36. package/src/__tests__/fec-worker-alta.test.ts +231 -0
  37. package/src/__tests__/fec-worker-cleanup-integration.test.ts +250 -0
  38. package/src/__tests__/fec-worker-pending-queue.test.ts +315 -0
  39. package/src/__tests__/fec-worker-repair-size.test.ts +1029 -0
  40. package/src/__tests__/fec-worker-wasm-contract.test.ts +84 -0
  41. package/src/__tests__/shred-fec-worker.test.ts +448 -0
  42. package/src/fec-worker-client.test.ts +530 -0
  43. package/src/fec-worker-client.ts +309 -0
  44. package/src/fec-worker-types.test.ts +400 -0
  45. package/src/fec-worker-types.ts +268 -0
  46. package/src/fec-worker.ts +1048 -0
  47. package/src/index.ts +32 -0
  48. package/src/shred-fec-worker.ts +558 -0
  49. package/src/shred-worker-client.test.ts +182 -0
  50. package/src/shred-worker-client.ts +222 -0
  51. package/src/shred-worker-types.ts +194 -0
  52. package/src/transfer.test.ts +24 -0
  53. package/src/transfer.ts +12 -0
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * @blockcast/fec-worker — Worker Entry Point
3
+ *
4
+ * Runs inside DedicatedWorkerGlobalScope. Receives FecWorkerCommand messages,
5
+ * drives the MmtFecDecoder WASM module, emits FecWorkerEvent messages back.
6
+ *
7
+ * WASM bindings are resolved via:
8
+ * 1. (self as any).__MMT_WASM_BINDINGS__ — set by consumer's loader/importmap
9
+ * 2. Falls back to error if not available (no defaults)
10
+ *
11
+ * This file must NOT import DOM globals incompatible with Worker scope.
12
+ */
13
+
14
+ import type {
15
+ FecWorkerCommand,
16
+ FecWorkerEvent,
17
+ FecTrackConfig,
18
+ FecTrackStats,
19
+ FecBlockSnapshot,
20
+ FrameMeta,
21
+ } from "./fec-worker-types.js";
22
+ import { toOwnedArrayBuffer } from "./transfer.js";
23
+
24
+ // --- WASM binding types (from mmt-wasm) ---
25
+ // Signatures MUST match a freshly generated external mmt_wasm.d.ts exactly.
26
+ // The decoder derives SBN/ESI internally — do not pre-compute.
27
+ interface WasmFecDecoder {
28
+ /** ISO 23008-1 §C.5.2: ss_id is the flat 32-bit Source Symbol ID. */
29
+ add_source(
30
+ ss_id: number,
31
+ data: Uint8Array,
32
+ timestamp: number,
33
+ ): Uint8Array | undefined;
34
+ /** ISO 23008-1 §C.5.3: repair ESI = ssb_length + rs_id, SBN = ss_start / ssb_length. */
35
+ add_repair(
36
+ ss_start: number,
37
+ ssb_length: number,
38
+ rs_id: number,
39
+ data: Uint8Array,
40
+ timestamp: number,
41
+ ): Uint8Array | undefined;
42
+ configure_params(k: number, symbolSize: number): void;
43
+ /** Block-scoped counterpart driven by the manager's exact deadlines. */
44
+ cleanup_blocks_detailed(sbns: Uint32Array): Uint8Array;
45
+ /** Remove decoder blocks below its configured live-SBN interleave horizon. */
46
+ cleanup_by_sbn(current_sbn: number): Uint32Array;
47
+ flush(): void;
48
+ pending_blocks(): number;
49
+ reset_stats(): void;
50
+ get_stats(): {
51
+ source_packets: bigint;
52
+ repair_packets: bigint;
53
+ blocks_complete: bigint;
54
+ blocks_recovered: bigint;
55
+ blocks_failed: bigint;
56
+ bytes_recovered: bigint;
57
+ recovery_rate(): number;
58
+ };
59
+ free(): void;
60
+ }
61
+
62
+ interface WasmBindings {
63
+ initSync(module: { module: WebAssembly.Module }): unknown;
64
+ MmtFecDecoder: new (
65
+ interleaveDepth: number,
66
+ greenFillEnabled: boolean,
67
+ ) => WasmFecDecoder;
68
+ }
69
+
70
+ // --- Worker state (local to this Worker instance) ---
71
+
72
+ let wasmDecoder: WasmFecDecoder | null = null;
73
+ let config: FecTrackConfig | null = null;
74
+ let altaEnabled = false;
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
77
+ let snapshotTimer: ReturnType<typeof setInterval> | null = null;
78
+
79
+ // Block tracking
80
+ const blocks = new Map<number, FecBlockSnapshot>();
81
+ const acceptedSourceEsis = new Map<number, Set<number>>();
82
+ const acceptedRepairEsis = new Map<number, Set<number>>();
83
+ const UINT32_MAX = 0xffff_ffff;
84
+ const UINT24_MAX = 0xff_ffff;
85
+ // ISO/IEC 23008-1 AL-FEC signaling carries interleave_depth in one byte.
86
+ const MAX_INTERLEAVE_DEPTH = 0xff;
87
+ // Rust stores T as u16 and this decoder profile fixes RFC 6330 Al=8.
88
+ const MAX_SYMBOL_SIZE = 0xfff8;
89
+ // RFC 6330 systematic table limit, enforced by the vendored raptorq decoder.
90
+ const MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56_403;
91
+
92
+ // Aggregate counters
93
+ let sourceSymbols = 0;
94
+ let repairSymbols = 0;
95
+ let blocksComplete = 0;
96
+ let blocksRecovered = 0;
97
+ let blocksFailed = 0;
98
+ let greenFillFrames = 0;
99
+ let altaVerified = 0;
100
+ let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
101
+ const altaFailedDetails: Array<{
102
+ seq: number;
103
+ error: string;
104
+ authLen: number;
105
+ payloadLen: number;
106
+ path: string; // "direct" | "recovered" | "pending-retry"
107
+ }> = [];
108
+ let altaPending = 0; // Unverifiable but not tamper (no trailer, verifier not ready, anchor missing)
109
+ let altaPendingEvictedCap = 0; // Incoming "pending" packets rejected because the queue was at cap after stale-sweep (flood indicator)
110
+ let altaPendingEvictedTtl = 0; // Deferred-queue entries dropped by TTL (retry window expired)
111
+ let altaError = 0; // verifyDetached threw — infrastructure flake, not tamper
112
+ let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnabled was forced off
113
+ let relayBlocksVerified = 0;
114
+ let relayBlocksFailed = 0;
115
+ let liveSbn = 0;
116
+ // Highest SBN that has fallen strictly below the decoder's retained
117
+ // interleave window. Numeric ordering is per decoder epoch; reset/configure
118
+ // starts a new epoch before the 32-bit source-symbol counter can wrap.
119
+ let retiredSbnFrontier = -1;
120
+ let repairSymbolSizeMismatch = 0;
121
+
122
+ // Recovery timing
123
+ const recoveryTimes: number[] = [];
124
+
125
+ // ── Deferred ALTA verification queue (08-07 Path 3) ───────────────────────
126
+ //
127
+ // When an ALTA packet arrives whose MAC references point at sequences not yet
128
+ // in the verifier's ring buffer, we can't anchor trust — verify returns "No
129
+ // trust anchor". Typical cause: a predecessor packet was lost and hasn't been
130
+ // FEC-recovered yet. Rather than drop these, hold them in a bounded FIFO and
131
+ // retry verification after every successful verify (direct or FEC-recovered)
132
+ // that seeds the ring buffer with new raw bytes.
133
+ //
134
+ // Zero-copy (1B-B): the queue entry stores a Uint8Array view into the
135
+ // original `data` ArrayBuffer. JS GC keeps the buffer alive via the view
136
+ // reference; no bytes are copied on queue.
137
+ //
138
+ // Timeout entries (TTL exceeded) and cap-rejections count as `altaPending`
139
+ // (2C-B): they're not tamper indicators, they're just packets that couldn't
140
+ // be authenticated within the receive window — either loss or, in the cap
141
+ // case, a flood of unanchorable arrivals (real or attacker-driven) that
142
+ // exceeded the queue budget. See enqueuePendingVerify for the rate-limit
143
+ // protocol.
144
+ interface PendingVerify {
145
+ seq: number;
146
+ auth: Uint8Array;
147
+ payload: Uint8Array;
148
+ arrivalTs: number;
149
+ }
150
+ const pendingVerify = new Map<number, PendingVerify>();
151
+ let pendingVerifyCap = 0;
152
+ let pendingVerifyTtlMs = 0;
153
+
154
+ // --- Helpers ---
155
+
156
+ function emit(event: FecWorkerEvent, transfer?: Transferable[]): void {
157
+ self.postMessage(event, transfer ?? []);
158
+ }
159
+
160
+ function isUint32(value: number): boolean {
161
+ return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX;
162
+ }
163
+
164
+ function isTerminalBlock(block: FecBlockSnapshot): boolean {
165
+ return block.state !== "collecting";
166
+ }
167
+
168
+ function retireAcceptedIds(sbn: number): void {
169
+ acceptedSourceEsis.delete(sbn);
170
+ acceptedRepairEsis.delete(sbn);
171
+ }
172
+
173
+ function clearBlockTracking(): void {
174
+ blocks.clear();
175
+ acceptedSourceEsis.clear();
176
+ acceptedRepairEsis.clear();
177
+ pendingVerify.clear();
178
+ recoveryTimes.length = 0;
179
+ liveSbn = 0;
180
+ retiredSbnFrontier = -1;
181
+ }
182
+
183
+ /**
184
+ * Retire terminal diagnostics only after the decoder's monotonic interleave
185
+ * horizon proves that the SBN can no longer be valid out-of-order media.
186
+ * Admission keeps the compact frontier as the durable tombstone, so deletion
187
+ * never reopens a finalized block.
188
+ */
189
+ function trimBlockRetention(): void {
190
+ for (const [sbn, block] of blocks) {
191
+ if (!isTerminalBlock(block) || sbn > retiredSbnFrontier) continue;
192
+ blocks.delete(sbn);
193
+ retireAcceptedIds(sbn);
194
+ }
195
+ }
196
+
197
+ function advanceMediaHorizon(sbn: number): void {
198
+ if (!config || sbn <= liveSbn) return;
199
+ liveSbn = sbn;
200
+ const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
201
+ retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
202
+ trimBlockRetention();
203
+ }
204
+
205
+ function markBlockTerminal(
206
+ block: FecBlockSnapshot,
207
+ state: "complete" | "recovered" | "failed",
208
+ ): void {
209
+ block.state = state;
210
+ block.completedTs = performance.now();
211
+ retireAcceptedIds(block.sbn);
212
+ trimBlockRetention();
213
+ }
214
+
215
+ function synchronizeDecoderHorizon(): void {
216
+ if (!wasmDecoder) return;
217
+ const expired = wasmDecoder.cleanup_by_sbn(liveSbn);
218
+ for (const sbn of expired) {
219
+ const block = blocks.get(sbn);
220
+ if (!block || isTerminalBlock(block)) {
221
+ retireAcceptedIds(sbn);
222
+ continue;
223
+ }
224
+ markBlockTerminal(block, "failed");
225
+ blocksFailed++;
226
+ emitBlockUpdate(block);
227
+ }
228
+ trimBlockRetention();
229
+ }
230
+
231
+ /**
232
+ * Run detached ALTA verify against the current verifier. Four-state result:
233
+ * - "valid": crypto passed, packet authentic. Caller increments altaVerified.
234
+ * - "failed": crypto confirmed tamper (MAC/sig mismatch, hash chain mismatch).
235
+ * Caller increments altaFailed. Not retryable.
236
+ * - "pending": anchor missing (MAC refs not in buffer, no signature, not
237
+ * early-warmup). Caller may enqueue for retry after the next
238
+ * successful verify seeds the ring buffer.
239
+ * - "error": verifier threw — WASM trap, unmarshal panic, OOM. Caller
240
+ * increments altaError (infrastructure flake, not tamper).
241
+ * The distinction matters for ops alerting: a spike in
242
+ * altaError indicates a runtime problem, not an attack.
243
+ */
244
+ let _lastVerifyError = "";
245
+ function runDetachedVerify(
246
+ auth: Uint8Array,
247
+ payload: Uint8Array,
248
+ ): "valid" | "failed" | "pending" | "error" {
249
+ if (!altaVerifier) return "pending";
250
+ try {
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+ const r: any = altaVerifier.verifyDetached(auth, payload);
253
+ if (r.valid) {
254
+ _lastVerifyError = "";
255
+ return "valid";
256
+ }
257
+ const err = (r.error as string | undefined) ?? "";
258
+ _lastVerifyError = err;
259
+ // The bootstrap-friendly verifier returns this specific error string when
260
+ // the only obstacle to verification is a missing anchor (see alta-rs
261
+ // verifier.rs verify()). Distinguishing this from tamper cases keeps us
262
+ // from queuing packets that are definitively invalid.
263
+ if (err.startsWith("No trust anchor")) return "pending";
264
+ return "failed";
265
+ } catch (e) {
266
+ // Ratelimit: first ~N occurrences get logged. A sustained exception
267
+ // stream indicates a real problem — the altaError counter is the
268
+ // durable signal.
269
+ if (altaError < 5) {
270
+ console.warn("[FecWorker] verifyDetached threw:", e);
271
+ }
272
+ return "error";
273
+ }
274
+ }
275
+
276
+ /** Extract u32 BE sequence number from the ALTA authenticator header (offset 0..3). */
277
+ function seqFromAuth(auth: Uint8Array): number {
278
+ return (
279
+ ((auth[0]! << 24) | (auth[1]! << 16) | (auth[2]! << 8) | auth[3]!) >>> 0
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Enqueue a packet whose ALTA verification returned "pending" (anchor
285
+ * missing). Dedupes by sequence number.
286
+ *
287
+ * Flood resistance (08-08 review): when the queue is at cap we do NOT
288
+ * evict the oldest entry. The oldest entry is typically the most
289
+ * load-bearing one — it's the packet whose anchor everything else in the
290
+ * queue is waiting on. Evict-oldest-on-insert let an attacker emitting a
291
+ * stream of "No trust anchor" packets flood out legitimate entries and
292
+ * prevent them from ever anchoring. Instead:
293
+ * 1. First try to free stale TTL'd entries (they'd be evicted on next
294
+ * drain anyway — pulling that forward is free).
295
+ * 2. If still full, reject the NEW entry. altaPendingEvictedCap counts
296
+ * these rejections (rate-limit trigger).
297
+ *
298
+ * Stores Uint8Array views into the original data + auth buffers — no copy.
299
+ * The views keep their underlying ArrayBuffers alive via GC references.
300
+ */
301
+ function enqueuePendingVerify(
302
+ auth: Uint8Array,
303
+ payload: Uint8Array,
304
+ ): void {
305
+ const seq = seqFromAuth(auth);
306
+ if (pendingVerify.has(seq)) return;
307
+
308
+ if (pendingVerify.size >= pendingVerifyCap) {
309
+ // Step 1: sweep stale entries so legitimate traffic isn't penalized
310
+ // by TTL'd junk that happens to still occupy slots.
311
+ const now = performance.now();
312
+ for (const [staleSeq, entry] of pendingVerify) {
313
+ if (now - entry.arrivalTs > pendingVerifyTtlMs) {
314
+ pendingVerify.delete(staleSeq);
315
+ altaPendingEvictedTtl++;
316
+ }
317
+ }
318
+ // Step 2: if the queue is still at cap it's a genuine flood —
319
+ // reject the new entry rather than evict an existing one. The
320
+ // rejected packet counts as altaPendingEvictedCap (which is
321
+ // surfaced up through stats for rate-limit alerting).
322
+ if (pendingVerify.size >= pendingVerifyCap) {
323
+ altaPendingEvictedCap++;
324
+ return;
325
+ }
326
+ }
327
+
328
+ pendingVerify.set(seq, {
329
+ seq,
330
+ auth,
331
+ payload,
332
+ arrivalTs: performance.now(),
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Retry every queued entry. Called after every successful verify (direct
338
+ * source or FEC-recovered) — new raw bytes in the verifier's ring buffer may
339
+ * unblock previously-pending MAC references.
340
+ *
341
+ * Three outcomes per entry:
342
+ * - "valid" → remove from queue, altaVerified++
343
+ * - "failed" → remove from queue, altaFailed++ (tamper on retry)
344
+ * - "pending" → keep in queue, unless TTL expired (then evict as altaPending)
345
+ *
346
+ * Iteration order: Map preserves insertion order, so we drain oldest-first.
347
+ * No-op when the queue is empty or the verifier isn't ready.
348
+ */
349
+ function drainPendingVerify(): void {
350
+ if (pendingVerify.size === 0 || !altaVerifier) return;
351
+ const now = performance.now();
352
+ // Collect keys to avoid mutating Map during iteration
353
+ const keys = Array.from(pendingVerify.keys());
354
+ for (const seq of keys) {
355
+ const entry = pendingVerify.get(seq);
356
+ if (!entry) continue;
357
+ if (now - entry.arrivalTs > pendingVerifyTtlMs) {
358
+ pendingVerify.delete(seq);
359
+ altaPendingEvictedTtl++;
360
+ continue;
361
+ }
362
+ const result = runDetachedVerify(entry.auth, entry.payload);
363
+ if (result === "valid") {
364
+ pendingVerify.delete(seq);
365
+ altaVerified++;
366
+ } else if (result === "failed") {
367
+ pendingVerify.delete(seq);
368
+ altaFailed++;
369
+ if (altaFailedDetails.length < 16) {
370
+ altaFailedDetails.push({
371
+ seq: entry.seq,
372
+ error: _lastVerifyError,
373
+ authLen: entry.auth.length,
374
+ payloadLen: entry.payload.length,
375
+ path: "pending-retry",
376
+ });
377
+ }
378
+ } else if (result === "error") {
379
+ // Infrastructure failure — remove from queue, count as altaError.
380
+ // Keeping it around would just re-throw on every subsequent drain.
381
+ pendingVerify.delete(seq);
382
+ altaError++;
383
+ }
384
+ // "pending" → leave in queue for next drain
385
+ }
386
+ }
387
+
388
+ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
389
+ let block = blocks.get(sbn);
390
+ if (!block) {
391
+ if (!config) throw new Error("[FecWorker] not configured");
392
+ const now = performance.now();
393
+ block = {
394
+ sbn,
395
+ state: "collecting",
396
+ sourceReceived: 0,
397
+ repairReceived: 0,
398
+ k: config.k,
399
+ repairCount: config.repairCount,
400
+ firstSymbolTs: now,
401
+ lastSymbolTs: now,
402
+ deadlineTs: now + config.deliveryWindowMs,
403
+ altaVerified: 0,
404
+ relayBlockSigValid: null,
405
+ };
406
+ blocks.set(sbn, block);
407
+ trimBlockRetention();
408
+ }
409
+ return block;
410
+ }
411
+
412
+ function buildStatsSnapshot(): FecTrackStats {
413
+ if (!config) throw new Error("[FecWorker] not configured");
414
+ const decoderStats = wasmDecoder?.get_stats();
415
+ const decoder = decoderStats
416
+ ? {
417
+ sourcePackets: Number(decoderStats.source_packets),
418
+ repairPackets: Number(decoderStats.repair_packets),
419
+ blocksComplete: Number(decoderStats.blocks_complete),
420
+ blocksRecovered: Number(decoderStats.blocks_recovered),
421
+ blocksFailed: Number(decoderStats.blocks_failed),
422
+ bytesRecovered: Number(decoderStats.bytes_recovered),
423
+ pendingBlocks: wasmDecoder?.pending_blocks() ?? 0,
424
+ }
425
+ : undefined;
426
+
427
+ const avgRecoveryMs =
428
+ recoveryTimes.length > 0
429
+ ? recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length
430
+ : 0;
431
+ const maxRecoveryMs =
432
+ recoveryTimes.length > 0 ? Math.max(...recoveryTimes) : 0;
433
+
434
+ // Cap block array to interleave window
435
+ const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
436
+ const blockArray = Array.from(blocks.values())
437
+ .sort((a, b) => b.sbn - a.sbn)
438
+ .slice(0, maxBlocks);
439
+
440
+ return {
441
+ config,
442
+ sourceSymbols,
443
+ repairSymbols,
444
+ blocksComplete,
445
+ blocksRecovered,
446
+ blocksFailed,
447
+ greenFillFrames,
448
+ recoveryRate:
449
+ blocksComplete + blocksRecovered > 0
450
+ ? Math.round(
451
+ (blocksRecovered / (blocksRecovered + blocksFailed)) * 100,
452
+ ) || 0
453
+ : 0,
454
+ altaVerified,
455
+ altaFailed,
456
+ altaPending,
457
+ altaPendingEvictedCap,
458
+ altaPendingEvictedTtl,
459
+ altaError,
460
+ altaInitFailed,
461
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
462
+ altaFailedDetails: altaFailedDetails.slice() as any,
463
+ relayBlocksVerified,
464
+ relayBlocksFailed,
465
+ liveSbn,
466
+ blocks: blockArray,
467
+ interleaveWindowBlocks: config.interleaveDepth,
468
+ avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
469
+ maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
470
+ repairSymbolSizeMismatch,
471
+ decoder,
472
+ };
473
+ }
474
+
475
+ function emitSnapshot(): void {
476
+ if (!config) return;
477
+ emit({ type: "snapshot", stats: buildStatsSnapshot() });
478
+ }
479
+
480
+ function emitBlockUpdate(block: FecBlockSnapshot): void {
481
+ emit({ type: "blockUpdate", block: { ...block } });
482
+ }
483
+
484
+ function emitRecoveredSymbols(
485
+ sbn: number,
486
+ recoveredBlock: Uint8Array,
487
+ block: FecBlockSnapshot,
488
+ ): void {
489
+ if (!config) return;
490
+ const symbolSize = config.symbolSize;
491
+ if (symbolSize <= 0) {
492
+ emit({
493
+ type: "error",
494
+ code: "recovered-symbol-size-unconfigured",
495
+ message: "[FecWorker] recovered block has no configured symbolSize",
496
+ });
497
+ return;
498
+ }
499
+ if (recoveredBlock.byteLength < symbolSize * config.k) {
500
+ emit({
501
+ type: "error",
502
+ code: "recovered-block-too-short",
503
+ message: `[FecWorker] recovered block too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`,
504
+ });
505
+ return;
506
+ }
507
+
508
+ for (let i = 0; i < config.k; i++) {
509
+ const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
510
+
511
+ // Source authentication now runs once at router admission in
512
+ // @blockcast/mmt-manifest-verify. The worker only records that this
513
+ // legacy ALTA-enabled symbol bypassed hot-path verification.
514
+ const recoveredAltaVerified: boolean | null = null;
515
+ if (altaEnabled) altaPending++;
516
+
517
+ const buf = toOwnedArrayBuffer(symbol);
518
+ const meta: FrameMeta = {
519
+ altaVerified: recoveredAltaVerified,
520
+ recovered: true,
521
+ sbn,
522
+ esi: i,
523
+ };
524
+ emit({ type: "frame", data: buf, meta }, [buf]);
525
+ }
526
+ }
527
+
528
+ function startSnapshotTimer(): void {
529
+ if (snapshotTimer !== null) clearInterval(snapshotTimer);
530
+ snapshotTimer = setInterval(() => emitSnapshot(), 200);
531
+ }
532
+
533
+ function resolveWasmBindings(): WasmBindings {
534
+ const bindings = (self as unknown as Record<string, unknown>)
535
+ .__MMT_WASM_BINDINGS__ as WasmBindings | undefined;
536
+ if (!bindings) {
537
+ throw new Error(
538
+ "[FecWorker] WASM bindings not available. Set self.__MMT_WASM_BINDINGS__ = { initSync, MmtFecDecoder } before configure.",
539
+ );
540
+ }
541
+ return bindings;
542
+ }
543
+
544
+ // --- Command handlers ---
545
+
546
+ async function handleConfigure(
547
+ cmd: Extract<FecWorkerCommand, { type: "configure" }>,
548
+ ): Promise<void> {
549
+ if (!cmd.wasmModule) {
550
+ throw new Error("[FecWorker] configure: wasmModule required");
551
+ }
552
+ if (!cmd.config) {
553
+ throw new Error("[FecWorker] configure: config required");
554
+ }
555
+ if (
556
+ !Number.isFinite(cmd.config.interleaveDepth) ||
557
+ !Number.isInteger(cmd.config.interleaveDepth) ||
558
+ cmd.config.interleaveDepth <= 0 ||
559
+ cmd.config.interleaveDepth > MAX_INTERLEAVE_DEPTH
560
+ ) {
561
+ throw new Error(
562
+ `[FecWorker] configure: interleaveDepth must be an integer in 1..${MAX_INTERLEAVE_DEPTH} (got ${cmd.config.interleaveDepth})`,
563
+ );
564
+ }
565
+ if (
566
+ !Number.isFinite(cmd.config.interleaveMs) ||
567
+ cmd.config.interleaveMs <= 0
568
+ ) {
569
+ throw new Error(
570
+ `[FecWorker] configure: interleaveMs must be finite and > 0 (got ${cmd.config.interleaveMs})`,
571
+ );
572
+ }
573
+ if (
574
+ !Number.isFinite(cmd.config.deliveryWindowMs) ||
575
+ cmd.config.deliveryWindowMs <= 0
576
+ ) {
577
+ throw new Error(
578
+ `[FecWorker] configure: deliveryWindowMs must be > 0 (got ${cmd.config.deliveryWindowMs})`,
579
+ );
580
+ }
581
+ if (
582
+ !Number.isInteger(cmd.config.k) ||
583
+ cmd.config.k <= 0 ||
584
+ cmd.config.k > MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK
585
+ ) {
586
+ throw new Error(
587
+ `[FecWorker] configure: k must be an integer in 1..${MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK} (got ${cmd.config.k})`,
588
+ );
589
+ }
590
+ if (
591
+ !Number.isInteger(cmd.config.symbolSize) ||
592
+ cmd.config.symbolSize <= 0 ||
593
+ cmd.config.symbolSize > MAX_SYMBOL_SIZE ||
594
+ cmd.config.symbolSize % 8 !== 0
595
+ ) {
596
+ throw new Error(
597
+ `[FecWorker] configure: symbolSize must be an 8-byte-aligned integer in 8..${MAX_SYMBOL_SIZE} (got ${cmd.config.symbolSize})`,
598
+ );
599
+ }
600
+
601
+ const wasm = resolveWasmBindings();
602
+
603
+ // Initialize WASM from pre-compiled Module (no fetch, no re-compile)
604
+ wasm.initSync({ module: cmd.wasmModule });
605
+
606
+ // A configure command starts a new decoder epoch. No terminal tombstone,
607
+ // accepted-symbol ID, or active block from the previous decoder may affect it.
608
+ wasmDecoder?.free();
609
+ wasmDecoder = null;
610
+ clearBlockTracking();
611
+
612
+ // Decoder policy and geometry are explicit at the epoch boundary. The WASM
613
+ // module has no guessed K/T/interleave slow path.
614
+ wasmDecoder = new wasm.MmtFecDecoder(
615
+ cmd.config.interleaveDepth,
616
+ cmd.config.trackType === "video",
617
+ );
618
+ config = cmd.config;
619
+ altaEnabled = !!cmd.altaPublicKey;
620
+
621
+ // Size the deferred-verify queue strictly from catalog-derived FEC
622
+ // parameters. CLAUDE.md bans Math.max(x, floor) magic — if catalog
623
+ // values are zero/missing, throw at init rather than silently applying
624
+ // a hardcoded floor. Consumers of an undersized queue cap would see
625
+ // altaPendingEvictedCap spike with no actionable signal.
626
+ // Cap: 1.5× interleave depth — wide enough to hold one full FEC recovery
627
+ // window plus jitter, narrow enough to bound memory.
628
+ pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
629
+ // TTL: 1.5× block duration (K symbols at one per interleave tick) —
630
+ // after this many ms, any unresolved entry has missed its FEC window.
631
+ pendingVerifyTtlMs = Math.ceil(config.interleaveMs * config.k * 1.5);
632
+
633
+ // Initialize ALTA WASM verifier if public key and WASM module provided (D-05, D-09)
634
+ altaVerifier = null;
635
+ altaInitFailed = false;
636
+ if (cmd.altaPublicKey && cmd.altaWasmModule) {
637
+ try {
638
+ // Load ALTA JS glue (wasm-bindgen output from wasm-pack build).
639
+ // Mirrors the RaptorQ WASM pattern: initSync({ module }) then construct.
640
+ // Dynamic import path matches the IWA static asset layout.
641
+ // Cast via string to prevent TypeScript from attempting static resolution
642
+ // of a runtime-only URL (same pattern as fec-worker-entry.js loading).
643
+ const altaGlueUrl = '/wasm/alta/alta_rs.js' as string;
644
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
645
+ const altaGlue: any = await import(/* webpackIgnore: true */ altaGlueUrl);
646
+ altaGlue.initSync({ module: cmd.altaWasmModule });
647
+ const keyBytes = new Uint8Array(cmd.altaPublicKey);
648
+ altaVerifier = new altaGlue.JsAltaVerifier(keyBytes);
649
+ } catch (e) {
650
+ // T-08-15: WASM init failure must NOT silently look like "not-ready-yet".
651
+ // Force altaEnabled = false so handleFeedSource takes the disabled
652
+ // branch instead of continuously bumping altaPending under the
653
+ // misleading "verifier not yet ready" comment. altaInitFailed
654
+ // flag in the snapshot tells consumers this is an error state, not
655
+ // an opt-out.
656
+ console.warn('[FecWorker] ALTA WASM init failed, verification disabled:', e);
657
+ altaVerifier = null;
658
+ altaEnabled = false;
659
+ altaInitFailed = true;
660
+ }
661
+ }
662
+
663
+ wasmDecoder.configure_params(config.k, config.symbolSize);
664
+
665
+ // Start periodic snapshot emission (200ms floor)
666
+ startSnapshotTimer();
667
+
668
+ // Emit initial snapshot
669
+ emitSnapshot();
670
+ }
671
+
672
+ function handleFeedSource(
673
+ cmd: Extract<FecWorkerCommand, { type: "feedSource" }>,
674
+ ): void {
675
+ if (!wasmDecoder) {
676
+ throw new Error("[FecWorker] feedSource: not configured");
677
+ }
678
+ if (!config) {
679
+ throw new Error("[FecWorker] feedSource: config missing");
680
+ }
681
+ if (
682
+ !isUint32(cmd.ssId) ||
683
+ !(cmd.data instanceof ArrayBuffer)
684
+ ) {
685
+ throw new Error("[FecWorker] feedSource: invalid source symbol");
686
+ }
687
+
688
+ const view = new Uint8Array(cmd.data);
689
+
690
+ // Derive SBN locally for block-level accounting. WASM derives the same
691
+ // value internally (ISO 23008-1 §C.5.2: SBN = floor(SS_ID / K)).
692
+ const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
693
+ const esi = cmd.ssId % config.k;
694
+ if (sbn <= retiredSbnFrontier) return;
695
+ const existingBlock = blocks.get(sbn);
696
+ if (existingBlock && isTerminalBlock(existingBlock)) return;
697
+ if (acceptedSourceEsis.get(sbn)?.has(esi)) return;
698
+
699
+ const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
700
+ advanceMediaHorizon(sbn);
701
+ let sourceEsis = acceptedSourceEsis.get(sbn);
702
+ if (!sourceEsis) {
703
+ sourceEsis = new Set<number>();
704
+ acceptedSourceEsis.set(sbn, sourceEsis);
705
+ }
706
+ sourceEsis.add(esi);
707
+
708
+ // Update block state
709
+ const block = getOrCreateBlock(sbn);
710
+ block.sourceReceived++;
711
+ block.lastSymbolTs = performance.now();
712
+ sourceSymbols++;
713
+
714
+ // The FEC worker must remain decode-only on the per-symbol hot path. ALTA
715
+ // WASM is retained temporarily for low-rate signaling users, but media is
716
+ // authenticated by the canonical manifest verifier at router admission.
717
+ if (altaEnabled) altaPending++;
718
+
719
+ // Check if block is complete (all K source symbols received)
720
+ if (
721
+ block.sourceReceived >= block.k &&
722
+ block.state === "collecting"
723
+ ) {
724
+ markBlockTerminal(block, "complete");
725
+ blocksComplete++;
726
+ emitBlockUpdate(block);
727
+ }
728
+
729
+ // A source-triggered return can be either clean block completion or recovery
730
+ // of earlier missing symbols after repair arrived. Clean completion is
731
+ // already routed by the source path, so only emit when the block did not
732
+ // reach K source arrivals.
733
+ if (result && result.byteLength > 0 && block.sourceReceived < block.k) {
734
+ const recovered = new Uint8Array(toOwnedArrayBuffer(result));
735
+ const recoveryMs = performance.now() - block.firstSymbolTs;
736
+ recoveryTimes.push(recoveryMs);
737
+ if (recoveryTimes.length > 1000) recoveryTimes.shift();
738
+
739
+ if (block.state === "collecting") {
740
+ markBlockTerminal(block, "recovered");
741
+ blocksRecovered++;
742
+ emitBlockUpdate(block);
743
+ }
744
+ emitRecoveredSymbols(sbn, recovered, block);
745
+ }
746
+ synchronizeDecoderHorizon();
747
+ }
748
+
749
+ function handleFeedRepair(
750
+ cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
751
+ ): void {
752
+ if (!wasmDecoder) {
753
+ throw new Error("[FecWorker] feedRepair: not configured");
754
+ }
755
+ if (!config) {
756
+ throw new Error("[FecWorker] feedRepair: config missing");
757
+ }
758
+ if (
759
+ !isUint32(cmd.ssStart) ||
760
+ !isUint32(cmd.ssbLength) ||
761
+ cmd.ssbLength <= 0 ||
762
+ cmd.ssbLength !== config.k ||
763
+ cmd.ssStart % cmd.ssbLength !== 0 ||
764
+ !isUint32(cmd.rsId) ||
765
+ cmd.ssbLength + cmd.rsId > UINT24_MAX ||
766
+ !(cmd.data instanceof ArrayBuffer)
767
+ ) {
768
+ throw new Error("[FecWorker] feedRepair: invalid repair symbol");
769
+ }
770
+
771
+ const view = new Uint8Array(cmd.data);
772
+ if (config.symbolSize > 0 && view.byteLength !== config.symbolSize) {
773
+ repairSymbolSizeMismatch++;
774
+ if (repairSymbolSizeMismatch <= 5 || repairSymbolSizeMismatch % 100 === 0) {
775
+ emit({
776
+ type: "error",
777
+ code: "repair-symbol-size-mismatch",
778
+ message: `[FecWorker] Dropping repair symbol with invalid size: got ${view.byteLength}B, expected ${config.symbolSize}B (ssStart=${cmd.ssStart}, ssbLength=${cmd.ssbLength}, rsId=${cmd.rsId})`,
779
+ });
780
+ }
781
+ return;
782
+ }
783
+ const sbn = Math.floor(cmd.ssStart / cmd.ssbLength);
784
+ const repairEsi = cmd.ssbLength + cmd.rsId;
785
+ if (sbn <= retiredSbnFrontier) return;
786
+ const existingBlock = blocks.get(sbn);
787
+ if (existingBlock && isTerminalBlock(existingBlock)) return;
788
+ if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
789
+
790
+ const result = wasmDecoder.add_repair(
791
+ cmd.ssStart,
792
+ cmd.ssbLength,
793
+ cmd.rsId,
794
+ view,
795
+ cmd.ts,
796
+ );
797
+ advanceMediaHorizon(sbn);
798
+ let repairEsis = acceptedRepairEsis.get(sbn);
799
+ if (!repairEsis) {
800
+ repairEsis = new Set<number>();
801
+ acceptedRepairEsis.set(sbn, repairEsis);
802
+ }
803
+ repairEsis.add(repairEsi);
804
+ const block = getOrCreateBlock(sbn);
805
+ block.repairReceived++;
806
+ block.lastSymbolTs = performance.now();
807
+ repairSymbols++;
808
+ // If WASM returned data, it is the recovered source block: K fixed-width
809
+ // source symbols concatenated. Split it before emitting so MmtpRouter gets
810
+ // the same one-symbol shape as a direct source packet.
811
+ if (result && result.byteLength > 0) {
812
+ const recovered = new Uint8Array(toOwnedArrayBuffer(result));
813
+ const recoveryMs = performance.now() - block.firstSymbolTs;
814
+ recoveryTimes.push(recoveryMs);
815
+ // Cap recovery times array at 1000 entries
816
+ if (recoveryTimes.length > 1000) recoveryTimes.shift();
817
+
818
+ if (block.state === "collecting") {
819
+ markBlockTerminal(block, "recovered");
820
+ blocksRecovered++;
821
+ emitBlockUpdate(block);
822
+ }
823
+ emitRecoveredSymbols(sbn, recovered, block);
824
+ }
825
+ synchronizeDecoderHorizon();
826
+ }
827
+
828
+ function handleRelayBlockSig(
829
+ cmd: Extract<FecWorkerCommand, { type: "relayBlockSig" }>,
830
+ ): void {
831
+ // No-op AUTH stub: store sig on block, mark as not-yet-verified
832
+ const block = blocks.get(cmd.sbn);
833
+ if (block) {
834
+ // Real verification deferred to ALTA milestone
835
+ // For now, mark as null (not verified) — will be wired when ALTA is implemented
836
+ block.relayBlockSigValid = null;
837
+ emitBlockUpdate(block);
838
+ }
839
+ }
840
+
841
+ function handleCleanup(
842
+ cmd: Extract<FecWorkerCommand, { type: "cleanup" }>,
843
+ ): void {
844
+ if (
845
+ !Array.isArray(cmd.sbns) ||
846
+ cmd.sbns.length === 0 ||
847
+ !cmd.sbns.every(isUint32)
848
+ ) {
849
+ throw new Error("[FecWorker] cleanup: invalid SBNs");
850
+ }
851
+ const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
852
+ const pendingSbns = sbns.filter((sbn) => sbn > retiredSbnFrontier);
853
+ try {
854
+ if (!wasmDecoder) {
855
+ throw new Error("[FecWorker] cleanup: not configured");
856
+ }
857
+
858
+ // Call WASM cleanup only after all earlier symbol commands have drained.
859
+ // The detailed API carries the true owner of every fill emitted by a
860
+ // single cleanup pass; command coalescing must not infer ownership from
861
+ // the largest timed-out SBN.
862
+ const cleanupRecords = pendingSbns.length > 0
863
+ ? wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns))
864
+ : new Uint8Array();
865
+ // Manager deadlines are block-scoped and may expire a newer SBN while an
866
+ // older interleaved block is still live. Only accepted media progression
867
+ // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
868
+
869
+ // Rust already finalized every SBN behind the media frontier. For each
870
+ // remaining exact request, mirror the block-scoped WASM boundary immediately
871
+ // so JS never retains state for a decoder block that is gone, even if decoding
872
+ // the trusted cleanup framing were to fail.
873
+ for (const sbn of pendingSbns) {
874
+ let currentBlock = blocks.get(sbn);
875
+ if (currentBlock && isTerminalBlock(currentBlock)) {
876
+ retireAcceptedIds(sbn);
877
+ continue;
878
+ }
879
+ currentBlock ??= getOrCreateBlock(sbn);
880
+ if (currentBlock.sourceReceived >= currentBlock.k) {
881
+ markBlockTerminal(currentBlock, "complete");
882
+ blocksComplete++;
883
+ } else {
884
+ markBlockTerminal(currentBlock, "failed");
885
+ blocksFailed++;
886
+ }
887
+ emitBlockUpdate(currentBlock);
888
+ }
889
+
890
+ const greenFills = decodeCleanupRecords(cleanupRecords);
891
+ for (const { sbn, data } of greenFills) {
892
+ greenFillFrames++;
893
+ emit({ type: "greenFill", sbn, data }, [data]);
894
+ }
895
+ } finally {
896
+ // Message ordering makes this an acknowledgement that every source/repair
897
+ // command posted before cleanup has completed. The main thread must not
898
+ // commit failure accounting until it observes this event.
899
+ emit({ type: "cleanupComplete", sbns });
900
+ }
901
+ }
902
+
903
+ function decodeCleanupRecords(
904
+ framed: Uint8Array,
905
+ ): Array<{ sbn: number; data: ArrayBuffer }> {
906
+ const records: Array<{ sbn: number; data: ArrayBuffer }> = [];
907
+ const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
908
+ let offset = 0;
909
+ while (offset < framed.byteLength) {
910
+ if (framed.byteLength - offset < 8) {
911
+ throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record header");
912
+ }
913
+ const sbn = view.getUint32(offset, false);
914
+ const length = view.getUint32(offset + 4, false);
915
+ offset += 8;
916
+ if (length > framed.byteLength - offset) {
917
+ throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record payload");
918
+ }
919
+ const data = toOwnedArrayBuffer(framed.subarray(offset, offset + length));
920
+ records.push({ sbn, data });
921
+ offset += length;
922
+ }
923
+ return records;
924
+ }
925
+
926
+ function handleDispose(): void {
927
+ // Clear snapshot timer (MUST clear to prevent leaks)
928
+ if (snapshotTimer !== null) {
929
+ clearInterval(snapshotTimer);
930
+ snapshotTimer = null;
931
+ }
932
+
933
+ // Free ALTA verifier WASM object (MUST call to release WASM memory —
934
+ // Worker.terminate() fires immediately after dispose, so the FinalizationRegistry
935
+ // callback has no chance to run and this memory would leak without explicit free)
936
+ if (altaVerifier) {
937
+ try {
938
+ altaVerifier.free();
939
+ } catch (_e) {
940
+ // Best-effort — verifier may already be freed
941
+ }
942
+ altaVerifier = null;
943
+ }
944
+
945
+ // Free WASM decoder (MUST call to release WASM memory)
946
+ if (wasmDecoder) {
947
+ wasmDecoder.free();
948
+ wasmDecoder = null;
949
+ }
950
+
951
+ // Clear deferred-verify queue so pinned ArrayBuffers drop.
952
+ pendingVerify.clear();
953
+ pendingVerifyCap = 0;
954
+ pendingVerifyTtlMs = 0;
955
+
956
+ config = null;
957
+ clearBlockTracking();
958
+ }
959
+
960
+ function handleReset(): void {
961
+ try {
962
+ wasmDecoder?.flush();
963
+ wasmDecoder?.reset_stats();
964
+ } catch (err) {
965
+ emit({
966
+ type: "error",
967
+ code: "reset-failed",
968
+ message:
969
+ err instanceof Error
970
+ ? `[FecWorker] reset failed: ${err.message}`
971
+ : `[FecWorker] reset failed: ${String(err)}`,
972
+ });
973
+ return;
974
+ }
975
+
976
+ clearBlockTracking();
977
+ sourceSymbols = 0;
978
+ repairSymbols = 0;
979
+ blocksComplete = 0;
980
+ blocksRecovered = 0;
981
+ blocksFailed = 0;
982
+ greenFillFrames = 0;
983
+ altaVerified = 0;
984
+ altaFailed = 0;
985
+ altaFailedDetails.length = 0;
986
+ altaPending = 0;
987
+ altaPendingEvictedCap = 0;
988
+ altaPendingEvictedTtl = 0;
989
+ altaError = 0;
990
+ relayBlocksVerified = 0;
991
+ relayBlocksFailed = 0;
992
+ repairSymbolSizeMismatch = 0;
993
+ emit({ type: "resetComplete" });
994
+ if (config) emitSnapshot();
995
+ }
996
+
997
+ // --- Main message handler ---
998
+
999
+ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1000
+ try {
1001
+ switch (cmd.type) {
1002
+ case "configure":
1003
+ // handleConfigure is async (ALTA WASM init uses dynamic import).
1004
+ // Await it so feed/cleanup messages cannot run against a partially
1005
+ // configured decoder.
1006
+ await handleConfigure(cmd);
1007
+ break;
1008
+ case "feedSource":
1009
+ handleFeedSource(cmd);
1010
+ break;
1011
+ case "feedRepair":
1012
+ handleFeedRepair(cmd);
1013
+ break;
1014
+ case "relayBlockSig":
1015
+ handleRelayBlockSig(cmd);
1016
+ break;
1017
+ case "cleanup":
1018
+ handleCleanup(cmd);
1019
+ break;
1020
+ case "reset":
1021
+ handleReset();
1022
+ break;
1023
+ case "dispose":
1024
+ handleDispose();
1025
+ break;
1026
+ default:
1027
+ emit({
1028
+ type: "error",
1029
+ code: "unknown-command",
1030
+ message: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
1031
+ });
1032
+ }
1033
+ } catch (err) {
1034
+ emit({
1035
+ type: "error",
1036
+ code: "command-failed",
1037
+ message:
1038
+ err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
1039
+ });
1040
+ }
1041
+ }
1042
+
1043
+ let commandQueue: Promise<void> = Promise.resolve();
1044
+
1045
+ self.onmessage = (e: MessageEvent<FecWorkerCommand>) => {
1046
+ const cmd = e.data;
1047
+ commandQueue = commandQueue.then(() => dispatchCommand(cmd));
1048
+ };