Files
skills/docs/foundation/data/web-workers/src/streaming/stream.md
2026-05-29 08:20:56 +01:00

3.6 KiB

stream

(no submodules)

Purpose

stream.ts exposes sendStream, a streaming RPC primitive that returns an AsyncIterable<TChunk> driven by frames the worker pushes back to the main thread. It complements the sendRequest request/response API by letting workers stream partial results — useful for progressive renders, chunked compression, log tails, or any long-running operation that produces incremental output.

The protocol is intentionally minimal: the consumer posts an initial envelope { streamId, payload } to the worker; the worker pushes { id: streamId, payload: { chunk } } frames, and signals completion with { id: streamId, payload: { done: true } }. Aborting the consumer's signal posts a stream:cancel frame to the worker and throws WEB_WORKERS.STREAM_ABORTED.

Features

  • Async-iterable interface: for await (const chunk of stream) ....
  • AbortSignal cancellation: aborts both the iterator and posts a cancel frame to the worker.
  • Pre-aborted signal short-circuits with WEB_WORKERS.STREAM_ABORTED and no initial message is posted.
  • Frame correlation by stream id: foreign-worker frames and foreign-id frames are ignored, so multiple concurrent streams over the same manager coexist cleanly.
  • Optional idFactory for deterministic ids in tests.
  • Optional transfer list for zero-copy initial payload handoff.

Usage: Basic Example

import { sendStream, WorkerManager } from "@suaveplan/web-workers";

const manager = new WorkerManager({ factory });
manager.create({ name: "tail", url: WORKER_URL });

for await (const line of sendStream<string, string>(
    manager,
    "tail",
    "tail-log",
    "/var/log/app.log"
)) {
    appendLine(line);
}

Usage: Advanced Example

import { sendStream, WEB_WORKERS_CODES } from "@suaveplan/web-workers";

const ctrl = new AbortController();
const buf = new ArrayBuffer(8 * 1024 * 1024);
const stream = sendStream<ArrayBuffer, ArrayBuffer>(
    manager,
    "compress",
    "lz4-stream",
    buf,
    { signal: ctrl.signal, transfer: [buf] }
);

const chunks: ArrayBuffer[] = [];
try {
    for await (const chunk of stream) {
        chunks.push(chunk);
        if (cancelRequested) ctrl.abort();
    }
} catch (err) {
    if (
        err instanceof Error &&
        (err as { code?: string }).code === WEB_WORKERS_CODES.STREAM_ABORTED
    ) {
        // expected when ctrl.abort() fires
    } else {
        throw err;
    }
}

API Reference

  • sendStream<TReq, TChunk>(manager, name, type, payload, options?): AsyncIterable<TChunk>.
  • interface StreamCapableManager { events; postMessage<T>(name, type, payload, options?) }.
  • interface StreamRequestEnvelope<TReq> { streamId: string; payload: TReq }.
  • type StreamIdFactory = () => string.
  • interface SendStreamOptions { signal?: AbortSignal; transfer?: Transferable[]; idFactory?: StreamIdFactory }.

Implementation Notes

The iterator uses an internal queue + resolveNext continuation pattern: every received frame pushes onto the queue and calls resolveNext if the consumer was waiting; the consumer's next-iteration loop drains the queue and then awaits on a fresh promise. This avoids buffering the whole stream in memory and gives the consumer natural backpressure (the consumer pulls). The initial payload is wrapped in a StreamRequestEnvelope so the worker sees both the streamId and the original payload — without that wrapper the worker would have no way to correlate frame ids to incoming requests. The cancel frame stream:cancel is sent via the same postMessage channel so the worker side can stop producing without needing a separate control channel.