@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The newAsyncGenerator function creates async generators from callback-based initialization
|
|
3
|
+
* functions, providing a bridge between imperative event handling and declarative async
|
|
4
|
+
* iteration patterns.
|
|
5
|
+
*
|
|
6
|
+
* The initialization function receives two callback functions that control the generator's
|
|
7
|
+
* behavior. The `next` function yields values to consumers, while the `done` function
|
|
8
|
+
* signals completion or error conditions. The initialization function can return a cleanup
|
|
9
|
+
* function that will be called when the generator is closed or an error occurs.
|
|
10
|
+
*
|
|
11
|
+
* The generator manages an internal queue of values and completion signals, ensuring that
|
|
12
|
+
* producers can yield values without overwhelming consumers. Proper backpressure is implemented
|
|
13
|
+
* by having the `next` and `done` functions return promises that resolve only after the
|
|
14
|
+
* consumer has processed the values. This prevents memory leaks and ensures that producers
|
|
15
|
+
* are aware of whether their values were successfully handled.
|
|
16
|
+
* The generator also handles cleanup by draining any remaining items in the queue and notifying
|
|
17
|
+
* producers that their values were not processed if the generator is closed early. This ensures
|
|
18
|
+
* that resources are properly managed and that no memory leaks occur.
|
|
19
|
+
*
|
|
20
|
+
* Example usage:
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const asyncGen = newAsyncGenerator<number>((next, done) => {
|
|
23
|
+
* let count = 0;
|
|
24
|
+
* const interval = setInterval(() => {
|
|
25
|
+
* if (count < 5) {
|
|
26
|
+
* next(count++);
|
|
27
|
+
* } else {
|
|
28
|
+
* done();
|
|
29
|
+
* clearInterval(interval);
|
|
30
|
+
* }
|
|
31
|
+
* }, 1000);
|
|
32
|
+
* return () => clearInterval(interval); // Cleanup function
|
|
33
|
+
* });
|
|
34
|
+
* (async () => {
|
|
35
|
+
* for await (const num of asyncGen) {
|
|
36
|
+
* console.log(num); // Logs numbers 0 to 4 at 1 second intervals
|
|
37
|
+
* }
|
|
38
|
+
* console.log("Completed");
|
|
39
|
+
* })();
|
|
40
|
+
* ```
|
|
41
|
+
* @template T The type of values yielded by the generator
|
|
42
|
+
* @template E The type of errors that can be thrown; defaults to Error
|
|
43
|
+
* @param init Initialization function that sets up the generator behavior with next/done callbacks;
|
|
44
|
+
* The first parameter - `next` function - is used to yield values to consumers;
|
|
45
|
+
* It returns a Promise<boolean> indicating whether the value was successfully handled;
|
|
46
|
+
* The second parameter - `done` function - is used to signal completion or error;
|
|
47
|
+
* It returns a Promise<boolean> indicating whether the completion was successfully handled;
|
|
48
|
+
* The initialization function can optionally return a cleanup function that will be called
|
|
49
|
+
* when the generator is closed or an error occurs;
|
|
50
|
+
* @param skipValues If true, only the most recent value is kept in the queue,
|
|
51
|
+
* skipping intermediate values (not consumed values are considered skipped);
|
|
52
|
+
* This is useful for scenarios where only the latest value matters, such as UI updates.
|
|
53
|
+
* Defaults to false, meaning all values are queued and processed in order.
|
|
54
|
+
* @returns AsyncGenerator that properly manages backpressure and resource cleanup
|
|
55
|
+
*/
|
|
56
|
+
export async function* newAsyncGenerator<T, E = Error>(
|
|
57
|
+
/**
|
|
58
|
+
* Initialization function that sets up the async generator behavior.
|
|
59
|
+
* @param next - Function to yield values to consumers. Returns a Promise<boolean>
|
|
60
|
+
* indicating whether the value was successfully handled.
|
|
61
|
+
* @param done - Function to signal completion or error. Optional error parameter
|
|
62
|
+
* will cause the generator to throw that error.
|
|
63
|
+
* @returns Optional cleanup function that will be called when the generator terminates.
|
|
64
|
+
*/
|
|
65
|
+
init: (
|
|
66
|
+
next: (value: T) => Promise<boolean>,
|
|
67
|
+
done: (err?: E) => Promise<boolean>,
|
|
68
|
+
) => void | (() => void | Promise<void>),
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Skipping queue implementation that maintains only the most recent value.
|
|
72
|
+
*/
|
|
73
|
+
skipValues = false,
|
|
74
|
+
): AsyncGenerator<T> {
|
|
75
|
+
/**
|
|
76
|
+
* Internal queue slot type that wraps values and completion signals with resolution callbacks.
|
|
77
|
+
* This enables the async generator to communicate back to producers whether their values
|
|
78
|
+
* were successfully processed, enabling backpressure management.
|
|
79
|
+
*/
|
|
80
|
+
type IterationSlot<T, E> =
|
|
81
|
+
| { done: false; value: T } // Regular value slot
|
|
82
|
+
| { done: true; error?: E }; // Completion/error slot
|
|
83
|
+
type QueueSlot<T, E> = IterationSlot<T, E> & {
|
|
84
|
+
next: QueueSlot<T, E> | undefined; // Pointer to the next slot in the queue
|
|
85
|
+
resolve: (handled: boolean) => void; // Callback to signal if the value was handled
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let head: QueueSlot<T, E> | undefined; // Head of the queue
|
|
89
|
+
let tail: QueueSlot<T, E> | undefined; // Tail of the queue
|
|
90
|
+
|
|
91
|
+
/** Flag to prevent new values from being queued after generator closes */
|
|
92
|
+
let closed = false;
|
|
93
|
+
|
|
94
|
+
/** Wake-up function to notify the generator loop of new items */
|
|
95
|
+
let wakeUp: undefined | (() => void);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Drains the internal queue, notifying all pending producers that their values
|
|
99
|
+
* were not processed due to generator closure. This prevents memory leaks and
|
|
100
|
+
* ensures proper backpressure signaling.
|
|
101
|
+
*/
|
|
102
|
+
const drainQueue = () => {
|
|
103
|
+
for (; head; head = head.next) {
|
|
104
|
+
closed = closed || head.done;
|
|
105
|
+
head.resolve(false); // Notify producer that value was not handled
|
|
106
|
+
}
|
|
107
|
+
tail = undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enqueues a value or completion signal with a promise that resolves when the item
|
|
112
|
+
* is processed. This enables backpressure by allowing producers to know when their
|
|
113
|
+
* values have been consumed.
|
|
114
|
+
*/
|
|
115
|
+
const enqueue = (params: IterationSlot<T, E>): Promise<boolean> => {
|
|
116
|
+
if (skipValues) {
|
|
117
|
+
// Remove any previous value slots
|
|
118
|
+
drainQueue();
|
|
119
|
+
}
|
|
120
|
+
return !closed
|
|
121
|
+
? new Promise<boolean>((resolve) => {
|
|
122
|
+
// Add the new slot to the queue
|
|
123
|
+
const next = { ...params, next: undefined, resolve };
|
|
124
|
+
if (tail) {
|
|
125
|
+
tail.next = next;
|
|
126
|
+
}
|
|
127
|
+
tail = next;
|
|
128
|
+
if (!head) {
|
|
129
|
+
head = tail;
|
|
130
|
+
}
|
|
131
|
+
// Notify the generator loop that a new item is available
|
|
132
|
+
wakeUp?.();
|
|
133
|
+
})
|
|
134
|
+
: // If the generator is closed, immediately resolve as not handled
|
|
135
|
+
Promise.resolve(false);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Producer function to yield a value to consumers. Returns a promise that resolves
|
|
140
|
+
* to true if the value was successfully processed, false if the generator is closed
|
|
141
|
+
* or the value was skipped due to backpressure.
|
|
142
|
+
*/
|
|
143
|
+
const next = (value: T): Promise<boolean> => enqueue({ done: false, value });
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Producer function to signal completion or error. Optional error parameter will
|
|
147
|
+
* cause the generator to throw that error to consumers.
|
|
148
|
+
*/
|
|
149
|
+
const done = (error?: E): Promise<boolean> => enqueue({ done: true, error });
|
|
150
|
+
|
|
151
|
+
// Initialize the producer by calling the init function with our control functions
|
|
152
|
+
const unsubscribe = init(next, done);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Main async generator loop - processes queued items and yields values
|
|
156
|
+
while (!closed) {
|
|
157
|
+
// Try to get the next item from the queue
|
|
158
|
+
if (!head) {
|
|
159
|
+
// If no items available, wait for producers to add something
|
|
160
|
+
await new Promise<void>((resolve) => {
|
|
161
|
+
wakeUp = resolve;
|
|
162
|
+
}).then(() => {
|
|
163
|
+
wakeUp = undefined;
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Process the next item in the queue
|
|
169
|
+
const slot = head;
|
|
170
|
+
// Move head to the next item
|
|
171
|
+
head = head.next;
|
|
172
|
+
// If we removed the tail, clear it as well
|
|
173
|
+
if (tail === slot) {
|
|
174
|
+
tail = head;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
// Handle completion/error signals
|
|
178
|
+
if (slot.done) {
|
|
179
|
+
closed = true;
|
|
180
|
+
if (slot.error !== undefined) {
|
|
181
|
+
throw slot.error;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
// Yield the value to the consumer
|
|
186
|
+
yield slot.value;
|
|
187
|
+
} finally {
|
|
188
|
+
// Signal successful processing
|
|
189
|
+
slot.resolve(true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
/**
|
|
194
|
+
* Cleanup phase - ensures proper resource management and notification of any
|
|
195
|
+
* remaining producers. This runs whether the generator completes normally,
|
|
196
|
+
* encounters an error, or is closed early by the consumer.
|
|
197
|
+
*/
|
|
198
|
+
closed = true;
|
|
199
|
+
|
|
200
|
+
// Wake up any pending operations to allow them to exit
|
|
201
|
+
wakeUp?.();
|
|
202
|
+
|
|
203
|
+
// Call the cleanup function returned by the initialization function
|
|
204
|
+
if (typeof unsubscribe === "function") {
|
|
205
|
+
await unsubscribe();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Drain any remaining items in the queue and notify their producers that
|
|
210
|
+
* the values were not processed. This prevents memory leaks and ensures
|
|
211
|
+
* proper backpressure signaling.
|
|
212
|
+
*/
|
|
213
|
+
drainQueue();
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const utf8 = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
export type ByteLike = Uint8Array | ArrayBuffer | ArrayBufferView | Blob | string;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Coerce common byte-like inputs into a `Uint8Array`. Strings encode as UTF-8.
|
|
7
|
+
* Blobs return a `Promise<Uint8Array>`; every other input returns synchronously.
|
|
8
|
+
* Throws `TypeError` for anything else.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeToUint8Array(data: ByteLike): Uint8Array | Promise<Uint8Array> {
|
|
11
|
+
if (data instanceof Uint8Array) return data;
|
|
12
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
13
|
+
if (ArrayBuffer.isView(data)) {
|
|
14
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
15
|
+
}
|
|
16
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
17
|
+
return data.arrayBuffer().then((buf) => new Uint8Array(buf));
|
|
18
|
+
}
|
|
19
|
+
if (typeof data === "string") return utf8.encode(data);
|
|
20
|
+
throw new TypeError(
|
|
21
|
+
`normalizeToUint8Array: unsupported input ${describe(data)}; expected Uint8Array, ArrayBuffer, ArrayBufferView, Blob, or string`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function describe(value: unknown): string {
|
|
26
|
+
if (value === null) return "null";
|
|
27
|
+
const t = typeof value;
|
|
28
|
+
if (t !== "object") return t;
|
|
29
|
+
const ctor = (value as { constructor?: { name?: string } }).constructor;
|
|
30
|
+
return ctor?.name ?? "object";
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function toReadableStream(it: AsyncIterator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
2
|
+
return new ReadableStream<Uint8Array>({
|
|
3
|
+
async pull(controller) {
|
|
4
|
+
let handled = false;
|
|
5
|
+
try {
|
|
6
|
+
while (true) {
|
|
7
|
+
const slot = await it.next();
|
|
8
|
+
if (!slot || slot.done) break;
|
|
9
|
+
const value = (await slot.value) as Uint8Array;
|
|
10
|
+
controller.enqueue(value);
|
|
11
|
+
}
|
|
12
|
+
} catch (error) {
|
|
13
|
+
handled = true;
|
|
14
|
+
controller.error(error);
|
|
15
|
+
} finally {
|
|
16
|
+
if (!handled) controller.close();
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function* fromReadableStream(
|
|
23
|
+
stream: ReadableStream<Uint8Array>,
|
|
24
|
+
): AsyncGenerator<Uint8Array, void, unknown> {
|
|
25
|
+
const reader = stream.getReader();
|
|
26
|
+
while (true) {
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
29
|
+
if (value !== undefined) yield value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { newAsyncGenerator } from "./new-async-generator.js";
|
|
2
|
+
import type { IteratorChunk } from "./send-iterator.js";
|
|
3
|
+
|
|
4
|
+
export type ChunkReceiver<T> = (chunk?: IteratorChunk<T>) => Promise<boolean>;
|
|
5
|
+
export type ReceiverInstaller<T> = (
|
|
6
|
+
deliver: ChunkReceiver<T>,
|
|
7
|
+
) => (() => void | Promise<void>) | undefined | void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
|
|
11
|
+
* chunks (delivered to the supplied callback by `installer`) into an async
|
|
12
|
+
* generator.
|
|
13
|
+
*
|
|
14
|
+
* The `installer` is given a `deliver` function. It should call `deliver`
|
|
15
|
+
* once for each incoming chunk and may return a cleanup callback that
|
|
16
|
+
* runs when the consumer stops iterating.
|
|
17
|
+
*/
|
|
18
|
+
export function recieveIterator<T>(installer: ReceiverInstaller<T>): AsyncGenerator<T> {
|
|
19
|
+
return newAsyncGenerator<T>((next, done) => {
|
|
20
|
+
const cleanup = installer(async (chunk = { done: true }) => {
|
|
21
|
+
const { done: isDone = true, value, error } = chunk;
|
|
22
|
+
if (error) return await done(error as Error);
|
|
23
|
+
if (isDone) return await done();
|
|
24
|
+
return await next(value as T);
|
|
25
|
+
});
|
|
26
|
+
if (cleanup) {
|
|
27
|
+
return async () => {
|
|
28
|
+
await cleanup();
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface IteratorChunk<T> {
|
|
2
|
+
done: boolean;
|
|
3
|
+
value?: T;
|
|
4
|
+
error?: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ChunkSender<T> = (chunk: IteratorChunk<T>) => void | Promise<void>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drain an async iterator into a sink that consumes one chunk at a time.
|
|
11
|
+
*
|
|
12
|
+
* Each yielded value becomes `{ done: false, value }`. Completion emits
|
|
13
|
+
* `{ done: true }`. If the iterator throws, the error is caught and the
|
|
14
|
+
* final `done` chunk carries it so the peer can rethrow on its side.
|
|
15
|
+
*/
|
|
16
|
+
export async function sendIterator<T>(
|
|
17
|
+
send: ChunkSender<T>,
|
|
18
|
+
it: AsyncIterable<T> | Iterable<T>,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
let error: unknown;
|
|
21
|
+
try {
|
|
22
|
+
for await (const value of it as AsyncIterable<T>) {
|
|
23
|
+
await send({ done: false, value });
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
error = err;
|
|
27
|
+
} finally {
|
|
28
|
+
await send({ done: true, error });
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Decode Uint8Array chunks to string chunks via TextDecoder, handling split multi-byte characters. */
|
|
2
|
+
export async function* decodeText(input: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
|
|
3
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
4
|
+
for await (const chunk of input) {
|
|
5
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
6
|
+
if (text) yield text;
|
|
7
|
+
}
|
|
8
|
+
const tail = decoder.decode();
|
|
9
|
+
if (tail) yield tail;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Encode string chunks to Uint8Array chunks via TextEncoder. */
|
|
13
|
+
export async function* encodeText(input: AsyncIterable<string>): AsyncGenerator<Uint8Array> {
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
for await (const str of input) {
|
|
16
|
+
yield encoder.encode(str);
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/to-chunks.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const DEFAULT_CHUNK_SIZE = 16 * 1024;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
|
|
5
|
+
* chunks no larger than `size` bytes. Empty input chunks are skipped; small
|
|
6
|
+
* input chunks pass through unchanged (zero-copy `subarray` views are used
|
|
7
|
+
* when splitting, so no allocation per chunk).
|
|
8
|
+
*
|
|
9
|
+
* Use to respect transport MTUs before reaching `channel.send`. Reassembly on
|
|
10
|
+
* the receive side is not needed — consumers iterate a continuous byte stream.
|
|
11
|
+
*/
|
|
12
|
+
export function toChunks(
|
|
13
|
+
size: number = DEFAULT_CHUNK_SIZE,
|
|
14
|
+
): (input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) => AsyncGenerator<Uint8Array> {
|
|
15
|
+
if (!(Number.isInteger(size) && size > 0)) {
|
|
16
|
+
throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
|
|
17
|
+
}
|
|
18
|
+
return async function* (input) {
|
|
19
|
+
for await (const block of input) {
|
|
20
|
+
if (block.byteLength === 0) continue;
|
|
21
|
+
if (block.byteLength <= size) {
|
|
22
|
+
yield block;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
let offset = 0;
|
|
26
|
+
while (offset < block.byteLength) {
|
|
27
|
+
const end = Math.min(offset + size, block.byteLength);
|
|
28
|
+
yield block.subarray(offset, end);
|
|
29
|
+
offset = end;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|