@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,181 @@
1
+ /**
2
+ * @blockcast/fec-worker — Shred Worker Main-Thread Client
3
+ *
4
+ * Thin wrapper over the shred (Reed-Solomon) worker, mirroring
5
+ * {@link FecWorkerClient}'s shape. Spawns one Worker, speaks the
6
+ * {@link ShredWorkerCommand}/{@link ShredWorkerEvent} protocol, and keeps the
7
+ * datagram read loop + wire parse + WASM FEC off the main thread.
8
+ *
9
+ * Typical wiring for a WebTransport datagram source (fully off-thread):
10
+ *
11
+ * const client = new ShredWorkerClient(new URL("./shred-fec-worker.js", import.meta.url));
12
+ * client.onRecovered((data, meta) => renderRecoveredSet(data, meta));
13
+ * client.configure({ maxSlots: 5000, symbolSize: 512, wasmUrl: "/vendor/fec/mmt_wasm.js" });
14
+ * client.attachStream(wt.datagrams.readable); // transferred; main does no read() per packet
15
+ *
16
+ * For sources that demux on the main thread (moqtail OBJECT_DATAGRAM callback,
17
+ * IWA multicast bridge), push each raw frame with {@link feedShred} instead.
18
+ *
19
+ * {@link feedShred} is a fire-and-forget `postMessage` with no native
20
+ * backpressure, so a consumer that decodes slower than the ingest rate (a
21
+ * backgrounded/throttled tab, WASM RS falling behind a ~4k shred/s feed) would
22
+ * grow the worker's inbound message queue without limit — the queue lives in the
23
+ * renderer process and has been observed to reach multiple GB, GC-thrashing the
24
+ * page into a wedge. The client bounds it with an in-flight window keyed off the
25
+ * worker's own periodic stats heartbeat (no per-frame ack round-trip); see
26
+ * {@link ShredWorkerClientOptions.maxInflight}.
27
+ */
28
+ /** Default {@link ShredWorkerClientOptions.maxInflight} (~10 MB of queued frames). */
29
+ export const DEFAULT_MAX_INFLIGHT = 8192;
30
+ export class ShredWorkerClient {
31
+ #worker;
32
+ #onRecovered = null;
33
+ #onStats = null;
34
+ #onError = null;
35
+ #disposed = false;
36
+ /** feedShred in-flight backpressure accounting (see ShredWorkerClientOptions.maxInflight). */
37
+ #maxInflight;
38
+ #sent = 0; // frames posted via feedShred (monotonic)
39
+ #processed = 0; // worker-consumed total from the last stats heartbeat (monotonic)
40
+ #droppedBackpressure = 0; // frames dropped by the in-flight guard (monotonic)
41
+ /**
42
+ * @param workerOrUrl - A URL (a Worker is created internally, `type: module`)
43
+ * or a pre-created Worker (for testing via constructor injection).
44
+ * @param opts - Optional {@link ShredWorkerClientOptions}.
45
+ */
46
+ constructor(workerOrUrl, opts = {}) {
47
+ this.#maxInflight = opts.maxInflight ?? DEFAULT_MAX_INFLIGHT;
48
+ this.#worker =
49
+ workerOrUrl instanceof URL
50
+ ? new Worker(workerOrUrl, { type: "module" })
51
+ : workerOrUrl;
52
+ this.#worker.onmessage = (e) => this.#handleEvent(e.data);
53
+ this.#worker.onerror = (e) => this.#onError?.(formatWorkerError(e), "worker-runtime");
54
+ this.#worker.onmessageerror = () => this.#onError?.("failed to deserialize worker message", "worker-message");
55
+ }
56
+ /** Configure the decoder (WASM load + ShredFecBuffer construction happen in the worker). */
57
+ configure(config) {
58
+ if (this.#disposed)
59
+ throw new Error("[ShredWorkerClient] disposed");
60
+ this.#post({ type: "configure", config });
61
+ }
62
+ /**
63
+ * Hand the worker a stream of raw shred frames to batch-drain. The stream is
64
+ * transferred — the caller must not use it afterward. One active stream at a
65
+ * time; attaching a new one cancels the previous.
66
+ */
67
+ attachStream(readable) {
68
+ if (this.#disposed)
69
+ return;
70
+ this.#post({ type: "attachStream", readable }, [readable]);
71
+ }
72
+ /**
73
+ * Feed one already-demuxed raw shred frame. `frame` is transferred zero-copy;
74
+ * the caller must not access it afterward.
75
+ *
76
+ * Dropped (frame NOT transferred, so the caller's buffer stays intact) when the
77
+ * in-flight window is full — i.e. the worker is more than `maxInflight` frames
78
+ * behind. This is the memory bound: a slow/wedged consumer costs bounded
79
+ * skip-to-live drops (see {@link droppedBackpressure}), never an unbounded
80
+ * renderer-heap blow-up.
81
+ */
82
+ feedShred(frame) {
83
+ if (this.#disposed)
84
+ return;
85
+ if (this.#maxInflight > 0 && this.#sent - this.#processed >= this.#maxInflight) {
86
+ this.#droppedBackpressure++;
87
+ return;
88
+ }
89
+ this.#sent++;
90
+ this.#post({ type: "feedShred", frame }, [frame]);
91
+ }
92
+ /** Explicitly drive loss recovery (in addition to the worker's internal timer). */
93
+ recoverAll(nowMs = Date.now()) {
94
+ if (this.#disposed)
95
+ return;
96
+ this.#post({ type: "recoverAll", nowMs });
97
+ }
98
+ /** Register callback for recovered/completed FEC sets. `data` is a transferred buffer. */
99
+ onRecovered(cb) {
100
+ this.#onRecovered = cb;
101
+ }
102
+ /** Register callback for the coalesced stats heartbeat. */
103
+ onStats(cb) {
104
+ this.#onStats = cb;
105
+ }
106
+ /** Register callback for worker errors. */
107
+ onError(cb) {
108
+ this.#onError = cb;
109
+ }
110
+ /**
111
+ * Frames dropped by the in-flight backpressure guard since construction
112
+ * (monotonic). Surface this in the UI so sustained loss is visible rather than
113
+ * silent — a rising counter means the consumer can't keep pace with ingest.
114
+ */
115
+ get droppedBackpressure() {
116
+ return this.#droppedBackpressure;
117
+ }
118
+ /**
119
+ * Frames posted via {@link feedShred} but not yet reported drained by the
120
+ * worker's last stats heartbeat (best-effort, heartbeat-granular; never < 0).
121
+ */
122
+ get inflight() {
123
+ return Math.max(0, this.#sent - this.#processed);
124
+ }
125
+ /** Dispose the worker. Idempotent. */
126
+ dispose() {
127
+ if (this.#disposed)
128
+ return;
129
+ this.#disposed = true;
130
+ try {
131
+ this.#worker.postMessage({ type: "dispose" });
132
+ }
133
+ catch {
134
+ /* worker may already be gone */
135
+ }
136
+ this.#worker.terminate();
137
+ }
138
+ #post(cmd, transfer = []) {
139
+ this.#worker.postMessage(cmd, transfer);
140
+ }
141
+ #handleEvent(event) {
142
+ switch (event.type) {
143
+ case "recovered":
144
+ this.#onRecovered?.(event.data, event.meta);
145
+ break;
146
+ case "stats":
147
+ // The worker's monotonic consumed-frame total is the drain signal for
148
+ // the feedShred in-flight guard. shredsRx counts only valid frames;
149
+ // malformed + versionMismatch are consumed off the queue too, so the
150
+ // sum is the true "frames taken off the queue" and keeps the window
151
+ // from inflating (and false-dropping) on a malformed/skewed source.
152
+ this.#processed =
153
+ event.stats.shredsRx + event.stats.malformed + event.stats.versionMismatch;
154
+ this.#onStats?.(event.stats);
155
+ break;
156
+ case "error":
157
+ if (event.code)
158
+ this.#onError?.(event.message, event.code);
159
+ else
160
+ this.#onError?.(event.message);
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ function formatWorkerError(event) {
166
+ const parts = ["Worker error"];
167
+ if (event.message)
168
+ parts.push(event.message);
169
+ if (event.filename) {
170
+ const loc = [event.filename];
171
+ if (event.lineno)
172
+ loc.push(String(event.lineno));
173
+ if (event.colno)
174
+ loc.push(String(event.colno));
175
+ parts.push(`at ${loc.join(":")}`);
176
+ }
177
+ if (event.error instanceof Error && event.error.stack)
178
+ parts.push(event.error.stack);
179
+ return parts.join(": ");
180
+ }
181
+ //# sourceMappingURL=shred-worker-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shred-worker-client.js","sourceRoot":"","sources":["../src/shred-worker-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @blockcast/fec-worker — Shred (Reed-Solomon) Worker Protocol
3
+ *
4
+ * A sibling codec to the RaptorQ/MMTP worker (`fec-worker.ts`). Solana-style
5
+ * shred streams carry a different FEC payload identity than MMTP source/repair
6
+ * symbols — a shred is `(slot, fecSetIndex, localIndex, numData, numCoding,
7
+ * isCoding, lastInSet)`, not a flat `ssId` — so they need their own command
8
+ * shape. The transport harness (Worker spawn, onmessage plumbing, zero-copy
9
+ * `toOwnedArrayBuffer` transfers) is shared; the decoder WASM is not.
10
+ *
11
+ * The worker owns the batch-drain: the consumer transfers a WebTransport
12
+ * `datagrams.readable` (or any `ReadableStream<Uint8Array>` of raw shred
13
+ * frames) into the Worker via {@link ShredWorkerCommand} `attachStream`, and
14
+ * the read loop + wire parse + `ShredFecBuffer.add_shred` all run off the main
15
+ * thread. Only recovered FEC sets (and coalesced stats) cross back. A
16
+ * `feedShred` command covers sources that have already demuxed the shred bytes
17
+ * on the main thread (e.g. a moqtail OBJECT_DATAGRAM callback, or the IWA
18
+ * multicast bridge).
19
+ *
20
+ * Types here are importable from both main thread and Worker contexts.
21
+ */
22
+ /**
23
+ * Wire header layout the worker parses. Mirrors the deployed shred player's
24
+ * `onShredBytes` exactly (libmmt shred-forwarder `emit_frame`) so the off-thread
25
+ * path is bit-identical to the proven on-thread path.
26
+ *
27
+ * v3 (28-byte header):
28
+ * [0] version u8 (== 3; non-3 frames are dropped, not misframed)
29
+ * [1-8] slot u64 LE
30
+ * [9-12] fecSetIndex u32 LE
31
+ * [13-16] localIndex u32 LE (data: 0..numData; coding: numData+position)
32
+ * [17] flags u8 (bit0 = DATA_COMPLETE, bit1 = IS_CODING_SHRED)
33
+ * [18] numData u8 (coding shreds only; 0 on data — WASM infers)
34
+ * [19] numCoding u8 (coding shreds only; 0 on data)
35
+ * [20-27] sendTsUs u64 LE (µs since epoch, 0 when unstamped)
36
+ * [28+] erasureShard (equal length across the FEC set)
37
+ *
38
+ * DATA_COMPLETE (last data shred in the set) is passed to `add_shred` as its
39
+ * `lastInSet` argument — the WASM uses it to infer numData for a set that
40
+ * received only data shreds (no coding).
41
+ */
42
+ export declare const SHRED_WIRE_VERSION = 3;
43
+ export declare const SHRED_HEADER_LEN = 28;
44
+ export declare const SHRED_FLAG_DATA_COMPLETE = 1;
45
+ export declare const SHRED_FLAG_IS_CODING = 2;
46
+ /** Per-decoder configuration for the shred FEC worker. */
47
+ export interface ShredWorkerConfig {
48
+ /** Ring capacity in slots (matches `new ShredFecBuffer(maxSlots, symbolSize)`). */
49
+ maxSlots: number;
50
+ /** Symbol size in bytes (Solana shred payloads are fixed-size within a set). */
51
+ symbolSize: number;
52
+ /**
53
+ * Module URL for the shred WASM (wasm-bindgen ESM exporting `default` init +
54
+ * `ShredFecBuffer`). The worker `import()`s it and calls `await default()`.
55
+ * Ignored when `self.__SHRED_WASM_BINDINGS__` is pre-injected (tests / custom
56
+ * loaders), mirroring the RaptorQ worker's `__MMT_WASM_BINDINGS__` contract.
57
+ */
58
+ wasmUrl?: string;
59
+ /**
60
+ * How often (ms) the worker coalesces and posts a `stats` event. The read
61
+ * loop never posts per-shred; only recovered frames and this heartbeat cross
62
+ * the boundary. Default 250ms.
63
+ */
64
+ statsIntervalMs?: number;
65
+ /**
66
+ * How often (ms) the worker runs `try_recover_all(now)` to drain timed-out
67
+ * FEC sets that `add_shred` didn't complete (loss scenarios). Default 50ms,
68
+ * matching the player's on-thread recovery timer. Set to 0 to disable (the
69
+ * consumer may instead drive it explicitly with `recoverAll`).
70
+ */
71
+ recoverIntervalMs?: number;
72
+ }
73
+ /**
74
+ * First-shred-per-slot sample, collected by the worker and drained into each
75
+ * coalesced `stats` heartbeat. Lets the main thread run app-specific,
76
+ * slot-derived stats (e.g. the turbine-leg latency estimator, per-slot latency
77
+ * percentiles) WITHOUT re-parsing the wire header — the worker stays the single
78
+ * source of truth for parsing, and these cross the boundary aggregated (a
79
+ * handful per heartbeat), never per-packet.
80
+ */
81
+ export interface SlotSample {
82
+ /** Slot number (first shred of this slot the worker saw). */
83
+ slot: bigint;
84
+ /** Publisher send timestamp (µs since epoch) of that first shred, 0 when unstamped. */
85
+ sendTsUs: bigint;
86
+ /** Frame byte length of that first shred. */
87
+ bytes: number;
88
+ }
89
+ /** Aggregate shred-decode counters, emitted on the coalesced `stats` heartbeat. */
90
+ export interface ShredWorkerStats {
91
+ /** Total shred frames parsed (data + coding). */
92
+ shredsRx: number;
93
+ /** Data (source) shreds observed. */
94
+ sourceShreds: number;
95
+ /** Coding (repair) shreds observed. */
96
+ repairShreds: number;
97
+ /** FEC sets completed (direct completion + FEC recovery). */
98
+ fecSetsCompleted: number;
99
+ /** Bytes handed back as recovered/completed sets. */
100
+ bytesReassembled: number;
101
+ /** Sets recovered specifically via `try_recover_all` (loss recovery). */
102
+ fecSetsRecovered: number;
103
+ /** Current `ShredFecBuffer.pending_fec_sets()`, or -1 when unavailable. */
104
+ pendingFecSets: number;
105
+ /** Frames dropped for being shorter than the v3 header. */
106
+ malformed: number;
107
+ /** Frames dropped for carrying a non-v3 version byte (forwarder/worker skew). */
108
+ versionMismatch: number;
109
+ /** Highest slot number observed (as a string — bigint isn't JSON-safe for UI). */
110
+ currentSlot: string;
111
+ /** First-shred-per-slot samples parsed since the previous heartbeat (drained each emit). */
112
+ slotSamples: SlotSample[];
113
+ }
114
+ /** Stable identities for worker failures whose message text carries live values. */
115
+ export type ShredWorkerErrorCode = "wasm-load-failed" | "wasm-bindings-missing" | "not-configured" | "stream-read-failed" | "unknown-command" | "worker-runtime" | "worker-message";
116
+ /** Metadata attached to each recovered set the worker emits. */
117
+ export interface RecoveredMeta {
118
+ /**
119
+ * Slot the recovered set belongs to. Attributed on both the direct-completion
120
+ * path and the identity-carrying recovery drain
121
+ * (`try_recover_all_with_identity`); only 0n on the legacy `ShredReassembler`
122
+ * fallback, which has no identity variant. Do NOT branch on `slot === 0n` as
123
+ * "unattributed" — that would drop the majority of recovered sets.
124
+ */
125
+ slot: bigint;
126
+ /** FEC set index within the slot (0 only on the legacy identity-less fallback). */
127
+ fecSetIndex: number;
128
+ /**
129
+ * Publisher send timestamp (µs since epoch), 0 when unstamped/unknown. On
130
+ * direct completion this is the completing shred's stamp; on the recovery
131
+ * drain it is the set's first-seen shred stamp (shreds of a set are sent µs
132
+ * apart, so the two are equivalent latency baselines).
133
+ */
134
+ sendTsUs: bigint;
135
+ /** True when produced by loss recovery (`try_recover_all`) rather than direct completion. */
136
+ recovered: boolean;
137
+ }
138
+ /** Discriminated union of all commands sent from the main thread to the shred worker. */
139
+ export type ShredWorkerCommand = {
140
+ type: "configure";
141
+ config: ShredWorkerConfig;
142
+ } | {
143
+ /**
144
+ * Hand the worker a stream of raw shred frames to batch-drain. Each
145
+ * chunk is one wire frame (one WebTransport datagram). The stream is
146
+ * transferred (add to the postMessage transfer list) — the main thread
147
+ * must not touch it afterward.
148
+ */
149
+ type: "attachStream";
150
+ readable: ReadableStream<Uint8Array>;
151
+ } | {
152
+ /**
153
+ * Feed one already-demuxed raw shred frame. `frame` is transferred
154
+ * zero-copy — the caller must not access it after posting. Use this for
155
+ * sources whose demux stays on the main thread (moqtail OBJECT_DATAGRAM
156
+ * callback, IWA multicast bridge).
157
+ */
158
+ type: "feedShred";
159
+ frame: ArrayBuffer;
160
+ } | {
161
+ type: "recoverAll";
162
+ nowMs: number;
163
+ } | {
164
+ type: "dispose";
165
+ };
166
+ /** Discriminated union of all events emitted from the shred worker to the main thread. */
167
+ export type ShredWorkerEvent = {
168
+ type: "recovered";
169
+ data: ArrayBuffer;
170
+ meta: RecoveredMeta;
171
+ } | {
172
+ type: "stats";
173
+ stats: ShredWorkerStats;
174
+ } | {
175
+ type: "error";
176
+ message: string;
177
+ code?: ShredWorkerErrorCode;
178
+ };
179
+ //# sourceMappingURL=shred-worker-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shred-worker-types.d.ts","sourceRoot":"","sources":["../src/shred-worker-types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @blockcast/fec-worker — Shred (Reed-Solomon) Worker Protocol
3
+ *
4
+ * A sibling codec to the RaptorQ/MMTP worker (`fec-worker.ts`). Solana-style
5
+ * shred streams carry a different FEC payload identity than MMTP source/repair
6
+ * symbols — a shred is `(slot, fecSetIndex, localIndex, numData, numCoding,
7
+ * isCoding, lastInSet)`, not a flat `ssId` — so they need their own command
8
+ * shape. The transport harness (Worker spawn, onmessage plumbing, zero-copy
9
+ * `toOwnedArrayBuffer` transfers) is shared; the decoder WASM is not.
10
+ *
11
+ * The worker owns the batch-drain: the consumer transfers a WebTransport
12
+ * `datagrams.readable` (or any `ReadableStream<Uint8Array>` of raw shred
13
+ * frames) into the Worker via {@link ShredWorkerCommand} `attachStream`, and
14
+ * the read loop + wire parse + `ShredFecBuffer.add_shred` all run off the main
15
+ * thread. Only recovered FEC sets (and coalesced stats) cross back. A
16
+ * `feedShred` command covers sources that have already demuxed the shred bytes
17
+ * on the main thread (e.g. a moqtail OBJECT_DATAGRAM callback, or the IWA
18
+ * multicast bridge).
19
+ *
20
+ * Types here are importable from both main thread and Worker contexts.
21
+ */
22
+ // --- Wire format ------------------------------------------------------------
23
+ /**
24
+ * Wire header layout the worker parses. Mirrors the deployed shred player's
25
+ * `onShredBytes` exactly (libmmt shred-forwarder `emit_frame`) so the off-thread
26
+ * path is bit-identical to the proven on-thread path.
27
+ *
28
+ * v3 (28-byte header):
29
+ * [0] version u8 (== 3; non-3 frames are dropped, not misframed)
30
+ * [1-8] slot u64 LE
31
+ * [9-12] fecSetIndex u32 LE
32
+ * [13-16] localIndex u32 LE (data: 0..numData; coding: numData+position)
33
+ * [17] flags u8 (bit0 = DATA_COMPLETE, bit1 = IS_CODING_SHRED)
34
+ * [18] numData u8 (coding shreds only; 0 on data — WASM infers)
35
+ * [19] numCoding u8 (coding shreds only; 0 on data)
36
+ * [20-27] sendTsUs u64 LE (µs since epoch, 0 when unstamped)
37
+ * [28+] erasureShard (equal length across the FEC set)
38
+ *
39
+ * DATA_COMPLETE (last data shred in the set) is passed to `add_shred` as its
40
+ * `lastInSet` argument — the WASM uses it to infer numData for a set that
41
+ * received only data shreds (no coding).
42
+ */
43
+ export const SHRED_WIRE_VERSION = 3;
44
+ export const SHRED_HEADER_LEN = 28;
45
+ export const SHRED_FLAG_DATA_COMPLETE = 0x01;
46
+ export const SHRED_FLAG_IS_CODING = 0x02;
47
+ //# sourceMappingURL=shred-worker-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shred-worker-types.js","sourceRoot":"","sources":["../src/shred-worker-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,+EAA+E;AAE/E;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AACpC,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAC;AACnC,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAAC;AAC7C,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Return a fresh ArrayBuffer containing exactly the bytes in `view`.
3
+ *
4
+ * Worker outputs must not transfer buffers that might be owned by a WASM
5
+ * binding or by a larger shared packet. A fresh JS-owned buffer gives
6
+ * postMessage() exclusive ownership without detaching unrelated memory.
7
+ */
8
+ export declare function toOwnedArrayBuffer(view: Uint8Array): ArrayBuffer;
9
+ //# sourceMappingURL=transfer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transfer.d.ts","sourceRoot":"","sources":["../src/transfer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAIhE"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Return a fresh ArrayBuffer containing exactly the bytes in `view`.
3
+ *
4
+ * Worker outputs must not transfer buffers that might be owned by a WASM
5
+ * binding or by a larger shared packet. A fresh JS-owned buffer gives
6
+ * postMessage() exclusive ownership without detaching unrelated memory.
7
+ */
8
+ export function toOwnedArrayBuffer(view) {
9
+ const copy = new Uint8Array(view.byteLength);
10
+ copy.set(view);
11
+ return copy.buffer;
12
+ }
13
+ //# sourceMappingURL=transfer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transfer.js","sourceRoot":"","sources":["../src/transfer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAgB;IAClD,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACf,OAAO,IAAI,CAAC,MAAM,CAAC;AACpB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@blockcast/fec-worker",
3
+ "version": "0.1.0",
4
+ "description": "Worker-per-track FEC decode with zero-copy ArrayBuffer transfer and ALTA auth plumbing",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./worker": {
14
+ "import": "./dist/fec-worker.js"
15
+ },
16
+ "./shred-worker": {
17
+ "import": "./dist/shred-fec-worker.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc && node build.mjs",
26
+ "dev": "tsc --watch",
27
+ "clean": "rm -rf dist",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "@blockcast/mmt-alta-parse": "^0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "esbuild": "^0.28.0",
37
+ "typescript": "^5.3.0",
38
+ "vitest": "^1.0.0"
39
+ },
40
+ "keywords": [
41
+ "fec",
42
+ "worker",
43
+ "raptorq",
44
+ "zero-copy",
45
+ "transferable",
46
+ "alta"
47
+ ],
48
+ "license": "Apache-2.0",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/Blockcast/libmmt.git",
52
+ "directory": "packages/fec-worker"
53
+ }
54
+ }