@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/dist/index.mjs
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
//#region src/collect.ts
|
|
2
|
+
/** Collect all items from an async iterable into an array. */
|
|
3
|
+
async function collect(input) {
|
|
4
|
+
const items = [];
|
|
5
|
+
for await (const item of input) items.push(item);
|
|
6
|
+
return items;
|
|
7
|
+
}
|
|
8
|
+
/** Concatenate all Uint8Array chunks into a single Uint8Array. */
|
|
9
|
+
async function collectBytes(input) {
|
|
10
|
+
const chunks = [];
|
|
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
|
+
/** Concatenate all string chunks into a single string. */
|
|
26
|
+
async function collectString(input) {
|
|
27
|
+
let result = "";
|
|
28
|
+
for await (const chunk of input) result += chunk;
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/errors.ts
|
|
33
|
+
function serializeError(error) {
|
|
34
|
+
if (error instanceof Error) {
|
|
35
|
+
const out = {
|
|
36
|
+
message: error.message,
|
|
37
|
+
stack: error.stack
|
|
38
|
+
};
|
|
39
|
+
const bag = error;
|
|
40
|
+
for (const key of Object.keys(bag)) out[key] = bag[key];
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
if (typeof error === "object" && error !== null) {
|
|
44
|
+
const bag = error;
|
|
45
|
+
return {
|
|
46
|
+
message: String(bag.message ?? error),
|
|
47
|
+
...bag
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { message: String(error) };
|
|
51
|
+
}
|
|
52
|
+
function deserializeError(error) {
|
|
53
|
+
const payload = typeof error === "string" ? { message: error } : error;
|
|
54
|
+
return Object.assign(new Error(payload.message), payload);
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/emulate-mux.ts
|
|
58
|
+
/**
|
|
59
|
+
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
60
|
+
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
61
|
+
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
62
|
+
*/
|
|
63
|
+
var TransportClosedError = class extends Error {
|
|
64
|
+
name = "TransportClosedError";
|
|
65
|
+
constructor(message = "transport closed") {
|
|
66
|
+
super(message);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const TYPE_OPEN = 1;
|
|
70
|
+
const TYPE_DATA = 2;
|
|
71
|
+
const TYPE_ACK = 3;
|
|
72
|
+
const TYPE_END = 4;
|
|
73
|
+
const TYPE_ERROR = 5;
|
|
74
|
+
const TYPE_CLOSE = 6;
|
|
75
|
+
const DEFAULT_MAX_STREAMS = 256;
|
|
76
|
+
const DEFAULT_MTU = 64 * 1024;
|
|
77
|
+
const textEncoder = new TextEncoder();
|
|
78
|
+
const textDecoder = new TextDecoder();
|
|
79
|
+
function emulateMux(channel, opts = {}) {
|
|
80
|
+
const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
81
|
+
const mtu = opts.mtu ?? DEFAULT_MTU;
|
|
82
|
+
const side = opts.side ?? "initiator";
|
|
83
|
+
const streams = /* @__PURE__ */ new Map();
|
|
84
|
+
let nextLocalId = side === "initiator" ? 2 : 1;
|
|
85
|
+
let handler = null;
|
|
86
|
+
let muxClosed = false;
|
|
87
|
+
const sendFrame = (id, type, payload) => {
|
|
88
|
+
if (muxClosed) return;
|
|
89
|
+
const idEnc = encodeVarint(id);
|
|
90
|
+
const total = idEnc.length + 1 + (payload?.byteLength ?? 0);
|
|
91
|
+
const frame = new Uint8Array(total);
|
|
92
|
+
frame.set(idEnc, 0);
|
|
93
|
+
frame[idEnc.length] = type;
|
|
94
|
+
if (payload && payload.byteLength > 0) frame.set(payload, idEnc.length + 1);
|
|
95
|
+
try {
|
|
96
|
+
channel.send(frame);
|
|
97
|
+
} catch {}
|
|
98
|
+
};
|
|
99
|
+
const teardownStream = (s, err) => {
|
|
100
|
+
if (s.closed) return;
|
|
101
|
+
s.closed = true;
|
|
102
|
+
const resolve = s.resolveAck;
|
|
103
|
+
const reject = s.rejectAck;
|
|
104
|
+
s.resolveAck = null;
|
|
105
|
+
s.rejectAck = null;
|
|
106
|
+
if (reject && err) reject(err);
|
|
107
|
+
else resolve?.();
|
|
108
|
+
s.doneIn(err);
|
|
109
|
+
streams.delete(s.id);
|
|
110
|
+
};
|
|
111
|
+
const failAll = (err) => {
|
|
112
|
+
if (muxClosed) return;
|
|
113
|
+
muxClosed = true;
|
|
114
|
+
for (const s of [...streams.values()]) teardownStream(s, err);
|
|
115
|
+
streams.clear();
|
|
116
|
+
};
|
|
117
|
+
const createStream = (id) => {
|
|
118
|
+
const queue = makeInboundQueue();
|
|
119
|
+
const state = {
|
|
120
|
+
id,
|
|
121
|
+
inbound: queue.generator(() => {
|
|
122
|
+
if (!state.closed && !muxClosed) sendFrame(state.id, TYPE_CLOSE);
|
|
123
|
+
teardownStream(state);
|
|
124
|
+
}),
|
|
125
|
+
pushIn: queue.push,
|
|
126
|
+
doneIn: queue.done,
|
|
127
|
+
resolveAck: null,
|
|
128
|
+
rejectAck: null,
|
|
129
|
+
closed: false
|
|
130
|
+
};
|
|
131
|
+
return state;
|
|
132
|
+
};
|
|
133
|
+
const pumpOutbound = async (s, input) => {
|
|
134
|
+
try {
|
|
135
|
+
for await (const chunk of input) {
|
|
136
|
+
if (s.closed || muxClosed) return;
|
|
137
|
+
if (chunk.byteLength === 0) continue;
|
|
138
|
+
let off = 0;
|
|
139
|
+
while (off < chunk.byteLength) {
|
|
140
|
+
if (s.closed || muxClosed) return;
|
|
141
|
+
const end = Math.min(off + mtu, chunk.byteLength);
|
|
142
|
+
const piece = chunk.subarray(off, end);
|
|
143
|
+
sendFrame(s.id, TYPE_DATA, piece);
|
|
144
|
+
await new Promise((resolve, reject) => {
|
|
145
|
+
s.resolveAck = resolve;
|
|
146
|
+
s.rejectAck = reject;
|
|
147
|
+
});
|
|
148
|
+
off = end;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!s.closed && !muxClosed) sendFrame(s.id, TYPE_END);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (!s.closed && !muxClosed) {
|
|
154
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
155
|
+
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
156
|
+
teardownStream(s, e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const runHandler = async (s, h) => {
|
|
161
|
+
let outbound;
|
|
162
|
+
try {
|
|
163
|
+
outbound = h(s.inbound);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
166
|
+
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
167
|
+
teardownStream(s, e);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await pumpOutbound(s, outbound);
|
|
171
|
+
};
|
|
172
|
+
const handleFrame = (frame) => {
|
|
173
|
+
if (muxClosed) return;
|
|
174
|
+
if (frame.byteLength < 2) return;
|
|
175
|
+
const { value: id, offset } = decodeVarint(frame, 0);
|
|
176
|
+
const type = frame[offset];
|
|
177
|
+
const payload = frame.subarray(offset + 1);
|
|
178
|
+
if (type === TYPE_OPEN) {
|
|
179
|
+
if (streams.has(id)) return;
|
|
180
|
+
if (streams.size >= maxStreams) {
|
|
181
|
+
sendFrame(id, TYPE_ERROR, encodeError(/* @__PURE__ */ new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`)));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!handler) {
|
|
185
|
+
sendFrame(id, TYPE_ERROR, encodeError(/* @__PURE__ */ new Error("emulateMux: no handler registered")));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const s = createStream(id);
|
|
189
|
+
streams.set(id, s);
|
|
190
|
+
runHandler(s, handler);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const s = streams.get(id);
|
|
194
|
+
if (!s) return;
|
|
195
|
+
switch (type) {
|
|
196
|
+
case TYPE_DATA: {
|
|
197
|
+
const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
|
|
198
|
+
s.pushIn(copy).then((handled) => {
|
|
199
|
+
if (handled && !s.closed && !muxClosed) sendFrame(id, TYPE_ACK);
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case TYPE_ACK: {
|
|
204
|
+
const r = s.resolveAck;
|
|
205
|
+
s.resolveAck = null;
|
|
206
|
+
s.rejectAck = null;
|
|
207
|
+
r?.();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case TYPE_END:
|
|
211
|
+
s.doneIn();
|
|
212
|
+
return;
|
|
213
|
+
case TYPE_ERROR:
|
|
214
|
+
teardownStream(s, decodeError(payload));
|
|
215
|
+
return;
|
|
216
|
+
case TYPE_CLOSE:
|
|
217
|
+
teardownStream(s);
|
|
218
|
+
return;
|
|
219
|
+
default: return;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
(async () => {
|
|
223
|
+
try {
|
|
224
|
+
for await (const frame of channel.recv) {
|
|
225
|
+
if (muxClosed) break;
|
|
226
|
+
handleFrame(frame);
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
failAll(err instanceof Error ? err : new Error(String(err)));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
failAll(new TransportClosedError());
|
|
233
|
+
})();
|
|
234
|
+
channel.closed.then(() => failAll(new TransportClosedError())).catch(() => {});
|
|
235
|
+
const call = (input) => {
|
|
236
|
+
if (muxClosed) return failedGenerator(new TransportClosedError());
|
|
237
|
+
if (streams.size >= maxStreams) return failedGenerator(/* @__PURE__ */ new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`));
|
|
238
|
+
const id = nextLocalId;
|
|
239
|
+
nextLocalId += 2;
|
|
240
|
+
const s = createStream(id);
|
|
241
|
+
streams.set(id, s);
|
|
242
|
+
sendFrame(id, TYPE_OPEN);
|
|
243
|
+
pumpOutbound(s, input);
|
|
244
|
+
return s.inbound;
|
|
245
|
+
};
|
|
246
|
+
const serve = (h) => {
|
|
247
|
+
handler = h;
|
|
248
|
+
let torn = false;
|
|
249
|
+
return async () => {
|
|
250
|
+
if (torn) return;
|
|
251
|
+
torn = true;
|
|
252
|
+
if (handler === h) handler = null;
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
const close = async () => {
|
|
256
|
+
if (muxClosed) return;
|
|
257
|
+
failAll(new TransportClosedError());
|
|
258
|
+
try {
|
|
259
|
+
channel.close();
|
|
260
|
+
} catch {}
|
|
261
|
+
};
|
|
262
|
+
return {
|
|
263
|
+
call,
|
|
264
|
+
serve,
|
|
265
|
+
close
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function failedGenerator(err) {
|
|
269
|
+
return (async function* () {
|
|
270
|
+
throw err;
|
|
271
|
+
})();
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Push/pull queue with eager push/done handles. Unlike `newAsyncGenerator`, the
|
|
275
|
+
* push and done functions are usable *before* the consumer begins iterating —
|
|
276
|
+
* `emulateMux` needs to enqueue frames from inbound traffic regardless of when
|
|
277
|
+
* (or whether) the consumer pulls them.
|
|
278
|
+
*
|
|
279
|
+
* `onCancel` fires only when the consumer terminates the generator before
|
|
280
|
+
* `done()` was called — i.e., a unilateral cancellation. If `done()` is
|
|
281
|
+
* called first (peer END/ERROR/CLOSE), the generator ends naturally and
|
|
282
|
+
* `onCancel` does not fire.
|
|
283
|
+
*/
|
|
284
|
+
function makeInboundQueue() {
|
|
285
|
+
const slots = [];
|
|
286
|
+
let wake = null;
|
|
287
|
+
let queueClosed = false;
|
|
288
|
+
let doneCalled = false;
|
|
289
|
+
const push = (chunk) => {
|
|
290
|
+
if (queueClosed) return Promise.resolve(false);
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
slots.push({
|
|
293
|
+
type: "value",
|
|
294
|
+
value: chunk,
|
|
295
|
+
resolve
|
|
296
|
+
});
|
|
297
|
+
wake?.();
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
const done = (err) => {
|
|
301
|
+
if (queueClosed || doneCalled) return Promise.resolve(false);
|
|
302
|
+
doneCalled = true;
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
slots.push({
|
|
305
|
+
type: "done",
|
|
306
|
+
err,
|
|
307
|
+
resolve
|
|
308
|
+
});
|
|
309
|
+
wake?.();
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
async function* generator(onCancel) {
|
|
313
|
+
try {
|
|
314
|
+
while (true) {
|
|
315
|
+
if (slots.length === 0) {
|
|
316
|
+
await new Promise((r) => {
|
|
317
|
+
wake = r;
|
|
318
|
+
});
|
|
319
|
+
wake = null;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const slot = slots.shift();
|
|
323
|
+
if (slot.type === "done") {
|
|
324
|
+
slot.resolve(true);
|
|
325
|
+
if (slot.err) throw slot.err;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
yield slot.value;
|
|
329
|
+
slot.resolve(true);
|
|
330
|
+
}
|
|
331
|
+
} finally {
|
|
332
|
+
queueClosed = true;
|
|
333
|
+
for (const s of slots) s.resolve(false);
|
|
334
|
+
slots.length = 0;
|
|
335
|
+
if (!doneCalled) onCancel();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
generator,
|
|
340
|
+
push,
|
|
341
|
+
done
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function encodeVarint(value) {
|
|
345
|
+
if (!Number.isInteger(value) || value < 0) throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
|
|
346
|
+
const out = [];
|
|
347
|
+
let v = value;
|
|
348
|
+
while (v >= 128) {
|
|
349
|
+
out.push(v & 127 | 128);
|
|
350
|
+
v >>>= 7;
|
|
351
|
+
}
|
|
352
|
+
out.push(v & 127);
|
|
353
|
+
return new Uint8Array(out);
|
|
354
|
+
}
|
|
355
|
+
function decodeVarint(buf, start) {
|
|
356
|
+
let value = 0;
|
|
357
|
+
let shift = 0;
|
|
358
|
+
let i = start;
|
|
359
|
+
while (i < buf.length) {
|
|
360
|
+
const b = buf[i++];
|
|
361
|
+
value |= (b & 127) << shift;
|
|
362
|
+
if ((b & 128) === 0) return {
|
|
363
|
+
value: value >>> 0,
|
|
364
|
+
offset: i
|
|
365
|
+
};
|
|
366
|
+
shift += 7;
|
|
367
|
+
if (shift > 28) throw new Error("decodeVarint: too long");
|
|
368
|
+
}
|
|
369
|
+
throw new Error("decodeVarint: truncated");
|
|
370
|
+
}
|
|
371
|
+
function encodeError(err) {
|
|
372
|
+
return textEncoder.encode(JSON.stringify(serializeError(err)));
|
|
373
|
+
}
|
|
374
|
+
function decodeError(buf) {
|
|
375
|
+
if (buf.byteLength === 0) return /* @__PURE__ */ new Error("unknown stream error");
|
|
376
|
+
try {
|
|
377
|
+
return deserializeError(JSON.parse(textDecoder.decode(buf)));
|
|
378
|
+
} catch {
|
|
379
|
+
return new Error(textDecoder.decode(buf));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/lines.ts
|
|
384
|
+
/** Split a stream of string chunks into individual lines (delimited by \n). */
|
|
385
|
+
async function* splitLines(input) {
|
|
386
|
+
let buffer = "";
|
|
387
|
+
for await (const chunk of input) {
|
|
388
|
+
buffer += chunk;
|
|
389
|
+
let idx = buffer.indexOf("\n");
|
|
390
|
+
while (idx !== -1) {
|
|
391
|
+
yield buffer.slice(0, idx);
|
|
392
|
+
buffer = buffer.slice(idx + 1);
|
|
393
|
+
idx = buffer.indexOf("\n");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (buffer) yield buffer;
|
|
397
|
+
}
|
|
398
|
+
/** Append \n to each string in the stream. */
|
|
399
|
+
async function* joinLines(input) {
|
|
400
|
+
for await (const line of input) yield `${line}\n`;
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/map.ts
|
|
404
|
+
/** Apply a sync or async function to each item in a stream. */
|
|
405
|
+
async function* map(input, fn) {
|
|
406
|
+
for await (const item of input) yield await fn(item);
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/jsonl.ts
|
|
410
|
+
/** Encode each value as a JSON line (terminated by \n). */
|
|
411
|
+
async function* encodeJsonl(input) {
|
|
412
|
+
yield* map(input, (item) => `${JSON.stringify(item)}\n`);
|
|
413
|
+
}
|
|
414
|
+
/** Decode each line as a JSON value. Skips empty lines. */
|
|
415
|
+
async function* decodeJsonl(input) {
|
|
416
|
+
for await (const line of splitLines(input)) {
|
|
417
|
+
const trimmed = line.trim();
|
|
418
|
+
if (trimmed) yield JSON.parse(trimmed);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/new-async-generator.ts
|
|
423
|
+
/**
|
|
424
|
+
* The newAsyncGenerator function creates async generators from callback-based initialization
|
|
425
|
+
* functions, providing a bridge between imperative event handling and declarative async
|
|
426
|
+
* iteration patterns.
|
|
427
|
+
*
|
|
428
|
+
* The initialization function receives two callback functions that control the generator's
|
|
429
|
+
* behavior. The `next` function yields values to consumers, while the `done` function
|
|
430
|
+
* signals completion or error conditions. The initialization function can return a cleanup
|
|
431
|
+
* function that will be called when the generator is closed or an error occurs.
|
|
432
|
+
*
|
|
433
|
+
* The generator manages an internal queue of values and completion signals, ensuring that
|
|
434
|
+
* producers can yield values without overwhelming consumers. Proper backpressure is implemented
|
|
435
|
+
* by having the `next` and `done` functions return promises that resolve only after the
|
|
436
|
+
* consumer has processed the values. This prevents memory leaks and ensures that producers
|
|
437
|
+
* are aware of whether their values were successfully handled.
|
|
438
|
+
* The generator also handles cleanup by draining any remaining items in the queue and notifying
|
|
439
|
+
* producers that their values were not processed if the generator is closed early. This ensures
|
|
440
|
+
* that resources are properly managed and that no memory leaks occur.
|
|
441
|
+
*
|
|
442
|
+
* Example usage:
|
|
443
|
+
* ```typescript
|
|
444
|
+
* const asyncGen = newAsyncGenerator<number>((next, done) => {
|
|
445
|
+
* let count = 0;
|
|
446
|
+
* const interval = setInterval(() => {
|
|
447
|
+
* if (count < 5) {
|
|
448
|
+
* next(count++);
|
|
449
|
+
* } else {
|
|
450
|
+
* done();
|
|
451
|
+
* clearInterval(interval);
|
|
452
|
+
* }
|
|
453
|
+
* }, 1000);
|
|
454
|
+
* return () => clearInterval(interval); // Cleanup function
|
|
455
|
+
* });
|
|
456
|
+
* (async () => {
|
|
457
|
+
* for await (const num of asyncGen) {
|
|
458
|
+
* console.log(num); // Logs numbers 0 to 4 at 1 second intervals
|
|
459
|
+
* }
|
|
460
|
+
* console.log("Completed");
|
|
461
|
+
* })();
|
|
462
|
+
* ```
|
|
463
|
+
* @template T The type of values yielded by the generator
|
|
464
|
+
* @template E The type of errors that can be thrown; defaults to Error
|
|
465
|
+
* @param init Initialization function that sets up the generator behavior with next/done callbacks;
|
|
466
|
+
* The first parameter - `next` function - is used to yield values to consumers;
|
|
467
|
+
* It returns a Promise<boolean> indicating whether the value was successfully handled;
|
|
468
|
+
* The second parameter - `done` function - is used to signal completion or error;
|
|
469
|
+
* It returns a Promise<boolean> indicating whether the completion was successfully handled;
|
|
470
|
+
* The initialization function can optionally return a cleanup function that will be called
|
|
471
|
+
* when the generator is closed or an error occurs;
|
|
472
|
+
* @param skipValues If true, only the most recent value is kept in the queue,
|
|
473
|
+
* skipping intermediate values (not consumed values are considered skipped);
|
|
474
|
+
* This is useful for scenarios where only the latest value matters, such as UI updates.
|
|
475
|
+
* Defaults to false, meaning all values are queued and processed in order.
|
|
476
|
+
* @returns AsyncGenerator that properly manages backpressure and resource cleanup
|
|
477
|
+
*/
|
|
478
|
+
async function* newAsyncGenerator(init, skipValues = false) {
|
|
479
|
+
let head;
|
|
480
|
+
let tail;
|
|
481
|
+
/** Flag to prevent new values from being queued after generator closes */
|
|
482
|
+
let closed = false;
|
|
483
|
+
/** Wake-up function to notify the generator loop of new items */
|
|
484
|
+
let wakeUp;
|
|
485
|
+
/**
|
|
486
|
+
* Drains the internal queue, notifying all pending producers that their values
|
|
487
|
+
* were not processed due to generator closure. This prevents memory leaks and
|
|
488
|
+
* ensures proper backpressure signaling.
|
|
489
|
+
*/
|
|
490
|
+
const drainQueue = () => {
|
|
491
|
+
for (; head; head = head.next) {
|
|
492
|
+
closed = closed || head.done;
|
|
493
|
+
head.resolve(false);
|
|
494
|
+
}
|
|
495
|
+
tail = void 0;
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* Enqueues a value or completion signal with a promise that resolves when the item
|
|
499
|
+
* is processed. This enables backpressure by allowing producers to know when their
|
|
500
|
+
* values have been consumed.
|
|
501
|
+
*/
|
|
502
|
+
const enqueue = (params) => {
|
|
503
|
+
if (skipValues) drainQueue();
|
|
504
|
+
return !closed ? new Promise((resolve) => {
|
|
505
|
+
const next = {
|
|
506
|
+
...params,
|
|
507
|
+
next: void 0,
|
|
508
|
+
resolve
|
|
509
|
+
};
|
|
510
|
+
if (tail) tail.next = next;
|
|
511
|
+
tail = next;
|
|
512
|
+
if (!head) head = tail;
|
|
513
|
+
wakeUp?.();
|
|
514
|
+
}) : Promise.resolve(false);
|
|
515
|
+
};
|
|
516
|
+
/**
|
|
517
|
+
* Producer function to yield a value to consumers. Returns a promise that resolves
|
|
518
|
+
* to true if the value was successfully processed, false if the generator is closed
|
|
519
|
+
* or the value was skipped due to backpressure.
|
|
520
|
+
*/
|
|
521
|
+
const next = (value) => enqueue({
|
|
522
|
+
done: false,
|
|
523
|
+
value
|
|
524
|
+
});
|
|
525
|
+
/**
|
|
526
|
+
* Producer function to signal completion or error. Optional error parameter will
|
|
527
|
+
* cause the generator to throw that error to consumers.
|
|
528
|
+
*/
|
|
529
|
+
const done = (error) => enqueue({
|
|
530
|
+
done: true,
|
|
531
|
+
error
|
|
532
|
+
});
|
|
533
|
+
const unsubscribe = init(next, done);
|
|
534
|
+
try {
|
|
535
|
+
while (!closed) {
|
|
536
|
+
if (!head) {
|
|
537
|
+
await new Promise((resolve) => {
|
|
538
|
+
wakeUp = resolve;
|
|
539
|
+
}).then(() => {
|
|
540
|
+
wakeUp = void 0;
|
|
541
|
+
});
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const slot = head;
|
|
545
|
+
head = head.next;
|
|
546
|
+
if (tail === slot) tail = head;
|
|
547
|
+
try {
|
|
548
|
+
if (slot.done) {
|
|
549
|
+
closed = true;
|
|
550
|
+
if (slot.error !== void 0) throw slot.error;
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
yield slot.value;
|
|
554
|
+
} finally {
|
|
555
|
+
slot.resolve(true);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} finally {
|
|
559
|
+
/**
|
|
560
|
+
* Cleanup phase - ensures proper resource management and notification of any
|
|
561
|
+
* remaining producers. This runs whether the generator completes normally,
|
|
562
|
+
* encounters an error, or is closed early by the consumer.
|
|
563
|
+
*/
|
|
564
|
+
closed = true;
|
|
565
|
+
wakeUp?.();
|
|
566
|
+
if (typeof unsubscribe === "function") await unsubscribe();
|
|
567
|
+
/**
|
|
568
|
+
* Drain any remaining items in the queue and notify their producers that
|
|
569
|
+
* the values were not processed. This prevents memory leaks and ensures
|
|
570
|
+
* proper backpressure signaling.
|
|
571
|
+
*/
|
|
572
|
+
drainQueue();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/normalize.ts
|
|
577
|
+
const utf8 = new TextEncoder();
|
|
578
|
+
/**
|
|
579
|
+
* Coerce common byte-like inputs into a `Uint8Array`. Strings encode as UTF-8.
|
|
580
|
+
* Blobs return a `Promise<Uint8Array>`; every other input returns synchronously.
|
|
581
|
+
* Throws `TypeError` for anything else.
|
|
582
|
+
*/
|
|
583
|
+
function normalizeToUint8Array(data) {
|
|
584
|
+
if (data instanceof Uint8Array) return data;
|
|
585
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
586
|
+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
587
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return data.arrayBuffer().then((buf) => new Uint8Array(buf));
|
|
588
|
+
if (typeof data === "string") return utf8.encode(data);
|
|
589
|
+
throw new TypeError(`normalizeToUint8Array: unsupported input ${describe(data)}; expected Uint8Array, ArrayBuffer, ArrayBufferView, Blob, or string`);
|
|
590
|
+
}
|
|
591
|
+
function describe(value) {
|
|
592
|
+
if (value === null) return "null";
|
|
593
|
+
const t = typeof value;
|
|
594
|
+
if (t !== "object") return t;
|
|
595
|
+
return value.constructor?.name ?? "object";
|
|
596
|
+
}
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/readable-streams.ts
|
|
599
|
+
function toReadableStream(it) {
|
|
600
|
+
return new ReadableStream({ async pull(controller) {
|
|
601
|
+
let handled = false;
|
|
602
|
+
try {
|
|
603
|
+
while (true) {
|
|
604
|
+
const slot = await it.next();
|
|
605
|
+
if (!slot || slot.done) break;
|
|
606
|
+
const value = await slot.value;
|
|
607
|
+
controller.enqueue(value);
|
|
608
|
+
}
|
|
609
|
+
} catch (error) {
|
|
610
|
+
handled = true;
|
|
611
|
+
controller.error(error);
|
|
612
|
+
} finally {
|
|
613
|
+
if (!handled) controller.close();
|
|
614
|
+
}
|
|
615
|
+
} });
|
|
616
|
+
}
|
|
617
|
+
async function* fromReadableStream(stream) {
|
|
618
|
+
const reader = stream.getReader();
|
|
619
|
+
while (true) {
|
|
620
|
+
const { done, value } = await reader.read();
|
|
621
|
+
if (done) break;
|
|
622
|
+
if (value !== void 0) yield value;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/recieve-iterator.ts
|
|
627
|
+
/**
|
|
628
|
+
* Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
|
|
629
|
+
* chunks (delivered to the supplied callback by `installer`) into an async
|
|
630
|
+
* generator.
|
|
631
|
+
*
|
|
632
|
+
* The `installer` is given a `deliver` function. It should call `deliver`
|
|
633
|
+
* once for each incoming chunk and may return a cleanup callback that
|
|
634
|
+
* runs when the consumer stops iterating.
|
|
635
|
+
*/
|
|
636
|
+
function recieveIterator(installer) {
|
|
637
|
+
return newAsyncGenerator((next, done) => {
|
|
638
|
+
const cleanup = installer(async (chunk = { done: true }) => {
|
|
639
|
+
const { done: isDone = true, value, error } = chunk;
|
|
640
|
+
if (error) return await done(error);
|
|
641
|
+
if (isDone) return await done();
|
|
642
|
+
return await next(value);
|
|
643
|
+
});
|
|
644
|
+
if (cleanup) return async () => {
|
|
645
|
+
await cleanup();
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/send-iterator.ts
|
|
651
|
+
/**
|
|
652
|
+
* Drain an async iterator into a sink that consumes one chunk at a time.
|
|
653
|
+
*
|
|
654
|
+
* Each yielded value becomes `{ done: false, value }`. Completion emits
|
|
655
|
+
* `{ done: true }`. If the iterator throws, the error is caught and the
|
|
656
|
+
* final `done` chunk carries it so the peer can rethrow on its side.
|
|
657
|
+
*/
|
|
658
|
+
async function sendIterator(send, it) {
|
|
659
|
+
let error;
|
|
660
|
+
try {
|
|
661
|
+
for await (const value of it) await send({
|
|
662
|
+
done: false,
|
|
663
|
+
value
|
|
664
|
+
});
|
|
665
|
+
} catch (err) {
|
|
666
|
+
error = err;
|
|
667
|
+
} finally {
|
|
668
|
+
await send({
|
|
669
|
+
done: true,
|
|
670
|
+
error
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/text.ts
|
|
676
|
+
/** Decode Uint8Array chunks to string chunks via TextDecoder, handling split multi-byte characters. */
|
|
677
|
+
async function* decodeText(input) {
|
|
678
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
679
|
+
for await (const chunk of input) {
|
|
680
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
681
|
+
if (text) yield text;
|
|
682
|
+
}
|
|
683
|
+
const tail = decoder.decode();
|
|
684
|
+
if (tail) yield tail;
|
|
685
|
+
}
|
|
686
|
+
/** Encode string chunks to Uint8Array chunks via TextEncoder. */
|
|
687
|
+
async function* encodeText(input) {
|
|
688
|
+
const encoder = new TextEncoder();
|
|
689
|
+
for await (const str of input) yield encoder.encode(str);
|
|
690
|
+
}
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/to-chunks.ts
|
|
693
|
+
const DEFAULT_CHUNK_SIZE = 16 * 1024;
|
|
694
|
+
/**
|
|
695
|
+
* Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
|
|
696
|
+
* chunks no larger than `size` bytes. Empty input chunks are skipped; small
|
|
697
|
+
* input chunks pass through unchanged (zero-copy `subarray` views are used
|
|
698
|
+
* when splitting, so no allocation per chunk).
|
|
699
|
+
*
|
|
700
|
+
* Use to respect transport MTUs before reaching `channel.send`. Reassembly on
|
|
701
|
+
* the receive side is not needed — consumers iterate a continuous byte stream.
|
|
702
|
+
*/
|
|
703
|
+
function toChunks(size = DEFAULT_CHUNK_SIZE) {
|
|
704
|
+
if (!(Number.isInteger(size) && size > 0)) throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
|
|
705
|
+
return async function* (input) {
|
|
706
|
+
for await (const block of input) {
|
|
707
|
+
if (block.byteLength === 0) continue;
|
|
708
|
+
if (block.byteLength <= size) {
|
|
709
|
+
yield block;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
let offset = 0;
|
|
713
|
+
while (offset < block.byteLength) {
|
|
714
|
+
const end = Math.min(offset + size, block.byteLength);
|
|
715
|
+
yield block.subarray(offset, end);
|
|
716
|
+
offset = end;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
export { TransportClosedError, collect, collectBytes, collectString, decodeJsonl, decodeText, deserializeError, emulateMux, encodeJsonl, encodeText, fromReadableStream, joinLines, map, newAsyncGenerator, normalizeToUint8Array, recieveIterator, sendIterator, serializeError, splitLines, toChunks, toReadableStream };
|