@statewalker/webrun-streams 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/index.mjs +722 -0
- package/package.json +46 -0
- package/src/collect.ts +31 -0
- package/src/emulate-mux.ts +442 -0
- package/src/errors.ts +24 -0
- package/src/index.ts +13 -0
- package/src/jsonl.ts +15 -0
- package/src/lines.ts +21 -0
- package/src/map.ts +9 -0
- package/src/new-async-generator.ts +215 -0
- package/src/normalize.ts +31 -0
- package/src/readable-streams.ts +31 -0
- package/src/recieve-iterator.ts +33 -0
- package/src/send-iterator.ts +30 -0
- package/src/text.ts +18 -0
- package/src/to-chunks.ts +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 statewalker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# @statewalker/webrun-streams
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
Every higher-level package in the `webrun-*` family (and its consumers — scanners, indexers, chat pipelines) needs the same small set of building blocks:
|
|
8
|
+
|
|
9
|
+
1. **Collectors** — turn any async iterable into a concrete array / `Uint8Array` / `string` without boilerplate; zero-copy short-circuit when a single chunk is produced.
|
|
10
|
+
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.
|
|
11
|
+
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.
|
|
12
|
+
4. **WHATWG ↔ async-iterator** conversions for body bytes, so code written against `fetch` (`ReadableStream<Uint8Array>`) can interoperate with `for await` code and back.
|
|
13
|
+
5. **Error (de)serialisation** for passing exceptions across structured-clone / JSON boundaries without losing stacks or extra fields.
|
|
14
|
+
6. **Line / JSONL / text codecs** so stream-processing code doesn't re-invent split/join/encode/decode in every consumer.
|
|
15
|
+
|
|
16
|
+
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 `@ygoe/msgpack`.
|
|
17
|
+
|
|
18
|
+
## How to use
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install @statewalker/webrun-streams
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Export | Purpose |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `collect(it)` | Drain `AsyncIterable<T>` into `T[]`. |
|
|
27
|
+
| `collectBytes(it)` | Concatenate `AsyncIterable<Uint8Array>` into one `Uint8Array` (zero-copy when a single chunk). |
|
|
28
|
+
| `collectString(it)` | Concatenate `AsyncIterable<string>` into one `string`. |
|
|
29
|
+
| `encodeText(it)` / `decodeText(it)` | UTF-8 `AsyncIterable<string>` ↔ `AsyncIterable<Uint8Array>`. |
|
|
30
|
+
| `splitLines(it)` / `joinLines(it)` | Line splitting over `string` streams (handles cross-chunk lines) and reverse. |
|
|
31
|
+
| `encodeJsonl(it)` / `decodeJsonl(it)` | JSON values ↔ `\n`-delimited JSON string stream. |
|
|
32
|
+
| `map(it, fn)` | Stream-map an `AsyncIterable<T>` through `fn: T => U \| Promise<U>`. |
|
|
33
|
+
| `newAsyncGenerator(init, skipValues?)` | Bridge imperative `next/done` callbacks into an `AsyncGenerator<T>`; returns `Promise<boolean>` for backpressure. |
|
|
34
|
+
| `sendIterator(send, iterable)` | Drain an (async) iterable into `send({done, value, error})` chunk calls; completes with one trailing `{done: true}` chunk. |
|
|
35
|
+
| `recieveIterator(installer)` | Inverse of `sendIterator`: wire an installer's chunk callback into a new `AsyncGenerator<T>`. |
|
|
36
|
+
| `toReadableStream(it)` | Wrap an `AsyncIterator<Uint8Array>` in a `ReadableStream<Uint8Array>`. |
|
|
37
|
+
| `fromReadableStream(stream)` | Iterate a `ReadableStream<Uint8Array>` as `AsyncGenerator<Uint8Array>`. |
|
|
38
|
+
| `serializeError(error)` | Turn an `Error` (or anything) into a plain `{message, stack, …}` object preserving subclass fields. |
|
|
39
|
+
| `deserializeError(obj \| string)` | Reconstruct an `Error` from a serialised form, restoring extra fields. |
|
|
40
|
+
|
|
41
|
+
## Examples
|
|
42
|
+
|
|
43
|
+
### Collectors
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { collect, collectBytes, collectString } from "@statewalker/webrun-streams";
|
|
47
|
+
|
|
48
|
+
async function* numbers() { yield 1; yield 2; yield 3; }
|
|
49
|
+
await collect(numbers()); // [1, 2, 3]
|
|
50
|
+
|
|
51
|
+
async function* bytes() {
|
|
52
|
+
yield new Uint8Array([1, 2]);
|
|
53
|
+
yield new Uint8Array([3]);
|
|
54
|
+
}
|
|
55
|
+
await collectBytes(bytes()); // Uint8Array(3) [1, 2, 3]
|
|
56
|
+
|
|
57
|
+
async function* strings() { yield "a"; yield "bc"; }
|
|
58
|
+
await collectString(strings()); // "abc"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Text / JSONL / lines codecs
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import {
|
|
65
|
+
decodeJsonl,
|
|
66
|
+
decodeText,
|
|
67
|
+
encodeJsonl,
|
|
68
|
+
encodeText,
|
|
69
|
+
joinLines,
|
|
70
|
+
splitLines,
|
|
71
|
+
} from "@statewalker/webrun-streams";
|
|
72
|
+
|
|
73
|
+
async function* chunks() {
|
|
74
|
+
yield new Uint8Array([0x7b, 0x22, 0x61]); // partial
|
|
75
|
+
yield new Uint8Array([0x22, 0x3a, 0x31, 0x7d, 0x0a]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const values = decodeJsonl<{ a: number }>(splitLines(decodeText(chunks())));
|
|
79
|
+
for await (const v of values) console.log(v); // { a: 1 }
|
|
80
|
+
|
|
81
|
+
// inverse
|
|
82
|
+
const jsonl = encodeText(joinLines(encodeJsonl([{ a: 1 }, { a: 2 }])));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Callback → AsyncGenerator bridge
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { newAsyncGenerator } from "@statewalker/webrun-streams";
|
|
89
|
+
|
|
90
|
+
function tickEverySecond(): AsyncGenerator<number> {
|
|
91
|
+
return newAsyncGenerator<number>((next, done) => {
|
|
92
|
+
let n = 0;
|
|
93
|
+
const id = setInterval(() => {
|
|
94
|
+
if (n < 5) void next(n++);
|
|
95
|
+
else {
|
|
96
|
+
void done();
|
|
97
|
+
clearInterval(id);
|
|
98
|
+
}
|
|
99
|
+
}, 1000);
|
|
100
|
+
return () => clearInterval(id); // cleanup if consumer breaks early
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for await (const n of tickEverySecond()) console.log(n); // 0 … 4
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Iterator chunk protocol
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { sendIterator, recieveIterator } from "@statewalker/webrun-streams";
|
|
111
|
+
|
|
112
|
+
// Drain an iterable across any transport.
|
|
113
|
+
async function transport<T>(chunk: { done: boolean; value?: T; error?: unknown }) {
|
|
114
|
+
// …send `chunk` over your channel.
|
|
115
|
+
}
|
|
116
|
+
await sendIterator(transport, [1, 2, 3]);
|
|
117
|
+
|
|
118
|
+
// On the other side, rebuild the original iterator.
|
|
119
|
+
const iter = recieveIterator<number>((deliver) => {
|
|
120
|
+
myChannel.onMessage = (chunk) => deliver(chunk);
|
|
121
|
+
});
|
|
122
|
+
for await (const v of iter) console.log(v); // 1, 2, 3
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### WHATWG streams ↔ async iterators
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
|
|
129
|
+
|
|
130
|
+
async function* encoded() {
|
|
131
|
+
const e = new TextEncoder();
|
|
132
|
+
yield e.encode("hello ");
|
|
133
|
+
yield e.encode("world");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Give an iterable a ReadableStream face for fetch / Response.
|
|
137
|
+
const response = new Response(toReadableStream(encoded()));
|
|
138
|
+
|
|
139
|
+
// …and the other way around.
|
|
140
|
+
const reqBody = new Request("/x", { method: "POST", body: response.body }).body!;
|
|
141
|
+
for await (const chunk of fromReadableStream(reqBody)) {
|
|
142
|
+
// chunk: Uint8Array
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Error roundtrip
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { serializeError, deserializeError } from "@statewalker/webrun-streams";
|
|
150
|
+
|
|
151
|
+
class NotFoundError extends Error {
|
|
152
|
+
status = 404;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const wire = serializeError(new NotFoundError("missing"));
|
|
156
|
+
// { message: "missing", stack: "…", status: 404 }
|
|
157
|
+
|
|
158
|
+
const restored = deserializeError(wire) as Error & { status?: number };
|
|
159
|
+
console.log(restored instanceof Error); // true
|
|
160
|
+
console.log(restored.status); // 404
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Internals
|
|
164
|
+
|
|
165
|
+
### `newAsyncGenerator` — backpressure queue
|
|
166
|
+
|
|
167
|
+
A singly-linked queue of slots; each slot carries either a value or a
|
|
168
|
+
terminal `{done: true, error?}`. Producers call `next(value)` or
|
|
169
|
+
`done(error?)`, both returning `Promise<boolean>` that resolves once the
|
|
170
|
+
consumer has dequeued the slot — so producers can apply backpressure by
|
|
171
|
+
`await`ing.
|
|
172
|
+
|
|
173
|
+
If the consumer breaks out of the `for await` early, the finally block
|
|
174
|
+
drains remaining slots and resolves each pending `next/done` promise
|
|
175
|
+
with `false`, letting the producer observe that its value wasn't
|
|
176
|
+
consumed and stop. Cleanup function (if the `init` returned one) runs
|
|
177
|
+
on the same exit path.
|
|
178
|
+
|
|
179
|
+
`skipValues: true` switches the queue into latest-only mode: pushing a
|
|
180
|
+
new value drops any unconsumed older ones. Useful for "show the most
|
|
181
|
+
recent state" scenarios (live previews, resizing, etc.) where missing
|
|
182
|
+
values is fine but lagging isn't.
|
|
183
|
+
|
|
184
|
+
### Chunk protocol
|
|
185
|
+
|
|
186
|
+
One object per message:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
{ done: false, value: T } — a value
|
|
190
|
+
{ done: true, error?: E } — termination (error if present rethrows)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`sendIterator` guarantees exactly one `done` chunk and never throws
|
|
194
|
+
itself — errors from the source iterator end up in the trailing chunk's
|
|
195
|
+
`error` field. `recieveIterator` rethrows them into the `for await`
|
|
196
|
+
loop on the other side.
|
|
197
|
+
|
|
198
|
+
### `readable-streams`
|
|
199
|
+
|
|
200
|
+
`toReadableStream` uses the default (non-byte) ReadableStream type to
|
|
201
|
+
sidestep the strict `ArrayBuffer`-not-`SharedArrayBuffer` typing the
|
|
202
|
+
byte-controller requires in recent TS libs. Both functions are
|
|
203
|
+
strict one-way converters: no queuing strategy tricks, no transform.
|
|
204
|
+
|
|
205
|
+
### Design notes
|
|
206
|
+
|
|
207
|
+
- **Zero runtime dependencies.** Only platform builtins
|
|
208
|
+
(`Promise`, `ReadableStream`, `TextEncoder`/`Decoder` if needed,
|
|
209
|
+
`setTimeout` via `newAsyncGenerator` consumers).
|
|
210
|
+
- **British/American spelling kept.** `recieveIterator` uses the
|
|
211
|
+
historical misspelling to stay wire-compatible with `webrun-ports`
|
|
212
|
+
consumers.
|
|
213
|
+
- **No tight coupling to any transport.** Nothing here mentions
|
|
214
|
+
`MessagePort`, `fetch`, `Worker`, etc. Those belong to the consuming
|
|
215
|
+
packages.
|
|
216
|
+
|
|
217
|
+
### Constraints
|
|
218
|
+
|
|
219
|
+
- `toReadableStream` / `fromReadableStream` assume `Uint8Array` chunks —
|
|
220
|
+
the usual shape for HTTP bodies. Generic byte-agnostic use isn't
|
|
221
|
+
supported.
|
|
222
|
+
- `newAsyncGenerator`'s backpressure Promise resolves with `false` both
|
|
223
|
+
on early break and on skip; consumers can't distinguish the two.
|
|
224
|
+
That's intentional — both mean "wasn't consumed".
|
|
225
|
+
|
|
226
|
+
### Dependencies
|
|
227
|
+
|
|
228
|
+
**Zero runtime dependencies.**
|
|
229
|
+
|
|
230
|
+
Dev: TypeScript, vitest, tsdown, rimraf, `@types/node`
|
|
231
|
+
(catalog versions from the monorepo root).
|
|
232
|
+
|
|
233
|
+
## Scripts
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
pnpm test # vitest run
|
|
237
|
+
pnpm run build # tsdown (publishes src + compiled dist)
|
|
238
|
+
pnpm lint # biome check
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT © statewalker
|