@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
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @blockcast/fec-worker — Worker-per-track FEC decode with zero-copy transfers
3
+ *
4
+ * Re-exports shared types and main-thread client.
5
+ */
6
+
7
+ export type {
8
+ FecWorkerCommand,
9
+ FecWorkerEvent,
10
+ FecWorkerErrorCode,
11
+ FecTrackConfig,
12
+ FecTrackStats,
13
+ FecBlockSnapshot,
14
+ FrameMeta,
15
+ } from "./fec-worker-types.js";
16
+
17
+ export { FecWorkerClient } from "./fec-worker-client.js";
18
+
19
+ // --- Shred (Reed-Solomon) codec — sibling to the RaptorQ/MMTP worker above.
20
+ // Different FEC payload identity (slot/fecSet/localIndex vs flat ssId) → its
21
+ // own command shape and worker; shares the transfer + client/worker pattern.
22
+ export type {
23
+ ShredWorkerCommand,
24
+ ShredWorkerEvent,
25
+ ShredWorkerConfig,
26
+ ShredWorkerStats,
27
+ ShredWorkerErrorCode,
28
+ RecoveredMeta,
29
+ SlotSample,
30
+ } from "./shred-worker-types.js";
31
+
32
+ export { ShredWorkerClient } from "./shred-worker-client.js";
@@ -0,0 +1,558 @@
1
+ /**
2
+ * @blockcast/fec-worker — Shred (Reed-Solomon) Worker Entry Point
3
+ *
4
+ * Runs inside a DedicatedWorkerGlobalScope. Owns the batch-drain: reads raw
5
+ * shred frames (either from a transferred `ReadableStream` or via `feedShred`),
6
+ * parses the wire header, drives the `ShredFecBuffer` WASM decoder, and posts
7
+ * only recovered FEC sets (plus a coalesced stats heartbeat) back to the main
8
+ * thread. This moves the datagram read loop + wire parse + WASM FEC entirely
9
+ * off the main thread, so the page does no `read()`-per-packet work.
10
+ *
11
+ * WASM bindings resolve via:
12
+ * 1. (self as any).__SHRED_WASM_BINDINGS__ — injected by a loader/importmap
13
+ * or by tests (mirrors the RaptorQ worker's __MMT_WASM_BINDINGS__ contract)
14
+ * 2. dynamic import of config.wasmUrl (wasm-bindgen ESM: `await default()`
15
+ * then `new ShredFecBuffer(...)`)
16
+ *
17
+ * This file must NOT import DOM globals incompatible with Worker scope.
18
+ */
19
+
20
+ import type {
21
+ ShredWorkerCommand,
22
+ ShredWorkerConfig,
23
+ ShredWorkerEvent,
24
+ ShredWorkerStats,
25
+ ShredWorkerErrorCode,
26
+ RecoveredMeta,
27
+ SlotSample,
28
+ } from "./shred-worker-types.js";
29
+ import {
30
+ SHRED_WIRE_VERSION,
31
+ SHRED_HEADER_LEN,
32
+ SHRED_FLAG_IS_CODING,
33
+ SHRED_FLAG_DATA_COMPLETE,
34
+ } from "./shred-worker-types.js";
35
+ import { toOwnedArrayBuffer } from "./transfer.js";
36
+
37
+ // --- WASM binding types (from the shred `mmt_wasm` build) -------------------
38
+ // Two decoder classes exist, selected the same way the player does
39
+ // (`ShredFecBuffer ?? ShredReassembler`) and dispatched by add_shred arity:
40
+ // - ShredFecBuffer : full Reed-Solomon path, 8-arg add_shred
41
+ // (slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet)
42
+ // - ShredReassembler : legacy data-only, 5-arg add_shred
43
+ // (slot, fecSetIndex, localIndex, payload, lastInSet); coding shreds ignored
44
+ /**
45
+ * A recovered FEC set that carries its `(slot, fecSetIndex)` identity, as
46
+ * returned by `ShredFecBuffer.try_recover_all_with_identity`. wasm-bindgen
47
+ * getters surface `slot` as a BigInt and `fec_set_index` as a number; `payload`
48
+ * is a fresh owned copy (safe to read after `free()`). The object is WASM-owned
49
+ * — call `free()` once consumed.
50
+ */
51
+ interface RecoveredSetWasm {
52
+ readonly slot: bigint;
53
+ readonly fec_set_index: number;
54
+ readonly payload: Uint8Array;
55
+ free?(): void;
56
+ }
57
+
58
+ interface ShredDecoderWasm {
59
+ // Loose signature so both arities type-check; the caller branches on
60
+ // `add_shred.length` (wasm-bindgen preserves declared arity).
61
+ add_shred(...args: unknown[]): Uint8Array | null | undefined;
62
+ /** Drain timed-out FEC sets the direct path didn't complete (RS path only). */
63
+ try_recover_all?(nowMs: number): Uint8Array | null | undefined;
64
+ /**
65
+ * Identity-carrying drain (ShredFecBuffer only): same backlog as
66
+ * `try_recover_all` but the result keeps its `(slot, fecSetIndex)`, so the
67
+ * worker can attribute the real send_ts recorded at ingest to a set that
68
+ * completes on the recover timer rather than inline. Absent on the legacy
69
+ * reassembler — the worker falls back to `try_recover_all` there.
70
+ */
71
+ try_recover_all_with_identity?(nowMs: number): RecoveredSetWasm | null | undefined;
72
+ pending_fec_sets?(): number;
73
+ free?(): void;
74
+ }
75
+
76
+ type ShredDecoderCtor = new (maxSlots: bigint, symbolSize: number) => ShredDecoderWasm;
77
+
78
+ interface ShredWasmBindings {
79
+ /** wasm-bindgen ESM default init (absent when bindings are pre-inited). */
80
+ default?: () => Promise<unknown>;
81
+ ShredFecBuffer?: ShredDecoderCtor;
82
+ ShredReassembler?: ShredDecoderCtor;
83
+ }
84
+
85
+ /**
86
+ * Dispatch one shred to the decoder, mirroring the player's `callAddShred`:
87
+ * ShredFecBuffer (>=7-arg) gets the full RS call; the legacy 5-arg reassembler
88
+ * gets data shreds only (coding shreds are dropped, it can't use them).
89
+ */
90
+ function callAddShred(
91
+ dec: ShredDecoderWasm,
92
+ slot: bigint,
93
+ fecSetIndex: number,
94
+ localIndex: number,
95
+ numData: number,
96
+ numCoding: number,
97
+ isCoding: boolean,
98
+ payload: Uint8Array,
99
+ lastInSet: boolean,
100
+ ): Uint8Array | null | undefined {
101
+ if (dec.add_shred.length >= 7) {
102
+ return dec.add_shred(slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet);
103
+ }
104
+ if (!isCoding) return dec.add_shred(slot, fecSetIndex, localIndex, payload, lastInSet);
105
+ return null;
106
+ }
107
+
108
+ // --- Worker state -----------------------------------------------------------
109
+
110
+ let decoder: ShredDecoderWasm | null = null;
111
+ // configure() generation — guards against re-entrant configure() races: a later
112
+ // call supersedes an earlier one still awaiting the WASM load, so the loser bails
113
+ // instead of arming a second set of timers (which would orphan the winner's — a
114
+ // leak that outlives dispose()).
115
+ let configureGen = 0;
116
+ let statsTimer: ReturnType<typeof setInterval> | null = null;
117
+ let recoverTimer: ReturnType<typeof setInterval> | null = null;
118
+ let activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
119
+ let disposed = false;
120
+
121
+ // Slot tracking for main-thread stats (turbine estimator, latency percentiles).
122
+ // The worker is the single parser; it hands the main thread first-shred-per-slot
123
+ // samples aggregated into the stats heartbeat, never per-packet.
124
+ let maxSlot = 0n;
125
+ let lastSlotSampled: bigint | null = null;
126
+ let slotSamples: SlotSample[] = [];
127
+ /** Cap the per-heartbeat sample batch; ~400ms slots means this is rarely hit. */
128
+ const SLOT_SAMPLE_CAP = 256;
129
+
130
+ // Per-FEC-set send timestamp, recorded at ingest (`(slot, fecSetIndex)` →
131
+ // first-seen sendTsUs). The loss-recovery drain returns a set that completed on
132
+ // the recover timer, not inline, so its completing shred's send_ts isn't in
133
+ // hand at emit time; without this, every RS-recovered set would carry sendTsUs
134
+ // 0n and the "Listener→browser" latency widgets would stay empty whenever loss
135
+ // (the common case over a QUIC datagram path) makes recovery — not inline
136
+ // completion — the dominant path.
137
+ //
138
+ // Key is a bigint pack `(slot << 32) | fecSetIndex` (fecSetIndex is u32) rather
139
+ // than a template string — `recordSetSendTs` runs on every ingested shred (a
140
+ // documented per-packet hot path), so this avoids a per-frame string alloc.
141
+ const setSendTs = new Map<bigint, bigint>();
142
+ /**
143
+ * Bound the map for sets that never complete (shreds dropped before enough of a
144
+ * set arrived to recover). Entries are deleted the moment a set completes
145
+ * (either path), so this cap is only reached under sustained un-recoverable
146
+ * loss; ~a few sets/slot means 4096 covers minutes of backlog.
147
+ */
148
+ const SET_TS_CAP = 4096;
149
+
150
+ function setKey(slot: bigint, fecSetIndex: number): bigint {
151
+ return (slot << 32n) | BigInt(fecSetIndex >>> 0);
152
+ }
153
+
154
+ /** Record the first send_ts seen for a set so the recover path can attribute it. */
155
+ function recordSetSendTs(slot: bigint, fecSetIndex: number, sendTsUs: bigint): void {
156
+ if (sendTsUs === 0n) return; // unstamped feed — nothing to attribute
157
+ const key = setKey(slot, fecSetIndex);
158
+ if (setSendTs.has(key)) return; // keep the first-seen stamp (matches slotSamples)
159
+ // At cap, reject the NEW entry rather than evicting the oldest — matching the
160
+ // flood-resistance policy of this package's `enqueuePendingVerify`
161
+ // (fec-worker.ts). The oldest pending sets are the ones closest to completing
162
+ // via the recover timer, so evict-oldest would drop attribution exactly when
163
+ // it matters and is attacker-triggerable via a flood of never-completing sets.
164
+ // A rejected set simply falls back to sendTsUs 0n if it later recovers.
165
+ if (setSendTs.size >= SET_TS_CAP) return;
166
+ setSendTs.set(key, sendTsUs);
167
+ }
168
+
169
+ const stats: ShredWorkerStats = {
170
+ shredsRx: 0,
171
+ sourceShreds: 0,
172
+ repairShreds: 0,
173
+ fecSetsCompleted: 0,
174
+ bytesReassembled: 0,
175
+ fecSetsRecovered: 0,
176
+ pendingFecSets: -1,
177
+ malformed: 0,
178
+ versionMismatch: 0,
179
+ currentSlot: "0",
180
+ slotSamples: [],
181
+ };
182
+
183
+ // --- Emit helpers -----------------------------------------------------------
184
+
185
+ function post(event: ShredWorkerEvent, transfer: Transferable[] = []): void {
186
+ (self as unknown as DedicatedWorkerGlobalScope).postMessage(event, transfer);
187
+ }
188
+
189
+ function postError(message: string, code?: ShredWorkerErrorCode): void {
190
+ post({ type: "error", message, code });
191
+ }
192
+
193
+ function emitRecovered(view: Uint8Array, meta: RecoveredMeta): void {
194
+ // Copy out of WASM-owned memory into a fresh JS buffer before transfer.
195
+ const buf = toOwnedArrayBuffer(view);
196
+ stats.fecSetsCompleted++;
197
+ stats.bytesReassembled += buf.byteLength;
198
+ if (meta.recovered) stats.fecSetsRecovered++;
199
+ post({ type: "recovered", data: buf, meta }, [buf]);
200
+ }
201
+
202
+ function postStats(): void {
203
+ if (decoder && typeof decoder.pending_fec_sets === "function") {
204
+ try {
205
+ stats.pendingFecSets = decoder.pending_fec_sets();
206
+ } catch {
207
+ /* pending count is best-effort */
208
+ }
209
+ }
210
+ stats.currentSlot = maxSlot.toString();
211
+ // Drain the slot-sample batch into this heartbeat; live counters keep mutating.
212
+ const drainedSamples = slotSamples;
213
+ slotSamples = [];
214
+ post({ type: "stats", stats: { ...stats, slotSamples: drainedSamples } });
215
+ }
216
+
217
+ // --- Wire parse + decode ----------------------------------------------------
218
+
219
+ /**
220
+ * Parse one raw shred frame and feed the decoder. Mirrors the deployed player's
221
+ * `onShredBytes` (v3 wire, version-guarded) so the off-thread path is
222
+ * bit-identical to the proven on-thread path. Emits a `recovered` event when a
223
+ * set completes directly.
224
+ */
225
+ function ingestFrame(frame: Uint8Array): void {
226
+ if (frame.byteLength < SHRED_HEADER_LEN) {
227
+ stats.malformed++;
228
+ return;
229
+ }
230
+ const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
231
+ if (dv.getUint8(0) !== SHRED_WIRE_VERSION) {
232
+ // Forwarder/worker wire-version skew — drop rather than misframe every shred.
233
+ stats.versionMismatch++;
234
+ return;
235
+ }
236
+ const slot = dv.getBigUint64(1, true);
237
+ const fecSetIndex = dv.getUint32(9, true);
238
+ const localIndex = dv.getUint32(13, true);
239
+ const flags = dv.getUint8(17);
240
+ const dataComplete = (flags & SHRED_FLAG_DATA_COMPLETE) !== 0; // last data shred in set
241
+ const isCoding = (flags & SHRED_FLAG_IS_CODING) !== 0;
242
+ const numData = dv.getUint8(18);
243
+ const numCoding = dv.getUint8(19);
244
+ const sendTsUs = dv.getBigUint64(20, true);
245
+ const payload = frame.subarray(SHRED_HEADER_LEN);
246
+
247
+ stats.shredsRx++;
248
+ if (isCoding) stats.repairShreds++;
249
+ else stats.sourceShreds++;
250
+
251
+ // Slot tracking for main-thread stats. Sample the first shred of each new
252
+ // slot (send timestamp + size) so the page can run the turbine-leg estimator
253
+ // and latency percentiles without re-parsing the wire header itself.
254
+ if (slot > maxSlot) maxSlot = slot;
255
+ if (lastSlotSampled === null || slot !== lastSlotSampled) {
256
+ lastSlotSampled = slot;
257
+ if (slotSamples.length < SLOT_SAMPLE_CAP) {
258
+ slotSamples.push({ slot, sendTsUs, bytes: frame.byteLength });
259
+ }
260
+ }
261
+
262
+ // Record this set's send_ts so the recover-timer path (which completes a set
263
+ // without its completing shred in hand) can attribute a real timestamp.
264
+ recordSetSendTs(slot, fecSetIndex, sendTsUs);
265
+
266
+ if (!decoder) return;
267
+
268
+ // DATA_COMPLETE is passed as `lastInSet`: the WASM uses it to infer numData
269
+ // for a set that received only data shreds (no coding).
270
+ let result: Uint8Array | null | undefined;
271
+ try {
272
+ result = callAddShred(
273
+ decoder,
274
+ slot,
275
+ fecSetIndex,
276
+ localIndex,
277
+ numData,
278
+ numCoding,
279
+ isCoding,
280
+ payload,
281
+ dataComplete,
282
+ );
283
+ } catch (err) {
284
+ postError(`add_shred threw: ${errString(err)}`, "worker-runtime");
285
+ return;
286
+ }
287
+ if (result && result.byteLength > 0) {
288
+ // Set completed inline — its completing shred's send_ts is exact; drop the
289
+ // ingest record so it can't leak or be re-attributed by the recover path.
290
+ setSendTs.delete(setKey(slot, fecSetIndex));
291
+ emitRecovered(result, { slot, fecSetIndex, sendTsUs, recovered: false });
292
+ }
293
+ }
294
+
295
+ /** Run one loss-recovery drain and emit any late-recovered set. */
296
+ function recoverAll(nowMs: number): void {
297
+ if (!decoder) return;
298
+
299
+ // Preferred path: the identity-carrying drain (ShredFecBuffer) keeps the
300
+ // recovered set's `(slot, fecSetIndex)`, so we can attribute the real send_ts
301
+ // recorded at ingest — otherwise a loss-recovered set (the common case over a
302
+ // lossy datagram path) would carry sendTsUs 0n and the latency widgets would
303
+ // never populate despite valid stamps on the wire.
304
+ if (typeof decoder.try_recover_all_with_identity === "function") {
305
+ let set: RecoveredSetWasm | null | undefined;
306
+ try {
307
+ set = decoder.try_recover_all_with_identity(nowMs);
308
+ } catch (err) {
309
+ postError(`try_recover_all_with_identity threw: ${errString(err)}`, "worker-runtime");
310
+ return;
311
+ }
312
+ if (!set) return;
313
+ // Read the WASM-owned fields before freeing; `payload` is a fresh copy.
314
+ const slot = set.slot;
315
+ const fecSetIndex = set.fec_set_index;
316
+ const payload = set.payload;
317
+ // Best-effort free, matching every other fallible WASM call in this file:
318
+ // recoverAll runs from an unguarded setInterval / message-dispatch branch,
319
+ // so a throwing free() must not propagate and silently drop the set.
320
+ try {
321
+ set.free?.();
322
+ } catch {
323
+ /* best-effort */
324
+ }
325
+ // Clear the map entry on ANY exit for this completed key (empty payload
326
+ // included), so a set can't linger until FIFO eviction.
327
+ const key = setKey(slot, fecSetIndex);
328
+ // sendTsUs here is the set's FIRST-seen shred stamp (recordSetSendTs), vs the
329
+ // inline path's completing-shred stamp; a set's shreds are sent µs apart, so
330
+ // both are equivalent baselines for the "Listener→browser" percentiles.
331
+ const sendTsUs = setSendTs.get(key) ?? 0n;
332
+ setSendTs.delete(key);
333
+ if (!payload || payload.byteLength === 0) return;
334
+ emitRecovered(payload, { slot, fecSetIndex, sendTsUs, recovered: true });
335
+ return;
336
+ }
337
+
338
+ // Legacy reassembler: payload-only drain, no set identity available.
339
+ if (typeof decoder.try_recover_all !== "function") return;
340
+ let result: Uint8Array | null | undefined;
341
+ try {
342
+ result = decoder.try_recover_all(nowMs);
343
+ } catch (err) {
344
+ postError(`try_recover_all threw: ${errString(err)}`, "worker-runtime");
345
+ return;
346
+ }
347
+ if (result && result.byteLength > 0) {
348
+ // slot / fecSetIndex / sendTs are not attributable from a bulk drain.
349
+ emitRecovered(result, {
350
+ slot: 0n,
351
+ fecSetIndex: 0,
352
+ sendTsUs: 0n,
353
+ recovered: true,
354
+ });
355
+ }
356
+ }
357
+
358
+ // --- Stream batch-drain -----------------------------------------------------
359
+
360
+ /**
361
+ * Batch-drain a transferred stream of raw shred frames. The read loop lives
362
+ * here (not on the main thread), so the page never pays a `read()` per packet;
363
+ * only recovered sets and the coalesced stats heartbeat cross back.
364
+ */
365
+ async function drainStream(readable: ReadableStream<Uint8Array>): Promise<void> {
366
+ // Replace any prior stream — one active source at a time.
367
+ await cancelActiveStream();
368
+ const reader = readable.getReader();
369
+ activeStreamReader = reader;
370
+ try {
371
+ for (;;) {
372
+ const { value, done } = await reader.read();
373
+ if (done || disposed) break;
374
+ if (value) ingestFrame(value);
375
+ }
376
+ } catch (err) {
377
+ if (!disposed) postError(`stream read failed: ${errString(err)}`, "stream-read-failed");
378
+ } finally {
379
+ if (activeStreamReader === reader) activeStreamReader = null;
380
+ try {
381
+ reader.releaseLock();
382
+ } catch {
383
+ /* already released */
384
+ }
385
+ }
386
+ }
387
+
388
+ async function cancelActiveStream(): Promise<void> {
389
+ const reader = activeStreamReader;
390
+ if (!reader) return;
391
+ activeStreamReader = null;
392
+ try {
393
+ await reader.cancel();
394
+ } catch {
395
+ /* stream may already be closed */
396
+ }
397
+ try {
398
+ reader.releaseLock();
399
+ } catch {
400
+ /* already released */
401
+ }
402
+ }
403
+
404
+ // --- WASM load --------------------------------------------------------------
405
+
406
+ /** Select the decoder class the same way the player does: RS first, legacy fallback. */
407
+ function pickDecoderCtor(b: ShredWasmBindings): ShredDecoderCtor | null {
408
+ if (typeof b.ShredFecBuffer === "function") return b.ShredFecBuffer;
409
+ if (typeof b.ShredReassembler === "function") return b.ShredReassembler;
410
+ return null;
411
+ }
412
+
413
+ async function loadBindings(cfg: ShredWorkerConfig): Promise<ShredWasmBindings> {
414
+ const injected = (self as unknown as { __SHRED_WASM_BINDINGS__?: ShredWasmBindings })
415
+ .__SHRED_WASM_BINDINGS__;
416
+ if (injected && pickDecoderCtor(injected)) {
417
+ return injected;
418
+ }
419
+ if (!cfg.wasmUrl) {
420
+ throw new Error(
421
+ "no shred WASM: set self.__SHRED_WASM_BINDINGS__ = { ShredFecBuffer } or pass config.wasmUrl",
422
+ );
423
+ }
424
+ const mod = (await import(/* @vite-ignore */ cfg.wasmUrl)) as ShredWasmBindings;
425
+ if (typeof mod.default === "function") await mod.default();
426
+ if (!pickDecoderCtor(mod)) {
427
+ throw new Error(
428
+ `shred WASM at ${cfg.wasmUrl} exports neither ShredFecBuffer nor ShredReassembler`,
429
+ );
430
+ }
431
+ return mod;
432
+ }
433
+
434
+ async function configure(cfg: ShredWorkerConfig): Promise<void> {
435
+ const gen = ++configureGen;
436
+ disposed = false;
437
+ teardownTimers();
438
+ await cancelActiveStream();
439
+ if (decoder?.free) {
440
+ try {
441
+ decoder.free();
442
+ } catch {
443
+ /* best-effort */
444
+ }
445
+ }
446
+ decoder = null;
447
+ setSendTs.clear(); // stale per-set stamps must not survive a reconfigure
448
+
449
+ let bindings: ShredWasmBindings;
450
+ try {
451
+ bindings = await loadBindings(cfg);
452
+ } catch (err) {
453
+ if (gen === configureGen) postError(errString(err), "wasm-load-failed");
454
+ return;
455
+ }
456
+ if (gen !== configureGen) return; // superseded by a newer configure() while loading
457
+ const Ctor = pickDecoderCtor(bindings);
458
+ if (!Ctor) {
459
+ postError("no shred decoder constructor after load", "wasm-bindings-missing");
460
+ return;
461
+ }
462
+ try {
463
+ decoder = new Ctor(BigInt(cfg.maxSlots), cfg.symbolSize);
464
+ } catch (err) {
465
+ postError(`shred decoder ctor threw: ${errString(err)}`, "wasm-load-failed");
466
+ return;
467
+ }
468
+
469
+ // Clear any timer a racing continuation may have armed between the await and
470
+ // here, so exactly one stats/recover interval is live.
471
+ teardownTimers();
472
+ const statsMs = cfg.statsIntervalMs ?? 250;
473
+ if (statsMs > 0) statsTimer = setInterval(postStats, statsMs);
474
+ const recoverMs = cfg.recoverIntervalMs ?? 50;
475
+ if (recoverMs > 0) recoverTimer = setInterval(() => recoverAll(Date.now()), recoverMs);
476
+ }
477
+
478
+ function teardownTimers(): void {
479
+ if (statsTimer !== null) {
480
+ clearInterval(statsTimer);
481
+ statsTimer = null;
482
+ }
483
+ if (recoverTimer !== null) {
484
+ clearInterval(recoverTimer);
485
+ recoverTimer = null;
486
+ }
487
+ }
488
+
489
+ async function dispose(): Promise<void> {
490
+ disposed = true;
491
+ teardownTimers();
492
+ await cancelActiveStream();
493
+ if (decoder?.free) {
494
+ try {
495
+ decoder.free();
496
+ } catch {
497
+ /* best-effort */
498
+ }
499
+ }
500
+ decoder = null;
501
+ setSendTs.clear();
502
+ }
503
+
504
+ // --- Message dispatch -------------------------------------------------------
505
+
506
+ self.onmessage = (e: MessageEvent<ShredWorkerCommand>): void => {
507
+ const cmd = e.data;
508
+ switch (cmd.type) {
509
+ case "configure":
510
+ void configure(cmd.config).catch((err) =>
511
+ postError(errString(err), "wasm-load-failed"),
512
+ );
513
+ break;
514
+ case "attachStream":
515
+ if (!decoder) {
516
+ postError("attachStream before configure", "not-configured");
517
+ // Drop the transferred stream so it doesn't leak.
518
+ void cancelStreamHandle(cmd.readable);
519
+ break;
520
+ }
521
+ void drainStream(cmd.readable);
522
+ break;
523
+ case "feedShred":
524
+ if (!decoder) {
525
+ postError("feedShred before configure", "not-configured");
526
+ break;
527
+ }
528
+ ingestFrame(new Uint8Array(cmd.frame));
529
+ break;
530
+ case "recoverAll":
531
+ recoverAll(cmd.nowMs);
532
+ break;
533
+ case "dispose":
534
+ void dispose();
535
+ break;
536
+ default: {
537
+ const _exhaustive: never = cmd;
538
+ postError(`unknown command: ${JSON.stringify(_exhaustive)}`, "unknown-command");
539
+ }
540
+ }
541
+ };
542
+
543
+ self.onmessageerror = (): void => {
544
+ postError("failed to deserialize worker message", "worker-message");
545
+ };
546
+
547
+ async function cancelStreamHandle(readable: ReadableStream<Uint8Array>): Promise<void> {
548
+ try {
549
+ await readable.cancel();
550
+ } catch {
551
+ /* best-effort */
552
+ }
553
+ }
554
+
555
+ function errString(err: unknown): string {
556
+ if (err instanceof Error) return err.message;
557
+ return String(err);
558
+ }