@blockcast/fec-worker
v0.1.0
Published
Worker-per-track FEC decode with zero-copy ArrayBuffer transfer and ALTA auth plumbing
Downloads
92
Maintainers
Readme
@blockcast/fec-worker
A Web Worker wrapper around @blockcast/mmt-fec that moves the RaptorQ decode off the main thread. The main-thread client posts source and repair symbols to a dedicated worker via postMessage with Transferable ArrayBuffer ownership transfer, so packet payloads cross the thread boundary with zero copies. Decoded / FEC-recovered frames travel back the same way. Critical for IWA / web players where main-thread blocking on FEC decode would cause frame drops at the renderer.
Install
pnpm add @blockcast/fec-workerThe package is dual-shaped: the main-thread side (FecWorkerClient, types) is the default export; the worker entry is exposed as the ./worker subpath so consumers can hand its URL to new Worker(...) (or to the FecWorkerClient constructor, which spawns it for you).
Quick start
import { FecWorkerClient, type FecTrackConfig } from "@blockcast/fec-worker"
// Worker URL resolved against your bundler. ?worker / new URL() / import.meta —
// any pattern that gives you a module-Worker URL works.
const workerUrl = new URL("./fec-worker-entry.js", import.meta.url)
const client = new FecWorkerClient(workerUrl)
client.onFrame((data, meta) => {
// data is an ArrayBuffer transferred from the worker (zero-copy).
// meta tells you whether this frame was recovered via FEC and (if ALTA is
// configured) whether its source-packet trailer verified.
decoder.decode(data) // hand off to WebCodecs / MSE
})
client.onSnapshot((stats) => fecPanel.render(stats))
client.onError((msg) => console.error("[fec]", msg))
// Configure with a pre-compiled mmt-wasm Module from your loader's importmap.
const config: FecTrackConfig = {
codec: "avc1.64001f",
trackName: "video/base",
trackType: "video",
resolution: { w: 1920, h: 1080 },
framerate: 30,
algorithm: "raptor",
k: 32,
repairCount: 8,
symbolSize: 1312,
interleaveDepth: 30,
interleaveMs: 1000, // 30 frames @ 30 fps
deliveryWindowMs: 2000, // computeFecDeliveryWindowMs(...) from the catalog
fecMode: "subframe",
altaEnabled: false,
relayBlockSigEnabled: false,
}
client.configure(config, mmtWasmModule)
// Feed packets as they arrive from transport. ArrayBuffers are transferred —
// caller MUST NOT touch `data` after the call.
client.feedSource(ssId, packetBuffer, captureTs)
client.feedRepair(ssStart, ssbLength, rsId, repairBuffer, captureTs)FecWorkerClient accepts either a URL (it spawns the worker for you with { type: "module" }) or a pre-created Worker instance (used in tests for constructor injection).
Why a Worker
A 1080p30 stream with K=32, P=8, symbolSize=1312 produces ~1 RaptorQ source-block decode every second per track. Decode time is amortized across SIMD lanes inside the WASM, but the worst-case block — one with the maximum recoverable loss pattern — can spike past 16 ms. Run that on the main thread and you've blown a frame; the renderer drops, the user sees a stutter, and CMCD reports a pacing violation.
Pushing the decode into a dedicated Worker keeps the main thread free for input handling, MSE buffer maintenance, and frame pacing. The same module compiled into both contexts decodes identically; what changes is who's blocked when a worst-case block hits.
This is the Worker-per-track FEC pipeline architectural decision tracked in the parent monorepo as a v2.0 milestone (pim-multicast-gateway/.planning/MILESTONES.md — "Worker-per-track FEC pipeline with zero-copy ArrayBuffer transfers"). Each track (base / delta / repair / audio) runs in its own Worker, so a stall on one track's decode never starves the others.
Architecture
Main thread │ FEC Worker (per track)
│
transport packet │
│ │
▼ │
FecWorkerClient.feedSource(...) │
│ │
│ postMessage(cmd, [data, ...]) │
│ ─ data: ArrayBuffer (transferred, neutered on sender)
│ ─ wasmModule: WebAssembly.Module (structured-cloned)
├──────────────────────────────────►│ self.onmessage
│ │ │
│ │ ▼
│ │ WasmFecDecoder.add_source(...)
│ │ │
│ │ ▼ (recovered or pass-through)
│ │ postMessage({type:"frame", data}, [data])
│◄──────────────────────────────────┤
│ │
▼ │
FecWorkerClient.onFrame(data, meta) │
│ │
▼ │
WebCodecs / MSE │Buffers cross the boundary via the Transferable list. After postMessage, the sender's ArrayBuffer is detached — touching it throws. This is the contract that makes zero-copy safe: there is no aliased view that could be mutated mid-decode.
WebAssembly.Module is structured-cloneable (not transferable) and is cloned (cheap — the underlying compiled module is shared across realms). The ALTA public key, on the other hand, is a regular ArrayBuffer and is transferred.
Errors that escape the worker's try/catch (in either the sync command path or the async configure path) are emitted as {type: "error", message} events rather than thrown — the main thread surfaces them through onError.
Message protocol
All messages are typed as discriminated unions in fec-worker-types.ts. Both the worker and the client import the same definitions, so adding a message variant is a single-file change that the compiler enforces on both sides.
Inbound — FecWorkerCommand (main → worker)
| type | Payload | Transferred |
|--------|---------|-------------|
| configure | config: FecTrackConfig, wasmModule: WebAssembly.Module, altaPublicKey?: ArrayBuffer, altaWasmModule?: WebAssembly.Module | altaPublicKey only — WASM modules are structured-cloned |
| feedSource | ssId: number, data: ArrayBuffer, ts: number, optional altaAuth: ArrayBuffer + altaPayloadEnd: number | data and altaAuth |
| feedRepair | ssStart: number, ssbLength: number, rsId: number, data: ArrayBuffer, ts: number | data |
| relayBlockSig | sbn: number, sig: ArrayBuffer, hash: ArrayBuffer | sig, hash |
| cleanup | sbns: number[] (all timed-out blocks covered by this coalesced barrier) | none |
| dispose | — | none |
feedSource carries the flat 32-bit Source Symbol ID from the MMTP Source FEC Payload ID (ISO 23008-1 §C.5.2). The WASM decoder derives SBN = floor(ssId / K) and ESI = ssId % K internally — do not pre-compute these.
feedRepair carries the wire-level Repair FEC Payload ID fields directly (ISO 23008-1 §C.5.3): ssStart is the SS_ID of the first source symbol in the block, ssbLength must equal the track's configured K, and rsId is the repair index within the block. A different K starts a new configured decoder epoch; it is never inferred from repair traffic.
Outbound — FecWorkerEvent (worker → main)
| type | Payload | Transferred back |
|--------|---------|------------------|
| frame | data: ArrayBuffer, meta: FrameMeta | data |
| snapshot | stats: FecTrackStats | none (structured clone) |
| blockUpdate | block: FecBlockSnapshot | none |
| greenFill | sbn: number, data: ArrayBuffer | data |
| cleanupComplete | sbns: number[] | none |
| error | message: string | none |
FrameMeta carries recovered: boolean (this frame came out of the FEC decode, not the direct path), sbn: number, and altaVerified: boolean | null (signature outcome, or null when ALTA is not configured / verification is pending).
Lifecycle
new FecWorkerClient(url)
│ worker spawned with { type: "module" }
▼
client.configure(config, wasmModule [, altaPublicKey, altaWasmModule])
│ worker initSyncs WASM, instantiates MmtFecDecoder(interleaveDepth, videoFill),
│ immediately configures exact catalog K/T (no decoder defaults),
│ derives pendingVerifyCap = ceil(D × 1.5) and TTL = ceil(K × interleaveMs × 1.5)
│ from catalog values, throws if any of K / D / interleaveMs ≤ 0,
│ starts the 200 ms snapshot timer, emits initial snapshot
▼
client.feedSource(...) / feedRepair(...) ◄── steady state
│ every transferred ArrayBuffer detaches on the main thread
│ decoded / recovered frames flow back as {type:"frame"}
│ every 200 ms a {type:"snapshot"} carries FecTrackStats
▼
client.cleanup(sbn) ◄── per-block inactivity deadline
│ coalesces queued SBNs behind one worker queue-drain barrier
│ evicts blocks relative to max(sbns) − D × 3
│ emits green-fill with the true SBN from WASM cleanup_blocks_detailed()
│ acknowledges every SBN in {type:"cleanupComplete", sbns}
│ any block still in "collecting" at eviction time is counted as failed
▼
client.dispose()
sends {type:"dispose"}, terminates worker. WASM decoder and ALTA
verifier are explicitly free()'d before terminate so their linear
memory is released (FinalizationRegistry would not run between
dispose and terminate). Idempotent.Backpressure
postMessage is fire-and-forget at the JS level — there is no built-in flow control. If the main thread can't drain frame events fast enough, the worker's outbound queue grows. The remedy is upstream of this package: pace your input (feedSource / feedRepair) against actual decode capacity by watching the avgRecoveryMs / maxRecoveryMs fields of each snapshot. Spikes in maxRecoveryMs past your frame budget are the early signal.
Stats
The snapshot event delivers a FecTrackStats every 200 ms (catalog-derived; not configurable from this layer). Key fields:
| Field | Meaning |
|-------|---------|
| sourceSymbols, repairSymbols | Raw ingress counters |
| blocksComplete | Block had all K source symbols without needing repair |
| blocksRecovered | Block was recovered using ≥1 repair symbol |
| blocksFailed | Block aged out before reaching K |
| greenFillFrames | Frames substituted with green-fill on unrecoverable loss |
| recoveryRate | recovered / (recovered + failed), 0–100 |
| avgRecoveryMs, maxRecoveryMs | Rolling decode latency (capped at 1000 samples) |
| liveSbn | Highest source-block number observed |
| blocks | Up to max(D × 3, 24) recent FecBlockSnapshots for live visualization |
| interleaveWindowBlocks | D from config |
When ALTA is enabled, the snapshot also carries the four-state verification accounting — altaVerified (crypto passed), altaFailed (tamper-confirmed), altaPending (unverifiable, e.g. missing trailer / late-joiner anchor gap), altaError (verifier threw — infrastructure flake, not tamper) — plus altaInitFailed (true if the ALTA WASM didn't initialize), altaPendingEvictedCap / altaPendingEvictedTtl (deferred-queue eviction reasons), and a bounded altaFailedDetails[] ring with the last ≤16 tamper events. The four-way split exists so ops alerting can distinguish a network problem (altaPending rising) from an attack (altaFailed rising) from a runtime bug (altaError rising).
FecTrackStats is also the shape consumed downstream by @blockcast/mmt-container's CmcdProducer and the diagnostics fec-panel — keep it stable when you extend it.
Related packages
@blockcast/mmt-fec— the wrapped library. Container-agnostic RaptorQ decoder + block manager + reorder buffer. Lives on the main thread when you don't need a Worker; this package is the off-thread variant of the same decoder.@blockcast/mmt-container— the consumer that wires this Worker into the data path.FecManagerinstantiates oneFecWorkerClientper track, routes MMTP source/repair payloads in, and pumps recovered frames into the MFU reassembler / fmp4 segmenter.@blockcast/mmt-alta-parse— ALTA trailer extraction. Used inside the Worker to parse the[0xF0A1][len][auth]trailer off recovered source symbols (publisher-stripped trailers don't appear on direct-source packets, which carry the trailer in band).
Testing
pnpm test # vitest run
pnpm test:watch # vitest in watch mode
pnpm typecheck # tsc --noEmitTests cover:
- The message-protocol type narrowing (
fec-worker-types.test.ts) - The main-thread client's transfer-list construction, callback dispatch, and dispose idempotence (
fec-worker-client.test.ts) — the Worker is injected via constructor as a stub so the tests don't need a real WASM build
License
Apache-2.0 — see LICENSE.
