@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.
- package/README.md +328 -286
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,57 +1,106 @@
|
|
|
1
1
|
# @statewalker/webrun-streams
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
pnpm add @statewalker/webrun-streams
|
|
24
42
|
```
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
(
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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]);
|
|
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
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
100
|
-
// `joinLines`
|
|
101
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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); //
|
|
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 {
|
|
183
|
+
import {
|
|
184
|
+
type ChunkReceiver,
|
|
185
|
+
type IteratorChunk,
|
|
186
|
+
collect,
|
|
187
|
+
recieveIterator,
|
|
188
|
+
sendIterator,
|
|
189
|
+
} from "@statewalker/webrun-streams";
|
|
133
190
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
myChannel.onMessage = (chunk) => deliver(chunk);
|
|
197
|
+
const iter = recieveIterator<number>((d) => {
|
|
198
|
+
deliver = d;
|
|
142
199
|
});
|
|
143
200
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
|
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
|
-
###
|
|
225
|
+
### Byte normalisation and re-chunking
|
|
177
226
|
|
|
178
227
|
```ts
|
|
179
|
-
import {
|
|
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
|
-
|
|
190
|
-
|
|
250
|
+
restored instanceof Error; // true (a plain Error, not NotFoundError)
|
|
251
|
+
restored.status; // 404
|
|
191
252
|
```
|
|
192
253
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
Everything in the `webrun-streams-*` family speaks one shape:
|
|
254
|
+
### `emulateMux`
|
|
196
255
|
|
|
197
256
|
```ts
|
|
198
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
233
|
-
|
|
262
|
+
const client = emulateMux(clientChannel, { side: "initiator" });
|
|
263
|
+
const server = emulateMux(serverChannel, { side: "responder" });
|
|
234
264
|
|
|
235
|
-
const
|
|
265
|
+
const stop = server.serve(async function* echo(input) {
|
|
266
|
+
for await (const chunk of input) yield chunk;
|
|
267
|
+
});
|
|
236
268
|
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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"` |
|
|
250
|
-
| `maxStreams` | `256` | Concurrent streams
|
|
251
|
-
| `mtu` | `65536` | Largest payload per DATA frame
|
|
252
|
-
| `maxStreamBuffer` | `8388608` | The credit this side advertises
|
|
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
|
-
### `
|
|
286
|
+
### `emulateMux` frames
|
|
348
287
|
|
|
349
|
-
A
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 }
|
|
372
|
-
{ done: true, error?: E }
|
|
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`
|
|
376
|
-
|
|
377
|
-
`
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
`
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
`
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
469
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-streams",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"
|
|
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
|
|
18
|
+
"url": "git+https://github.com/statewalker/webrun-wire.git",
|
|
16
19
|
"directory": "packages/webrun-streams"
|
|
17
20
|
},
|
|
18
21
|
"exports": {
|