@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/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-streams",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Async-iterator / ReadableStream primitives: collect, text/jsonl codecs, lines, backpressure generators, serialisable errors.",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-wire",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-wire.git",
|
|
16
|
+
"directory": "packages/webrun-streams"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^25.6.0",
|
|
27
|
+
"rimraf": "^6.1.3",
|
|
28
|
+
"tsdown": "^0.21.9",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.4"
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"dev": "tsdown --watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"clean": "rimraf dist",
|
|
43
|
+
"lint": "biome check --write .",
|
|
44
|
+
"format": "biome format --write ."
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/collect.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Collect all items from an async iterable into an array. */
|
|
2
|
+
export async function collect<T>(input: AsyncIterable<T>): Promise<T[]> {
|
|
3
|
+
const items: T[] = [];
|
|
4
|
+
for await (const item of input) items.push(item);
|
|
5
|
+
return items;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Concatenate all Uint8Array chunks into a single Uint8Array. */
|
|
9
|
+
export async function collectBytes(input: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
10
|
+
const chunks: Uint8Array[] = [];
|
|
11
|
+
let total = 0;
|
|
12
|
+
for await (const chunk of input) {
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
total += chunk.length;
|
|
15
|
+
}
|
|
16
|
+
if (chunks.length === 1) return chunks[0] ?? new Uint8Array(0);
|
|
17
|
+
const result = new Uint8Array(total);
|
|
18
|
+
let offset = 0;
|
|
19
|
+
for (const chunk of chunks) {
|
|
20
|
+
result.set(chunk, offset);
|
|
21
|
+
offset += chunk.length;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Concatenate all string chunks into a single string. */
|
|
27
|
+
export async function collectString(input: AsyncIterable<string>): Promise<string> {
|
|
28
|
+
let result = "";
|
|
29
|
+
for await (const chunk of input) result += chunk;
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { deserializeError, serializeError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical seam for the webrun-streams transport family. A `Duplex` carries
|
|
5
|
+
* one logical call: caller emits an iterable of bytes as input, peer yields an
|
|
6
|
+
* async generator of bytes as output. Same shape on both sides — an in-process
|
|
7
|
+
* test can wire `const caller = handler` and run without any transport.
|
|
8
|
+
*
|
|
9
|
+
* Iterator semantics carry every signal:
|
|
10
|
+
* - Consumer `.return()` on the output → producer's `finally` runs.
|
|
11
|
+
* - Producer `throw` → consumer's `for await` throws.
|
|
12
|
+
* - Normal exhaustion on either side → matching end on the other side.
|
|
13
|
+
*/
|
|
14
|
+
export type Duplex = (
|
|
15
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
16
|
+
) => AsyncGenerator<Uint8Array>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adapter-side factory that stands up a transport connection and yields a
|
|
20
|
+
* caller `Duplex`. One `Connect` invocation owns one transport; each call
|
|
21
|
+
* to the resolved `call` opens a new sub-stream on it.
|
|
22
|
+
*/
|
|
23
|
+
export type Connect<P> = (params: P) => Promise<{
|
|
24
|
+
call: Duplex;
|
|
25
|
+
close: () => Promise<void>;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Adapter-side factory that registers a handler `Duplex` against a transport.
|
|
30
|
+
* Returns an idempotent teardown.
|
|
31
|
+
*/
|
|
32
|
+
export type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
|
|
36
|
+
* bytes are surfaced as an async iterable; outbound is imperative `send`. All
|
|
37
|
+
* five message-oriented transports (WebSocket, LiveKit data channel, PeerJS
|
|
38
|
+
* DataConnection, MessagePort, in-process pipe) match this shape after a thin
|
|
39
|
+
* wrapper.
|
|
40
|
+
*/
|
|
41
|
+
export type ByteChannel = {
|
|
42
|
+
send(bytes: Uint8Array): void;
|
|
43
|
+
recv: AsyncIterable<Uint8Array>;
|
|
44
|
+
closed: Promise<void>;
|
|
45
|
+
close(): void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
50
|
+
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
51
|
+
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
52
|
+
*/
|
|
53
|
+
export class TransportClosedError extends Error {
|
|
54
|
+
override readonly name = "TransportClosedError";
|
|
55
|
+
constructor(message = "transport closed") {
|
|
56
|
+
super(message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TYPE_OPEN = 0x01;
|
|
61
|
+
const TYPE_DATA = 0x02;
|
|
62
|
+
const TYPE_ACK = 0x03;
|
|
63
|
+
const TYPE_END = 0x04;
|
|
64
|
+
const TYPE_ERROR = 0x05;
|
|
65
|
+
const TYPE_CLOSE = 0x06;
|
|
66
|
+
|
|
67
|
+
const DEFAULT_MAX_STREAMS = 256;
|
|
68
|
+
const DEFAULT_MTU = 64 * 1024;
|
|
69
|
+
|
|
70
|
+
const textEncoder = new TextEncoder();
|
|
71
|
+
const textDecoder = new TextDecoder();
|
|
72
|
+
|
|
73
|
+
interface Stream {
|
|
74
|
+
id: number;
|
|
75
|
+
inbound: AsyncGenerator<Uint8Array>;
|
|
76
|
+
pushIn: (chunk: Uint8Array) => Promise<boolean>;
|
|
77
|
+
doneIn: (err?: Error) => Promise<boolean>;
|
|
78
|
+
resolveAck: (() => void) | null;
|
|
79
|
+
rejectAck: ((err: Error) => void) | null;
|
|
80
|
+
closed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EmulateMuxOptions {
|
|
84
|
+
maxStreams?: number;
|
|
85
|
+
mtu?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Stream-id allocation side. Initiator uses even ids (2, 4, …); responder
|
|
88
|
+
* uses odd ids (1, 3, …). Pick one per peer so allocations don't collide.
|
|
89
|
+
*/
|
|
90
|
+
side?: "initiator" | "responder";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function emulateMux(
|
|
94
|
+
channel: ByteChannel,
|
|
95
|
+
opts: EmulateMuxOptions = {},
|
|
96
|
+
): {
|
|
97
|
+
call: Duplex;
|
|
98
|
+
serve: (handler: Duplex) => () => Promise<void>;
|
|
99
|
+
close: () => Promise<void>;
|
|
100
|
+
} {
|
|
101
|
+
const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
102
|
+
const mtu = opts.mtu ?? DEFAULT_MTU;
|
|
103
|
+
const side = opts.side ?? "initiator";
|
|
104
|
+
|
|
105
|
+
const streams = new Map<number, Stream>();
|
|
106
|
+
let nextLocalId = side === "initiator" ? 2 : 1;
|
|
107
|
+
let handler: Duplex | null = null;
|
|
108
|
+
let muxClosed = false;
|
|
109
|
+
|
|
110
|
+
const sendFrame = (id: number, type: number, payload?: Uint8Array): void => {
|
|
111
|
+
if (muxClosed) return;
|
|
112
|
+
const idEnc = encodeVarint(id);
|
|
113
|
+
const total = idEnc.length + 1 + (payload?.byteLength ?? 0);
|
|
114
|
+
const frame = new Uint8Array(total);
|
|
115
|
+
frame.set(idEnc, 0);
|
|
116
|
+
frame[idEnc.length] = type;
|
|
117
|
+
if (payload && payload.byteLength > 0) frame.set(payload, idEnc.length + 1);
|
|
118
|
+
try {
|
|
119
|
+
channel.send(frame);
|
|
120
|
+
} catch {
|
|
121
|
+
/* underlying transport closed; inbound loop will detect */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const teardownStream = (s: Stream, err?: Error): void => {
|
|
126
|
+
if (s.closed) return;
|
|
127
|
+
s.closed = true;
|
|
128
|
+
const resolve = s.resolveAck;
|
|
129
|
+
const reject = s.rejectAck;
|
|
130
|
+
s.resolveAck = null;
|
|
131
|
+
s.rejectAck = null;
|
|
132
|
+
if (reject && err) reject(err);
|
|
133
|
+
else resolve?.();
|
|
134
|
+
void s.doneIn(err);
|
|
135
|
+
streams.delete(s.id);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const failAll = (err: Error): void => {
|
|
139
|
+
if (muxClosed) return;
|
|
140
|
+
muxClosed = true;
|
|
141
|
+
for (const s of [...streams.values()]) teardownStream(s, err);
|
|
142
|
+
streams.clear();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const createStream = (id: number): Stream => {
|
|
146
|
+
const queue = makeInboundQueue();
|
|
147
|
+
const state: Stream = {
|
|
148
|
+
id,
|
|
149
|
+
inbound: queue.generator(() => {
|
|
150
|
+
if (!state.closed && !muxClosed) sendFrame(state.id, TYPE_CLOSE);
|
|
151
|
+
teardownStream(state);
|
|
152
|
+
}),
|
|
153
|
+
pushIn: queue.push,
|
|
154
|
+
doneIn: queue.done,
|
|
155
|
+
resolveAck: null,
|
|
156
|
+
rejectAck: null,
|
|
157
|
+
closed: false,
|
|
158
|
+
};
|
|
159
|
+
return state;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const pumpOutbound = async (
|
|
163
|
+
s: Stream,
|
|
164
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
165
|
+
): Promise<void> => {
|
|
166
|
+
try {
|
|
167
|
+
for await (const chunk of input) {
|
|
168
|
+
if (s.closed || muxClosed) return;
|
|
169
|
+
if (chunk.byteLength === 0) continue;
|
|
170
|
+
let off = 0;
|
|
171
|
+
while (off < chunk.byteLength) {
|
|
172
|
+
if (s.closed || muxClosed) return;
|
|
173
|
+
const end = Math.min(off + mtu, chunk.byteLength);
|
|
174
|
+
const piece = chunk.subarray(off, end);
|
|
175
|
+
sendFrame(s.id, TYPE_DATA, piece);
|
|
176
|
+
await new Promise<void>((resolve, reject) => {
|
|
177
|
+
s.resolveAck = resolve;
|
|
178
|
+
s.rejectAck = reject;
|
|
179
|
+
});
|
|
180
|
+
off = end;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!s.closed && !muxClosed) sendFrame(s.id, TYPE_END);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (!s.closed && !muxClosed) {
|
|
186
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
187
|
+
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
188
|
+
teardownStream(s, e);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const runHandler = async (s: Stream, h: Duplex): Promise<void> => {
|
|
194
|
+
let outbound: AsyncGenerator<Uint8Array>;
|
|
195
|
+
try {
|
|
196
|
+
outbound = h(s.inbound);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
199
|
+
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
200
|
+
teardownStream(s, e);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await pumpOutbound(s, outbound);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleFrame = (frame: Uint8Array): void => {
|
|
207
|
+
if (muxClosed) return;
|
|
208
|
+
if (frame.byteLength < 2) return;
|
|
209
|
+
const { value: id, offset } = decodeVarint(frame, 0);
|
|
210
|
+
const type = frame[offset];
|
|
211
|
+
const payload = frame.subarray(offset + 1);
|
|
212
|
+
|
|
213
|
+
if (type === TYPE_OPEN) {
|
|
214
|
+
if (streams.has(id)) return;
|
|
215
|
+
if (streams.size >= maxStreams) {
|
|
216
|
+
sendFrame(
|
|
217
|
+
id,
|
|
218
|
+
TYPE_ERROR,
|
|
219
|
+
encodeError(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`)),
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!handler) {
|
|
224
|
+
sendFrame(id, TYPE_ERROR, encodeError(new Error("emulateMux: no handler registered")));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const s = createStream(id);
|
|
228
|
+
streams.set(id, s);
|
|
229
|
+
void runHandler(s, handler);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const s = streams.get(id);
|
|
234
|
+
if (!s) return;
|
|
235
|
+
|
|
236
|
+
switch (type) {
|
|
237
|
+
case TYPE_DATA: {
|
|
238
|
+
const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
|
|
239
|
+
// Push fire-and-forget; ACK after the consumer drains. The inbound
|
|
240
|
+
// loop must NOT block on consumer drainage — peer holds one in-flight
|
|
241
|
+
// DATA per stream and waits for ACK, so blocking here causes a
|
|
242
|
+
// cross-direction deadlock where ACK frames can't be processed.
|
|
243
|
+
void s.pushIn(copy).then((handled) => {
|
|
244
|
+
if (handled && !s.closed && !muxClosed) sendFrame(id, TYPE_ACK);
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case TYPE_ACK: {
|
|
249
|
+
const r = s.resolveAck;
|
|
250
|
+
s.resolveAck = null;
|
|
251
|
+
s.rejectAck = null;
|
|
252
|
+
r?.();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case TYPE_END: {
|
|
256
|
+
void s.doneIn();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case TYPE_ERROR: {
|
|
260
|
+
teardownStream(s, decodeError(payload));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case TYPE_CLOSE: {
|
|
264
|
+
teardownStream(s);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Inbound consumer
|
|
273
|
+
void (async () => {
|
|
274
|
+
try {
|
|
275
|
+
for await (const frame of channel.recv) {
|
|
276
|
+
if (muxClosed) break;
|
|
277
|
+
handleFrame(frame);
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
failAll(err instanceof Error ? err : new Error(String(err)));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
failAll(new TransportClosedError());
|
|
284
|
+
})();
|
|
285
|
+
|
|
286
|
+
void channel.closed.then(() => failAll(new TransportClosedError())).catch(() => {});
|
|
287
|
+
|
|
288
|
+
const call: Duplex = (input) => {
|
|
289
|
+
if (muxClosed) return failedGenerator(new TransportClosedError());
|
|
290
|
+
if (streams.size >= maxStreams) {
|
|
291
|
+
return failedGenerator(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`));
|
|
292
|
+
}
|
|
293
|
+
const id = nextLocalId;
|
|
294
|
+
nextLocalId += 2;
|
|
295
|
+
const s = createStream(id);
|
|
296
|
+
streams.set(id, s);
|
|
297
|
+
sendFrame(id, TYPE_OPEN);
|
|
298
|
+
void pumpOutbound(s, input);
|
|
299
|
+
return s.inbound;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const serve = (h: Duplex): (() => Promise<void>) => {
|
|
303
|
+
handler = h;
|
|
304
|
+
let torn = false;
|
|
305
|
+
return async () => {
|
|
306
|
+
if (torn) return;
|
|
307
|
+
torn = true;
|
|
308
|
+
if (handler === h) handler = null;
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const close = async (): Promise<void> => {
|
|
313
|
+
if (muxClosed) return;
|
|
314
|
+
failAll(new TransportClosedError());
|
|
315
|
+
try {
|
|
316
|
+
channel.close();
|
|
317
|
+
} catch {
|
|
318
|
+
/* ignore */
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return { call, serve, close };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function failedGenerator(err: Error): AsyncGenerator<Uint8Array> {
|
|
326
|
+
return (async function* () {
|
|
327
|
+
if ((0 as number) === 0) throw err;
|
|
328
|
+
yield new Uint8Array(0);
|
|
329
|
+
})();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Push/pull queue with eager push/done handles. Unlike `newAsyncGenerator`, the
|
|
334
|
+
* push and done functions are usable *before* the consumer begins iterating —
|
|
335
|
+
* `emulateMux` needs to enqueue frames from inbound traffic regardless of when
|
|
336
|
+
* (or whether) the consumer pulls them.
|
|
337
|
+
*
|
|
338
|
+
* `onCancel` fires only when the consumer terminates the generator before
|
|
339
|
+
* `done()` was called — i.e., a unilateral cancellation. If `done()` is
|
|
340
|
+
* called first (peer END/ERROR/CLOSE), the generator ends naturally and
|
|
341
|
+
* `onCancel` does not fire.
|
|
342
|
+
*/
|
|
343
|
+
function makeInboundQueue(): {
|
|
344
|
+
generator: (onCancel: () => void) => AsyncGenerator<Uint8Array>;
|
|
345
|
+
push: (chunk: Uint8Array) => Promise<boolean>;
|
|
346
|
+
done: (err?: Error) => Promise<boolean>;
|
|
347
|
+
} {
|
|
348
|
+
type Slot =
|
|
349
|
+
| { type: "value"; value: Uint8Array; resolve: (v: boolean) => void }
|
|
350
|
+
| { type: "done"; err?: Error; resolve: (v: boolean) => void };
|
|
351
|
+
const slots: Slot[] = [];
|
|
352
|
+
let wake: (() => void) | null = null;
|
|
353
|
+
let queueClosed = false;
|
|
354
|
+
let doneCalled = false;
|
|
355
|
+
|
|
356
|
+
const push = (chunk: Uint8Array): Promise<boolean> => {
|
|
357
|
+
if (queueClosed) return Promise.resolve(false);
|
|
358
|
+
return new Promise<boolean>((resolve) => {
|
|
359
|
+
slots.push({ type: "value", value: chunk, resolve });
|
|
360
|
+
wake?.();
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const done = (err?: Error): Promise<boolean> => {
|
|
365
|
+
if (queueClosed || doneCalled) return Promise.resolve(false);
|
|
366
|
+
doneCalled = true;
|
|
367
|
+
return new Promise<boolean>((resolve) => {
|
|
368
|
+
slots.push({ type: "done", err, resolve });
|
|
369
|
+
wake?.();
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
async function* generator(onCancel: () => void): AsyncGenerator<Uint8Array> {
|
|
374
|
+
try {
|
|
375
|
+
while (true) {
|
|
376
|
+
if (slots.length === 0) {
|
|
377
|
+
await new Promise<void>((r) => {
|
|
378
|
+
wake = r;
|
|
379
|
+
});
|
|
380
|
+
wake = null;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const slot = slots.shift() as Slot;
|
|
384
|
+
if (slot.type === "done") {
|
|
385
|
+
slot.resolve(true);
|
|
386
|
+
if (slot.err) throw slot.err;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
yield slot.value;
|
|
390
|
+
slot.resolve(true);
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
queueClosed = true;
|
|
394
|
+
for (const s of slots) s.resolve(false);
|
|
395
|
+
slots.length = 0;
|
|
396
|
+
if (!doneCalled) onCancel();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { generator, push, done };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function encodeVarint(value: number): Uint8Array {
|
|
404
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
405
|
+
throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
|
|
406
|
+
}
|
|
407
|
+
const out: number[] = [];
|
|
408
|
+
let v = value;
|
|
409
|
+
while (v >= 0x80) {
|
|
410
|
+
out.push((v & 0x7f) | 0x80);
|
|
411
|
+
v >>>= 7;
|
|
412
|
+
}
|
|
413
|
+
out.push(v & 0x7f);
|
|
414
|
+
return new Uint8Array(out);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function decodeVarint(buf: Uint8Array, start: number): { value: number; offset: number } {
|
|
418
|
+
let value = 0;
|
|
419
|
+
let shift = 0;
|
|
420
|
+
let i = start;
|
|
421
|
+
while (i < buf.length) {
|
|
422
|
+
const b = buf[i++];
|
|
423
|
+
value |= (b & 0x7f) << shift;
|
|
424
|
+
if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
|
|
425
|
+
shift += 7;
|
|
426
|
+
if (shift > 28) throw new Error("decodeVarint: too long");
|
|
427
|
+
}
|
|
428
|
+
throw new Error("decodeVarint: truncated");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function encodeError(err: Error): Uint8Array {
|
|
432
|
+
return textEncoder.encode(JSON.stringify(serializeError(err)));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function decodeError(buf: Uint8Array): Error {
|
|
436
|
+
if (buf.byteLength === 0) return new Error("unknown stream error");
|
|
437
|
+
try {
|
|
438
|
+
return deserializeError(JSON.parse(textDecoder.decode(buf)));
|
|
439
|
+
} catch {
|
|
440
|
+
return new Error(textDecoder.decode(buf));
|
|
441
|
+
}
|
|
442
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SerializedError {
|
|
2
|
+
message: string;
|
|
3
|
+
stack?: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function serializeError(error: unknown): SerializedError {
|
|
8
|
+
if (error instanceof Error) {
|
|
9
|
+
const out: SerializedError = { message: error.message, stack: error.stack };
|
|
10
|
+
const bag = error as unknown as Record<string, unknown>;
|
|
11
|
+
for (const key of Object.keys(bag)) out[key] = bag[key];
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
if (typeof error === "object" && error !== null) {
|
|
15
|
+
const bag = error as Record<string, unknown>;
|
|
16
|
+
return { message: String(bag.message ?? error), ...bag };
|
|
17
|
+
}
|
|
18
|
+
return { message: String(error) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function deserializeError(error: SerializedError | string): Error {
|
|
22
|
+
const payload = typeof error === "string" ? { message: error } : error;
|
|
23
|
+
return Object.assign(new Error(payload.message), payload);
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./collect.js";
|
|
2
|
+
export * from "./emulate-mux.js";
|
|
3
|
+
export * from "./errors.js";
|
|
4
|
+
export * from "./jsonl.js";
|
|
5
|
+
export * from "./lines.js";
|
|
6
|
+
export * from "./map.js";
|
|
7
|
+
export * from "./new-async-generator.js";
|
|
8
|
+
export * from "./normalize.js";
|
|
9
|
+
export * from "./readable-streams.js";
|
|
10
|
+
export * from "./recieve-iterator.js";
|
|
11
|
+
export * from "./send-iterator.js";
|
|
12
|
+
export * from "./text.js";
|
|
13
|
+
export * from "./to-chunks.js";
|
package/src/jsonl.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { splitLines } from "./lines.js";
|
|
2
|
+
import { map } from "./map.js";
|
|
3
|
+
|
|
4
|
+
/** Encode each value as a JSON line (terminated by \n). */
|
|
5
|
+
export async function* encodeJsonl<T>(input: AsyncIterable<T>): AsyncGenerator<string> {
|
|
6
|
+
yield* map(input, (item) => `${JSON.stringify(item)}\n`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Decode each line as a JSON value. Skips empty lines. */
|
|
10
|
+
export async function* decodeJsonl<T>(input: AsyncIterable<string>): AsyncGenerator<T> {
|
|
11
|
+
for await (const line of splitLines(input)) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
if (trimmed) yield JSON.parse(trimmed) as T;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/lines.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Split a stream of string chunks into individual lines (delimited by \n). */
|
|
2
|
+
export async function* splitLines(input: AsyncIterable<string>): AsyncGenerator<string> {
|
|
3
|
+
let buffer = "";
|
|
4
|
+
for await (const chunk of input) {
|
|
5
|
+
buffer += chunk;
|
|
6
|
+
let idx = buffer.indexOf("\n");
|
|
7
|
+
while (idx !== -1) {
|
|
8
|
+
yield buffer.slice(0, idx);
|
|
9
|
+
buffer = buffer.slice(idx + 1);
|
|
10
|
+
idx = buffer.indexOf("\n");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (buffer) yield buffer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Append \n to each string in the stream. */
|
|
17
|
+
export async function* joinLines(input: AsyncIterable<string>): AsyncGenerator<string> {
|
|
18
|
+
for await (const line of input) {
|
|
19
|
+
yield `${line}\n`;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/map.ts
ADDED