@statewalker/webrun-streams 0.2.1 → 0.2.2

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 (2) hide show
  1. package/README.md +328 -286
  2. package/package.json +6 -3
package/README.md CHANGED
@@ -1,57 +1,106 @@
1
1
  # @statewalker/webrun-streams
2
2
 
3
- Async-iterator and `ReadableStream` primitives: `collect` / `collectBytes` / `collectString`, text and JSONL codecs, line splitting/joining, a backpressure-aware queue-based generator, a chunk protocol for pushing iterators across transports, conversions between async iterators and WHATWG `ReadableStream<Uint8Array>`, and serialisable `Error` objects.
3
+ ## What it is
4
4
 
5
- It also defines the **`Duplex` seam** the whole `webrun-streams-*` transport family implements, and `emulateMux` — a stream multiplexer that turns any message-oriented byte channel into many concurrent `Duplex` calls.
5
+ Async-iterator and `ReadableStream` primitives, plus the transport seam the
6
+ `webrun-streams-*` adapters are written against. It contains collectors
7
+ (`collect`, `collectBytes`, `collectString`), text, line and JSONL codecs, a
8
+ backpressure-aware callback-to-generator bridge, a chunk protocol for moving an
9
+ iterator across any message channel, conversions between async iterators and
10
+ WHATWG `ReadableStream<Uint8Array>`, serialisable errors, the `Duplex` /
11
+ `Connect` / `Serve` types, and `emulateMux`, which multiplexes many concurrent
12
+ `Duplex` calls over one message-oriented byte channel.
6
13
 
7
14
  ## Why it exists
8
15
 
9
- Every higher-level package in the `webrun-*` family (and its consumers — scanners, indexers, chat pipelines) needs the same small set of building blocks:
16
+ Code that moves bytes between a browser, a worker, a server and a peer needs
17
+ the same small set of building blocks in every place:
18
+
19
+ 1. **Collectors** that turn an async iterable into an array, a `Uint8Array` or a
20
+ `string` without boilerplate.
21
+ 2. A **callback-to-async-iterator bridge** with backpressure, so a producer
22
+ knows when the consumer has taken a value or stopped listening.
23
+ 3. A **chunk protocol**: a `{ done, value?, error? }` envelope that crosses any
24
+ transport (MessagePort, WebSocket, IPC, in-memory) and rebuilds the iterator
25
+ on the other side, errors included.
26
+ 4. **WHATWG to async-iterator conversions** that carry cancellation, so code
27
+ written against `fetch` bodies interoperates with `for await` code.
28
+ 5. **Error serialisation** that keeps `message`, `stack` and custom fields across
29
+ JSON and structured-clone boundaries.
30
+ 6. **Line, JSONL and text codecs**, so stream code does not re-implement
31
+ split/join/encode/decode.
32
+ 7. **One seam for transports.** Every adapter exposes the same `Duplex` shape,
33
+ so code above the transport does not change when the transport does.
34
+ Message-oriented transports have no streams of their own; `emulateMux`
35
+ supplies them once, with credit-based flow control, instead of each adapter
36
+ writing its own.
10
37
 
11
- 1. **Collectors** — turn any async iterable into a concrete array / `Uint8Array` / `string` without boilerplate; zero-copy short-circuit when a single chunk is produced.
12
- 2. A **callback-to-async-iterator** bridge — turn incoming `{done, value, error}` callbacks into a `for await` loop, with backpressure so producers know when consumers have stopped listening.
13
- 3. A **chunk protocol** — a tiny `{done, value?, error?}` envelope that can travel across any transport (MessagePort, WebSocket, IPC, in-memory) and rebuild the original iterator on the other side.
14
- 4. **WHATWG ↔ async-iterator** conversions for body bytes, so code written against `fetch` (`ReadableStream<Uint8Array>`) can interoperate with `for await` code and back.
15
- 5. **Error (de)serialisation** for passing exceptions across structured-clone / JSON boundaries without losing stacks or extra fields.
16
- 6. **Line / JSONL / text codecs** so stream-processing code doesn't re-invent split/join/encode/decode in every consumer.
17
-
18
- The MessagePack codec that previously rode along here is split out to [`@statewalker/webrun-msgpack`](../webrun-msgpack) so consumers that don't need framing don't pull in a MessagePack implementation.
19
-
20
- ## Install
38
+ ## How to use
21
39
 
22
40
  ```sh
23
- npm install @statewalker/webrun-streams
41
+ pnpm add @statewalker/webrun-streams
24
42
  ```
25
43
 
26
- Zero runtime dependencies, zero peer dependencies. ESM only
27
- (`"type": "module"`); runs in browsers, Node, Deno, Bun and Workers. This is the
28
- foundation package — everything else in the workspace depends on it.
44
+ No runtime dependencies and no peer dependencies. ESM only. One entry point,
45
+ `.` (the package root), which exports everything listed below. It uses only
46
+ platform globals (`TextEncoder`, `TextDecoder`, `ReadableStream`, `Blob`), so it
47
+ runs in browsers, workers and Node.
29
48
 
30
- ## How to use
49
+ | Export | What it does |
50
+ | --- | --- |
51
+ | `collect(it)` | Drain an `AsyncIterable<T>` into `T[]`. |
52
+ | `collectBytes(it)` | Concatenate `AsyncIterable<Uint8Array>` into one `Uint8Array`. A single chunk is returned as is, without a copy. |
53
+ | `collectString(it)` | Concatenate `AsyncIterable<string>` into one `string`. |
54
+ | `encodeText(it)` / `decodeText(it)` | UTF-8 `AsyncIterable<string>` to `AsyncGenerator<Uint8Array>` and back. `decodeText` handles multi-byte characters split across chunks. |
55
+ | `splitLines(it)` / `joinLines(it)` | Split string chunks on `\n` (lines may span chunks); append `\n` to each string. |
56
+ | `encodeJsonl(it)` / `decodeJsonl(it)` | Values to `\n`-terminated JSON strings; string chunks to parsed values. `decodeJsonl` splits lines itself and skips blank lines. |
57
+ | `map(it, fn)` | Map an `AsyncIterable<I>` through `fn: (item) => O \| Promise<O>`. |
58
+ | `newAsyncGenerator(init, skipValues?)` | Bridge `next(value)` / `done(error?)` callbacks into an `AsyncGenerator<T>`. Both callbacks return `Promise<boolean>`. |
59
+ | `sendIterator(send, iterable)` | Drain an iterable into `send({ done, value, error })` calls, ending with exactly one `{ done: true }` chunk. |
60
+ | `recieveIterator(installer)` | Inverse of `sendIterator`: turn delivered chunks into an `AsyncGenerator<T>`. |
61
+ | `toReadableStream(iterator)` | Wrap an `AsyncIterator<Uint8Array>` in a `ReadableStream<Uint8Array>`. |
62
+ | `fromReadableStream(stream)` | Iterate a `ReadableStream<Uint8Array>` as an `AsyncGenerator<Uint8Array>`. |
63
+ | `normalizeToUint8Array(value)` | Coerce a `ByteLike` (`Uint8Array`, `ArrayBuffer`, `ArrayBufferView`, `Blob`, `string`) to `Uint8Array`. Synchronous, except a `Blob`, which returns a `Promise<Uint8Array>`. |
64
+ | `toChunks(size?)` | Curried. Returns a transform that splits `Uint8Array` chunks into pieces of at most `size` bytes (default 16384). |
65
+ | `serializeError(error)` / `deserializeError(obj \| string)` | `Error` (or anything) to a plain `SerializedError` and back, keeping own enumerable fields. |
66
+ | `emulateMux(channel, opts?)` | Many concurrent `Duplex` calls over one `ByteChannel`. Returns `{ call, serve, close }`. |
67
+ | `newCreditLedger(initial?)` / `newCreditGrantor(window, threshold?)` | The sender and receiver halves of credit-based flow control, as pure state machines. |
68
+ | `TransportClosedError` | Error class raised when a transport closes with calls in flight. |
69
+
70
+ Types: `Duplex`, `Connect<P>`, `Serve<P>`, `ByteChannel`, `ByteLike`,
71
+ `EmulateMuxOptions`, `CreditLedger`, `CreditGrantor`, `IteratorChunk<T>`,
72
+ `ChunkSender<T>`, `ChunkReceiver<T>`, `ReceiverInstaller<T>`,
73
+ `SerializedError`.
74
+
75
+ ### The `Duplex` seam
31
76
 
32
- ```sh
33
- npm install @statewalker/webrun-streams
77
+ ```ts
78
+ type Duplex = (input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) => AsyncGenerator<Uint8Array>;
79
+ type Connect<P> = (params: P) => Promise<{ call: Duplex; close: () => Promise<void> }>;
80
+ type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
34
81
  ```
35
82
 
36
- | Export | Purpose |
83
+ One `Duplex` invocation carries one logical call: the caller supplies input
84
+ bytes, the peer yields output bytes. Caller and handler have the same shape, so
85
+ an in-process test can set `const call = handler` and run with no transport.
86
+ `Connect` stands up one transport and returns a `call`; each `call` opens a new
87
+ sub-stream on it. `Serve` registers a handler and resolves to an idempotent
88
+ teardown.
89
+
90
+ Iterator semantics carry every signal, so there is no separate close or abort
91
+ API:
92
+
93
+ | Signal | Mechanism |
37
94
  | --- | --- |
38
- | `collect(it)` | Drain `AsyncIterable<T>` into `T[]`. |
39
- | `collectBytes(it)` | Concatenate `AsyncIterable<Uint8Array>` into one `Uint8Array` (zero-copy when a single chunk). |
40
- | `collectString(it)` | Concatenate `AsyncIterable<string>` into one `string`. |
41
- | `encodeText(it)` / `decodeText(it)` | UTF-8 `AsyncIterable<string>` ↔ `AsyncIterable<Uint8Array>`. |
42
- | `splitLines(it)` / `joinLines(it)` | Line splitting over `string` streams (handles cross-chunk lines) and reverse. |
43
- | `encodeJsonl(it)` / `decodeJsonl(it)` | JSON values ↔ `\n`-delimited JSON string stream. |
44
- | `map(it, fn)` | Stream-map an `AsyncIterable<T>` through `fn: T => U \| Promise<U>`. |
45
- | `newAsyncGenerator(init, skipValues?)` | Bridge imperative `next/done` callbacks into an `AsyncGenerator<T>`; returns `Promise<boolean>` for backpressure. |
46
- | `sendIterator(send, iterable)` | Drain an (async) iterable into `send({done, value, error})` chunk calls; completes with one trailing `{done: true}` chunk. |
47
- | `recieveIterator(installer)` | Inverse of `sendIterator`: wire an installer's chunk callback into a new `AsyncGenerator<T>`. |
48
- | `toReadableStream(it)` | Wrap an `AsyncIterator<Uint8Array>` in a `ReadableStream<Uint8Array>`. |
49
- | `fromReadableStream(stream)` | Iterate a `ReadableStream<Uint8Array>` as `AsyncGenerator<Uint8Array>`. |
50
- | `emulateMux(channel, opts?)` | Multiplex many concurrent `Duplex` calls over one `ByteChannel`; returns `{ call, serve, close }`. |
51
- | `normalizeToUint8Array(value)` | Coerce a `ByteLike` (string, `ArrayBuffer`, typed array, `Blob`) to `Uint8Array`. Synchronous except for a `Blob`, which returns a `Promise`. |
52
- | `toChunks(size?)` | Curried: returns a transform that re-chunks an `AsyncIterable<Uint8Array>` into pieces of at most `size` bytes. `size` defaults to 16384. |
53
- | `serializeError(error)` | Turn an `Error` (or anything) into a plain `{message, stack, …}` object preserving subclass fields. |
54
- | `deserializeError(obj \| string)` | Reconstruct an `Error` from a serialised form, restoring extra fields. |
95
+ | Consumer is done early | `.return()` on the output; the producer's `finally` runs |
96
+ | Producer failed | `throw`; the consumer's `for await` throws |
97
+ | Either side finished normally | Normal exhaustion; matching end on the other side |
98
+
99
+ **Caller obligation.** Either drain the returned generator or `.return()` it.
100
+ Dropping the reference does neither: the consumer never acknowledges inbound
101
+ data, the peer's outbound pump blocks waiting for credit, no end-of-stream is
102
+ exchanged, and both peers hold the stream open. An unreferenced generator is
103
+ not observable, so no transport can detect this for you.
55
104
 
56
105
  ## Examples
57
106
 
@@ -73,38 +122,40 @@ async function* strings() { yield "a"; yield "bc"; }
73
122
  await collectString(strings()); // "abc"
74
123
  ```
75
124
 
76
- ### Text / JSONL / lines codecs
125
+ ### Text, JSONL and line codecs
77
126
 
78
127
  ```ts
79
128
  import {
129
+ collectString,
80
130
  decodeJsonl,
81
131
  decodeText,
82
132
  encodeJsonl,
83
133
  encodeText,
84
- joinLines,
85
134
  splitLines,
86
135
  } from "@statewalker/webrun-streams";
87
136
 
88
137
  async function* chunks() {
89
- yield new Uint8Array([0x7b, 0x22, 0x61]); // partial
90
- yield new Uint8Array([0x22, 0x3a, 0x31, 0x7d, 0x0a]);
138
+ yield new Uint8Array([0x7b, 0x22, 0x61]); // '{"a'
139
+ yield new Uint8Array([0x22, 0x3a, 0x31, 0x7d, 0x0a]); // '":1}\n'
91
140
  }
92
141
 
93
- // `decodeJsonl` splits lines itself — do not wrap it in `splitLines`, or a
94
- // stream carrying more than one value arrives as one concatenated line and
95
- // `JSON.parse` throws.
96
- const values = decodeJsonl<{ a: number }>(decodeText(chunks()));
97
- for await (const v of values) console.log(v); // { a: 1 }
142
+ // `decodeJsonl` splits lines itself. Do not wrap its input in `splitLines`:
143
+ // the lines lose their "\n", arrive concatenated, and `JSON.parse` throws.
144
+ for await (const v of decodeJsonl<{ a: number }>(decodeText(chunks()))) {
145
+ console.log(v); // { a: 1 }
146
+ }
98
147
 
99
- // inverse. `encodeJsonl` already terminates each value with "\n", so
100
- // `joinLines` here would emit a blank line between every record.
101
- const jsonl = encodeText(encodeJsonl([{ a: 1 }, { a: 2 }]));
148
+ // The inverse. `encodeJsonl` already ends each value with "\n", so adding
149
+ // `joinLines` would emit a blank line after every record.
150
+ async function* records() { yield { a: 1 }; yield { a: 2 }; }
151
+ await collectString(decodeText(encodeText(encodeJsonl(records())))); // '{"a":1}\n{"a":2}\n'
102
152
 
103
- // `splitLines` / `joinLines` are for plain string streams, with no JSON involved:
104
- for await (const line of splitLines(decodeText(byteStream))) console.log(line);
153
+ // `splitLines` / `joinLines` are for plain string streams.
154
+ async function* text() { yield "one\ntw"; yield "o\n"; }
155
+ for await (const line of splitLines(text())) console.log(line); // "one", "two"
105
156
  ```
106
157
 
107
- ### Callback → AsyncGenerator bridge
158
+ ### Callback to AsyncGenerator bridge
108
159
 
109
160
  ```ts
110
161
  import { newAsyncGenerator } from "@statewalker/webrun-streams";
@@ -119,7 +170,7 @@ function tickEverySecond(): AsyncGenerator<number> {
119
170
  clearInterval(id);
120
171
  }
121
172
  }, 1000);
122
- return () => clearInterval(id); // cleanup if consumer breaks early
173
+ return () => clearInterval(id); // runs if the consumer stops early
123
174
  });
124
175
  }
125
176
 
@@ -129,30 +180,32 @@ for await (const n of tickEverySecond()) console.log(n); // 0 … 4
129
180
  ### Iterator chunk protocol
130
181
 
131
182
  ```ts
132
- import { collect, recieveIterator, sendIterator } from "@statewalker/webrun-streams";
183
+ import {
184
+ type ChunkReceiver,
185
+ type IteratorChunk,
186
+ collect,
187
+ recieveIterator,
188
+ sendIterator,
189
+ } from "@statewalker/webrun-streams";
133
190
 
134
- // Drain an iterable across any transport.
135
- async function transport<T>(chunk: { done: boolean; value?: T; error?: unknown }) {
136
- await myChannel.send(chunk); // …however your channel sends
137
- }
191
+ // Stand-in for a real channel: whatever is sent is delivered to the receiver.
192
+ let deliver: ChunkReceiver<number> | undefined;
193
+ const transport = async (chunk: IteratorChunk<number>) => {
194
+ await deliver?.(chunk);
195
+ };
138
196
 
139
- // On the other side, rebuild the original iterator.
140
- const iter = recieveIterator<number>((deliver) => {
141
- myChannel.onMessage = (chunk) => deliver(chunk);
197
+ const iter = recieveIterator<number>((d) => {
198
+ deliver = d;
142
199
  });
143
200
 
144
- // Start consuming *before* (or concurrently with) draining the source.
145
- // `deliver` resolves only once the consumer has dequeued the chunk — that is
146
- // the backpressure — so awaiting `sendIterator` with nobody iterating `iter`
147
- // deadlocks both sides.
148
- const [, received] = await Promise.all([
149
- sendIterator(transport, [1, 2, 3]),
150
- collect(iter),
151
- ]);
201
+ // Consume while sending. `deliver` resolves only once the consumer has taken
202
+ // the chunk (that is the backpressure), so awaiting `sendIterator` with nobody
203
+ // iterating `iter` deadlocks.
204
+ const [, received] = await Promise.all([sendIterator(transport, [1, 2, 3]), collect(iter)]);
152
205
  console.log(received); // [1, 2, 3]
153
206
  ```
154
207
 
155
- ### WHATWG streams ↔ async iterators
208
+ ### WHATWG streams and async iterators
156
209
 
157
210
  ```ts
158
211
  import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
@@ -163,20 +216,28 @@ async function* encoded() {
163
216
  yield e.encode("world");
164
217
  }
165
218
 
166
- // Give an iterable a ReadableStream face for fetch / Response.
167
219
  const response = new Response(toReadableStream(encoded()));
168
-
169
- // …and the other way around.
170
- const reqBody = new Request("/x", { method: "POST", body: response.body }).body!;
171
- for await (const chunk of fromReadableStream(reqBody)) {
220
+ for await (const chunk of fromReadableStream(response.body!)) {
172
221
  // chunk: Uint8Array
173
222
  }
174
223
  ```
175
224
 
176
- ### Error roundtrip
225
+ ### Byte normalisation and re-chunking
177
226
 
178
227
  ```ts
179
- import { serializeError, deserializeError } from "@statewalker/webrun-streams";
228
+ import { collect, normalizeToUint8Array, toChunks } from "@statewalker/webrun-streams";
229
+
230
+ normalizeToUint8Array("ab"); // Uint8Array [97, 98]
231
+ await normalizeToUint8Array(new Blob(["ab"])); // Blob input returns a Promise
232
+
233
+ async function* big() { yield new Uint8Array(20_000); }
234
+ (await collect(toChunks()(big()))).map((c) => c.byteLength); // [16384, 3616]
235
+ ```
236
+
237
+ ### Error round trip
238
+
239
+ ```ts
240
+ import { deserializeError, serializeError } from "@statewalker/webrun-streams";
180
241
 
181
242
  class NotFoundError extends Error {
182
243
  status = 404;
@@ -186,242 +247,223 @@ const wire = serializeError(new NotFoundError("missing"));
186
247
  // { message: "missing", stack: "…", status: 404 }
187
248
 
188
249
  const restored = deserializeError(wire) as Error & { status?: number };
189
- console.log(restored instanceof Error); // true
190
- console.log(restored.status); // 404
250
+ restored instanceof Error; // true (a plain Error, not NotFoundError)
251
+ restored.status; // 404
191
252
  ```
192
253
 
193
- ## The `Duplex` seam
194
-
195
- Everything in the `webrun-streams-*` family speaks one shape:
254
+ ### `emulateMux`
196
255
 
197
256
  ```ts
198
- type Duplex = (input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) => AsyncGenerator<Uint8Array>;
199
- ```
200
-
201
- One `Duplex` invocation carries **one logical call**: the caller emits bytes,
202
- the peer yields bytes back. Because both sides have the same shape, an
203
- in-process test can wire `const caller = handler` and run with no transport at
204
- all.
205
-
206
- Iterator semantics carry every signal, which is why no separate close/abort API
207
- exists:
208
-
209
- | Signal | Mechanism |
210
- | --- | --- |
211
- | Consumer is done early | `.return()` on the output → producer's `finally` runs |
212
- | Producer failed | `throw` → consumer's `for await` throws |
213
- | Either side finished normally | Normal exhaustion → matching end on the other side |
214
-
215
- `Connect<P>` and `Serve<P>` are the adapter-side factories that stand up a
216
- transport and produce or register a `Duplex`.
217
-
218
- > **Caller obligation.** Either drain the returned generator or `.return()` it.
219
- > Dropping the reference without doing either emits no observable signal: the
220
- > abandoned consumer never acknowledges inbound data, so the peer's outbound
221
- > pump blocks awaiting that acknowledgement, no end-of-stream is exchanged, and
222
- > both peers hold the stream open. An unreferenced generator is not observable,
223
- > so no transport can detect this for you.
224
-
225
- ## `emulateMux`
257
+ import { type ByteChannel, collectBytes, emulateMux } from "@statewalker/webrun-streams";
226
258
 
227
- Turns one `ByteChannel` — anything with `send` / `recv` / `closed` / `close` —
228
- into many concurrent `Duplex` calls. Used by the MessagePort, WebSocket,
229
- LiveKit, PeerJS and signaling adapters; transports with native multiplexing
230
- (libp2p) don't need it.
259
+ declare const clientChannel: ByteChannel; // e.g. one end of a WebSocket
260
+ declare const serverChannel: ByteChannel; // the other end
231
261
 
232
- ```ts
233
- import { emulateMux } from "@statewalker/webrun-streams";
262
+ const client = emulateMux(clientChannel, { side: "initiator" });
263
+ const server = emulateMux(serverChannel, { side: "responder" });
234
264
 
235
- const { call, serve, close } = emulateMux(channel, { side: "initiator" });
265
+ const stop = server.serve(async function* echo(input) {
266
+ for await (const chunk of input) yield chunk;
267
+ });
236
268
 
237
- // caller side
238
- const response = call([new TextEncoder().encode("ping")]);
239
- for await (const chunk of response) { /* … */ }
269
+ const response = client.call([new TextEncoder().encode("ping")]);
270
+ new TextDecoder().decode(await collectBytes(response)); // "ping"
240
271
 
241
- // responder side
242
- const stop = serve(async function* handler(input) {
243
- for await (const chunk of input) yield chunk; // echo
244
- });
272
+ await stop();
273
+ await client.close();
274
+ await server.close();
245
275
  ```
246
276
 
247
277
  | Option | Default | Purpose |
248
278
  | --- | --- | --- |
249
- | `side` | `"initiator"` | Id allocation: initiator uses even ids, responder odd. Pick one per peer so they cannot collide. |
250
- | `maxStreams` | `256` | Concurrent streams before new calls are refused. |
251
- | `mtu` | `65536` | Largest payload per DATA frame; bigger chunks are split. |
252
- | `maxStreamBuffer` | `8388608` | The credit this side advertises to the peer, in bytes, and the hard cap on inbound bytes one stream may hold undrained. A peer that honours credit never reaches the cap; one that ignores it has that stream torn down. |
253
-
254
- ### Flow control
255
-
256
- Receiver-advertised credit. Each side puts its `maxStreamBuffer` in the frame it
257
- opens with — `OPEN` for the caller, the `ACK` answering it for the responder —
258
- and the sender may only send what it has been granted. Both sides start at zero,
259
- so a caller pays one round trip per stream before its first DATA frame and none
260
- thereafter. The receiver grants more once its consumer has actually drained,
261
- batched at half the window and flushed as soon as its queue empties. A sender
262
- therefore cannot overrun the receiver's buffer, and `maxStreamBuffer` bounds only
263
- peers that ignore the protocol.
264
-
265
- Backpressure is **per-stream**, so a stalled stream does not block the others,
266
- and it applies symmetrically in both directions.
267
-
268
- There is deliberately **no stall timeout**: a peer that never acknowledges
269
- blocks that producer indefinitely, exactly as a TCP receiver that never reads
270
- blocks its sender. `maxStreams` and `maxStreamBuffer` bound what that can cost.
271
- The bound is per stream and there is no mux-wide budget, so the worst case is
272
- `maxStreams × maxStreamBuffer` — 2 GiB at the defaults, against 16 MiB under the
273
- one-frame-in-flight rule this replaced. Lower `maxStreamBuffer` if that matters
274
- more than throughput.
275
-
276
- ### Behaviour on hostile input
277
-
278
- A `ByteChannel` is message-oriented, so frames are discrete and a corrupt one
279
- cannot desync the next. A frame that cannot be parsed is therefore **dropped**
280
- rather than failing the connection — otherwise one malformed frame would tear
281
- down every stream sharing the mux. A stream that exceeds `maxStreamBuffer` is
282
- torn down on its own, with an error frame sent to the peer.
283
-
284
- ## Exports
285
-
286
- Everything is exported from the package root.
287
-
288
- ### Seam types
289
-
290
- | Export | Kind | Purpose |
291
- | --- | --- | --- |
292
- | `Duplex` | type | `(input) => AsyncGenerator<Uint8Array>` — one logical call. |
293
- | `Connect<P>` | type | `(params) => Promise<{ call: Duplex; close() }>`. |
294
- | `Serve<P>` | type | `(params, handler) => Promise<() => Promise<void>>`. |
295
- | `ByteChannel` | type | `{ send, recv, closed, close }` — the minimum a transport must expose. |
296
- | `ByteLike` | type | Accepted byte inputs before normalisation. |
297
- | `emulateMux(channel, opts?)` | function | Multi-stream over a single `ByteChannel`. |
298
- | `EmulateMuxOptions` | type | `maxStreams` (256), `mtu` (64 KiB), `maxStreamBuffer` (8 MiB), `side`. |
299
- | `TransportClosedError` | class | Thrown when the transport closes with calls in flight. Catch via `instanceof` or `error.name`. |
300
-
301
- The port multiplexing layer — `multiplexPort`, `PortMux`, `structuredCodec`,
302
- `PortEnvelope` and the `MessageTarget` family of types — lives in
303
- [`@statewalker/webrun-rpc`](../webrun-rpc#port-multiplexing), not here.
304
-
305
- ### Credit
306
-
307
- | Export | Kind | Purpose |
308
- | --- | --- | --- |
309
- | `newCreditLedger(initial?)` | function | Sender-side credit: `reserve(upTo)` waits for any credit and returns how much it got (`upTo` must be >= 1; less rejects with a `RangeError`), `grant(units)` releases waiters in order, `fail(err)` unwinds them. Starts at zero unless told otherwise. |
310
- | `CreditLedger` | type | `{ available, reserve, grant, fail }`. |
311
- | `newCreditGrantor(window, threshold?)` | function | Receiver-side: `consumed(units, queueEmpty)` returns the credit to hand back, batched at `threshold` (default half the window) and flushed once the queue empties. |
312
- | `CreditGrantor` | type | `{ consumed(units, queueEmpty): number }`. |
313
-
314
- The unit is whatever the caller counts. `emulateMux` counts bytes; a value-
315
- oriented caller would count values. Nothing in this module interprets it.
316
-
317
- ### Collectors and codecs
318
-
319
- | Export | Purpose |
320
- | --- | --- |
321
- | `collect` / `collectBytes` / `collectString` | Drain an async iterable to an array / `Uint8Array` / `string`. |
322
- | `encodeText` / `decodeText` | UTF-8 `string` ↔ `Uint8Array` streams. |
323
- | `splitLines` / `joinLines` | Cross-chunk-safe line splitting and rejoining. |
324
- | `encodeJsonl` / `decodeJsonl` | JSON values ↔ `\n`-delimited string stream. |
325
- | `map` | Stream-map over an `AsyncIterable<T>`. |
326
- | `toChunks` / `normalizeToUint8Array` | Coerce assorted byte-ish inputs into `Uint8Array` chunks. |
327
-
328
- ### Iterator plumbing
329
-
330
- | Export | Purpose |
331
- | --- | --- |
332
- | `newAsyncGenerator` | Backpressure-aware queue turning `next`/`done` callbacks into an async generator. |
333
- | `sendIterator` / `recieveIterator` | Ship an async iterator across any transport. |
334
- | `IteratorChunk` | type — the `{ done, value, error }` chunk envelope they exchange. |
335
- | `ChunkSender` / `ChunkReceiver` / `ReceiverInstaller` | types — the transport-side callbacks those two are wired to. |
336
- | `toReadableStream` / `fromReadableStream` | `AsyncIterator<Uint8Array>` ↔ WHATWG `ReadableStream<Uint8Array>`. |
337
-
338
- ### Errors
339
-
340
- | Export | Purpose |
341
- | --- | --- |
342
- | `serializeError` / `deserializeError` | Preserve `message`, `stack` and custom fields across JSON / structured-clone boundaries. |
343
- | `SerializedError` | type — the wire shape those two produce and consume. |
279
+ | `side` | `"initiator"` | Stream-id allocation: initiator uses even ids (2, 4, …), responder odd ids (1, 3, …). Give the two peers different sides so ids cannot collide. |
280
+ | `maxStreams` | `256` | Concurrent streams. Further calls, and further OPENs from the peer, are refused. |
281
+ | `mtu` | `65536` | Largest payload per DATA frame. Larger chunks are split. |
282
+ | `maxStreamBuffer` | `8388608` (8 MiB) | The credit this side advertises per stream, in bytes, and the hard cap on inbound bytes one stream may hold undrained. Must be at least 1. |
344
283
 
345
284
  ## Internals
346
285
 
347
- ### `newAsyncGenerator` — backpressure queue
286
+ ### `emulateMux` frames
348
287
 
349
- A singly-linked queue of slots; each slot carries either a value or a
350
- terminal `{done: true, error?}`. Producers call `next(value)` or
351
- `done(error?)`, both returning `Promise<boolean>` that resolves once the
352
- consumer has dequeued the slot — so producers can apply backpressure by
353
- `await`ing.
288
+ A `ByteChannel` is `{ send(bytes), recv: AsyncIterable<Uint8Array>, closed: Promise<void>, close() }`
289
+ and is message-oriented: each `send` arrives as one `recv` item. `emulateMux`
290
+ puts one frame in each message:
291
+
292
+ ```
293
+ +----------------+--------+------------------+
294
+ | stream id | type | payload |
295
+ | varint, LEB128 | 1 byte | rest of message |
296
+ +----------------+--------+------------------+
297
+
298
+ OPEN 0x01 payload: uint32 BE credit the opener grants
299
+ DATA 0x02 payload: bytes (at most mtu, at most the credit held)
300
+ ACK 0x03 payload: uint32 BE credit granted (answer to OPEN, or more later)
301
+ END 0x04 sender finished this direction
302
+ ERROR 0x05 payload: JSON of serializeError(err); tears the stream down
303
+ CLOSE 0x06 receiver no longer wants this stream; tears it down
304
+ ```
354
305
 
355
- If the consumer breaks out of the `for await` early, the finally block
356
- drains remaining slots and resolves each pending `next/done` promise
357
- with `false`, letting the producer observe that its value wasn't
358
- consumed and stop. Cleanup function (if the `init` returned one) runs
359
- on the same exit path.
306
+ ```
307
+ caller responder
308
+ | OPEN id=2, credit=8 MiB ------------> | handler(input) starts
309
+ | <---------------- ACK id=2, credit=8 MiB
310
+ | DATA id=2 ... (spends credit) -------> |
311
+ | <------------------- ACK id=2, credit=n (after the consumer drained)
312
+ | END id=2 ---------------------------> |
313
+ | <----------------------------- DATA / END
314
+ ```
360
315
 
361
- `skipValues: true` switches the queue into latest-only mode: pushing a
362
- new value drops any unconsumed older ones. Useful for "show the most
363
- recent state" scenarios (live previews, resizing, etc.) where missing
364
- values is fine but lagging isn't.
316
+ ### Why credit flows from the receiver
317
+
318
+ The sender cannot know how much the receiver can buffer unless the receiver
319
+ says so. Each side therefore advertises its `maxStreamBuffer` in the frame it
320
+ opens with (OPEN for the caller, the ACK that answers it for the responder),
321
+ and a sender may only put on the wire what it has been granted. Both ledgers
322
+ start at zero, so a caller pays one round trip per stream before its first DATA
323
+ frame and none after that. A uint32 cannot carry more than `2^32 - 1`, so a
324
+ larger window is advertised as `2^32 - 1` rather than wrapping (a wrapped
325
+ `2^32` would advertise zero credit and hang the peer).
326
+
327
+ The receiver grants more only for bytes its consumer actually took. Grants are
328
+ batched until half the window has drained (`newCreditGrantor`'s default
329
+ `threshold` of `0.5`) and flushed as soon as the receive queue is empty, so the
330
+ receiver never sits on credit it owes. `reserve(upTo)` on the sender side
331
+ resolves with whatever is available, at least 1 and at most `upTo`, and waiters
332
+ are released strictly in order; a peer whose window is smaller than one `mtu`
333
+ still makes progress, one short piece at a time.
334
+
335
+ Backpressure is per stream: a stalled stream does not block the others, and it
336
+ applies in both directions. Inbound frames are pushed to the stream's queue
337
+ without waiting for the consumer, because the inbound loop must keep processing
338
+ ACKs for this side's own senders; blocking it on a slow consumer would deadlock
339
+ the two directions against each other.
340
+
341
+ The unit is opaque to `newCreditLedger` and `newCreditGrantor`. `emulateMux`
342
+ counts bytes; a value-oriented caller can count values.
343
+
344
+ ### Why there is no stall timeout
345
+
346
+ A peer that never drains blocks that stream's producer indefinitely, as a TCP
347
+ receiver that never reads blocks its sender. `maxStreams` and `maxStreamBuffer`
348
+ bound the cost. The bound is per stream and there is no mux-wide budget, so the
349
+ worst case is `maxStreams × maxStreamBuffer`: 2 GiB at the defaults. Lower
350
+ `maxStreamBuffer` if memory matters more than throughput.
351
+
352
+ ### What a hostile or broken peer can and cannot do
353
+
354
+ - A frame that cannot be parsed (truncated or over-long varint id, no type
355
+ byte) is **dropped**. Frames are discrete messages, so a corrupt one cannot
356
+ desync the next, and failing the connection would let one bad frame tear down
357
+ every stream sharing it.
358
+ - A peer that ignores credit and floods DATA has that one stream torn down when
359
+ it holds more than `maxStreamBuffer` undrained bytes. The peer receives an
360
+ ERROR frame: `emulateMux: stream <id> buffered <n> bytes past maxStreamBuffer=<max> without being drained`.
361
+ - An OPEN for a stream id that is already live is ignored. An OPEN past the
362
+ limit is answered with ERROR `emulateMux: maxStreams=<n> exceeded`; an OPEN
363
+ with no handler registered, with ERROR `emulateMux: no handler registered`.
364
+ - An ACK without a 4-byte credit payload is ignored rather than granting an
365
+ arbitrary amount.
366
+
367
+ ### How streams end
368
+
369
+ A stream holds a slot until both directions finish (END sent and END
370
+ received), so a normally completed call releases its slot. Cancellation
371
+ releases it at once:
372
+
373
+ - The caller calling `.return()` on the response sends CLOSE and returns the
374
+ caller's input iterator, so the input's `finally` runs. The return is not
375
+ awaited: `.return()` on a generator parked in its own `next()` is queued
376
+ behind that `next()`, and awaiting it would hang teardown.
377
+ - When a handler's output finishes before it has read all of its input, the
378
+ mux sends CLOSE for the request body. A handler must therefore consume
379
+ `input` within its generator's lifetime; reading it from a detached task
380
+ looks the same as not reading it.
381
+ - When the channel's `recv` ends or `closed` resolves, every live stream fails
382
+ with `TransportClosedError` (message `transport closed`). `call` after
383
+ `close()` returns a generator that throws the same error. `call` past
384
+ `maxStreams` returns a generator that throws
385
+ `RangeError: emulateMux: maxStreams=<n> exceeded`.
386
+ - `emulateMux` throws `RangeError: emulateMux: maxStreamBuffer must be at least 1, got <n>`
387
+ at construction, because a zero window authorises nothing and would stall the
388
+ first call forever.
389
+
390
+ ### `newAsyncGenerator` is a backpressure queue
391
+
392
+ A singly linked queue of slots, each holding a value or a terminal
393
+ `{ done: true, error? }`. `next(value)` and `done(error?)` return a
394
+ `Promise<boolean>` that resolves `true` once the consumer has dequeued the slot,
395
+ so a producer applies backpressure by awaiting. When the consumer exits early,
396
+ the cleanup function returned by `init` runs and every pending slot resolves
397
+ `false`, telling the producer its value was not consumed.
398
+
399
+ `skipValues: true` keeps only the newest value: pushing a value drops any
400
+ unconsumed older ones, whose promises resolve `false`. Use it where only the
401
+ latest state matters (live previews, resize events). A producer cannot tell
402
+ "skipped" from "consumer left"; both mean "not consumed".
365
403
 
366
404
  ### Chunk protocol
367
405
 
368
- One object per message:
369
-
370
406
  ```
371
- { done: false, value: T } — a value
372
- { done: true, error?: E } — termination (error if present rethrows)
407
+ { done: false, value: T } a value
408
+ { done: true, error?: E } termination; an error is rethrown by the receiver
373
409
  ```
374
410
 
375
- `sendIterator` guarantees exactly one `done` chunk and never throws
376
- itself — errors from the source iterator end up in the trailing chunk's
377
- `error` field. `recieveIterator` rethrows them into the `for await`
378
- loop on the other side.
379
-
380
- ### `readable-streams`
381
-
382
- `toReadableStream` uses the default (non-byte) ReadableStream type to
383
- sidestep the strict `ArrayBuffer`-not-`SharedArrayBuffer` typing the
384
- byte-controller requires in recent TS libs. Both functions are
385
- strict one-way converters: no queuing strategy tricks, no transform.
386
-
387
- ### Design notes
388
-
389
- - **Zero runtime dependencies.** Only platform builtins
390
- (`Promise`, `ReadableStream`, `TextEncoder`/`Decoder` if needed,
391
- `setTimeout` via `newAsyncGenerator` consumers).
392
- - **British/American spelling kept.** `recieveIterator` uses the
393
- historical misspelling to stay wire-compatible with `webrun-ports`
394
- consumers.
395
- - **No tight coupling to any transport.** `ByteChannel` is the only
396
- transport-facing type, and it is an interface — nothing here mentions
397
- `MessagePort`, `WebSocket`, `fetch`, `Worker`, etc. Those belong to the
398
- `webrun-streams-*` adapters, each of which supplies a `ByteChannel` and lets
399
- `emulateMux` do the rest.
400
-
401
- ### Constraints
402
-
403
- - `toReadableStream` / `fromReadableStream` assume `Uint8Array` chunks —
404
- the usual shape for HTTP bodies. Generic byte-agnostic use isn't
405
- supported.
406
- - `newAsyncGenerator`'s backpressure Promise resolves with `false` both
407
- on early break and on skip; consumers can't distinguish the two.
408
- That's intentional — both mean "wasn't consumed".
411
+ `sendIterator` always sends exactly one `done` chunk. An error thrown by the
412
+ source iterable is caught and travels in that chunk's `error` field;
413
+ `recieveIterator` rethrows it into the consumer's `for await`. An error thrown
414
+ by `send` itself propagates out of `sendIterator`. `recieveIterator` treats a
415
+ falsy `error` as no error, so a source that throws `0`, `""` or `null` ends the
416
+ stream normally on the receiving side.
417
+
418
+ The exported name `recieveIterator` is spelled that way, and dependents import
419
+ it under that name.
420
+
421
+ ### Why the `ReadableStream` adapters carry cancellation
422
+
423
+ A response body can leave a handler as a `ReadableStream`, cross a transport
424
+ as an iterator, and become a `ReadableStream` again at the caller. When the
425
+ caller walks away, the only way back to the producer runs through these two
426
+ functions. So:
427
+
428
+ - `toReadableStream` pulls one chunk per `pull`, which keeps the stream's own
429
+ backpressure and leaves a point between chunks where cancellation can act.
430
+ `cancel(reason)` calls the iterator's `return(reason)` without awaiting it,
431
+ for the queued-behind-`next()` reason above. It uses a default (non-byte)
432
+ stream.
433
+ - `fromReadableStream` cancels the source when the consumer stops early
434
+ (`break`, `.return()`, an error) and only releases the lock when the stream
435
+ ended on its own.
436
+
437
+ Both assume `Uint8Array` chunks.
438
+
439
+ ### Other edge cases
440
+
441
+ - `serializeError` copies own enumerable properties; `deserializeError` returns
442
+ a plain `Error` with those fields assigned, not an instance of the original
443
+ subclass. `name` is restored only if it was an own property.
444
+ - `toChunks(size)` throws `RangeError: toChunks: size must be a positive integer, got <size>`.
445
+ Empty input chunks are skipped; oversized ones are split with zero-copy
446
+ `subarray` views.
447
+ - `normalizeToUint8Array` throws `TypeError: normalizeToUint8Array: unsupported input <type>; expected Uint8Array, ArrayBuffer, ArrayBufferView, Blob, or string`.
448
+ - `newCreditLedger().reserve(upTo)` rejects with
449
+ `RangeError: newCreditLedger: reserve(<upTo>) — upTo must be at least 1`;
450
+ after `fail(err)`, every pending and future `reserve` rejects with `err`.
451
+
452
+ ### Who uses `emulateMux`
453
+
454
+ The message-oriented adapters supply a `ByteChannel` and let `emulateMux` do
455
+ the rest: `@statewalker/webrun-streams-ws`, `@statewalker/webrun-streams-peerjs`,
456
+ `@statewalker/webrun-streams-livekit`, `@statewalker/webrun-streams-signaling`
457
+ and `@statewalker/webrun-http-streams`. Transports with native multiplexing
458
+ (`@statewalker/webrun-streams-webrtc`, `@statewalker/webrun-streams-libp2p`) do
459
+ not use it. Nothing in this package names a concrete transport; `ByteChannel`
460
+ is the only transport-facing type.
409
461
 
410
462
  ### Dependencies
411
463
 
412
- **Zero runtime dependencies.**
413
-
414
- Dev: TypeScript, vitest, rolldown, rimraf, `@types/node`
415
- (catalog versions from the monorepo root).
416
-
417
- ## Scripts
418
-
419
- ```sh
420
- pnpm test # vitest run
421
- pnpm run build # rolldown + tsc --emitDeclarationOnly (ships src + dist)
422
- pnpm lint # biome check
423
- ```
464
+ Zero runtime dependencies and zero peer dependencies. Only platform globals are
465
+ used.
424
466
 
425
467
  ## License
426
468
 
427
- MIT © statewalker — see [LICENSE](../../LICENSE).
469
+ MIT
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-streams",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Async-iterator / ReadableStream primitives: collect, text/jsonl codecs, lines, backpressure generators, serialisable errors.",
7
- "homepage": "https://github.com/statewalker/webrun-wire",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-wire/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-streams#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,7 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git@github.com:statewalker/webrun-wire.git",
18
+ "url": "git+https://github.com/statewalker/webrun-wire.git",
16
19
  "directory": "packages/webrun-streams"
17
20
  },
18
21
  "exports": {