streamfold 0.1.5 → 0.1.7
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 +9 -0
- package/package.json +5 -1
- package/src/index.d.ts +5 -1
- package/src/internal/abortable-source.js +82 -0
- package/src/internal/wasm-runtime.js +10 -0
- package/src/read-structured.js +34 -6
- package/src/testing.d.ts +13 -0
- package/src/testing.js +121 -0
package/README.md
CHANGED
|
@@ -34,6 +34,11 @@ for frozen snapshots that preserve earlier values and share unchanged branches.
|
|
|
34
34
|
Pools and custom adapter factories accept the same option; managed readers
|
|
35
35
|
forward it through `limits`.
|
|
36
36
|
|
|
37
|
+
Chunks must contain well-formed Unicode. Valid surrogate pairs may span chunks;
|
|
38
|
+
raw unpaired UTF-16 code units fail with `INVALID_CHUNK` instead of being silently
|
|
39
|
+
replaced during UTF-8 encoding. Use JSON Unicode escapes for unpaired code units
|
|
40
|
+
(as produced by `JSON.stringify`).
|
|
41
|
+
|
|
37
42
|
Use `streamfold/assistant-ui`, `streamfold/vercel-ai`, `streamfold/openai`,
|
|
38
43
|
`streamfold/anthropic`, `streamfold/gemini`, `streamfold/langchain`, or
|
|
39
44
|
`streamfold/ag-ui` for decoded SDK events. Integrations use structural event
|
|
@@ -50,6 +55,10 @@ same batch adapter, finalizes remaining calls at the end of the source, and
|
|
|
50
55
|
disposes on completion, failure, or early exit. `limits` is optional.
|
|
51
56
|
For a built-in SDK factory, use `{ integration: assistantUI, limits }` instead.
|
|
52
57
|
Both paths yield the same lifecycle updates without replaying completions.
|
|
58
|
+
Readable streams are consumed through `getReader()` even without an abort
|
|
59
|
+
signal or native async-iterator support.
|
|
60
|
+
Upstream failures close the iterator or reader while preserving the original
|
|
61
|
+
error, even when no abort signal is supplied.
|
|
53
62
|
|
|
54
63
|
Every built-in adapter supports `pushAll(event)`, returning all ordered
|
|
55
64
|
`start`, `update`, and `complete` updates, including multiple calls in one event.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamfold",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Protocol-neutral incremental state for structured AI streams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"!src/**/*.test.js"
|
|
13
13
|
],
|
|
14
14
|
"exports": {
|
|
15
|
+
"./testing": {
|
|
16
|
+
"types": "./src/testing.d.ts",
|
|
17
|
+
"default": "./src/testing.js"
|
|
18
|
+
},
|
|
15
19
|
".": {
|
|
16
20
|
"types": "./src/index.d.ts",
|
|
17
21
|
"default": "./src/index.js"
|
package/src/index.d.ts
CHANGED
|
@@ -175,6 +175,8 @@ export function defineAdapter<Event, Id = string>(
|
|
|
175
175
|
|
|
176
176
|
export interface ReadStructuredOptions<Event, Id = string>
|
|
177
177
|
extends StructuredStreamIntegrationOptions {
|
|
178
|
+
/** Stop waiting and release parsers; pass the same signal to the transport. */
|
|
179
|
+
readonly signal?: AbortSignal;
|
|
178
180
|
readonly adapter: StructuredStreamAdapter<Event, Id>;
|
|
179
181
|
readonly integration?: never;
|
|
180
182
|
readonly limits?: StructuredStreamPoolOptions;
|
|
@@ -182,6 +184,8 @@ export interface ReadStructuredOptions<Event, Id = string>
|
|
|
182
184
|
|
|
183
185
|
export interface ReadStructuredIntegrationOptions<Event, Id = string>
|
|
184
186
|
extends StructuredStreamIntegrationOptions {
|
|
187
|
+
/** Stop waiting and release parsers; pass the same signal to the transport. */
|
|
188
|
+
readonly signal?: AbortSignal;
|
|
185
189
|
readonly integration: BatchStructuredStreamIntegration<Event, Id>;
|
|
186
190
|
readonly adapter?: never;
|
|
187
191
|
readonly limits?: StructuredStreamPoolOptions;
|
|
@@ -189,7 +193,7 @@ export interface ReadStructuredIntegrationOptions<Event, Id = string>
|
|
|
189
193
|
|
|
190
194
|
/** Consume decoded events, finalizing at EOF and disposing on every exit. */
|
|
191
195
|
export function readStructured<Event, Id = string>(
|
|
192
|
-
events: AsyncIterable<Event> | Iterable<Event>,
|
|
196
|
+
events: AsyncIterable<Event> | Iterable<Event> | ReadableStream<Event>,
|
|
193
197
|
options:
|
|
194
198
|
| ReadStructuredOptions<Event, Id>
|
|
195
199
|
| ReadStructuredIntegrationOptions<Event, Id>,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function abortableSource(events, signal, onAbort) {
|
|
2
|
+
const reader = typeof events?.getReader === "function" ? events.getReader() : undefined;
|
|
3
|
+
const asyncFactory = reader === undefined ? events[Symbol.asyncIterator] : undefined;
|
|
4
|
+
const synchronous = reader === undefined && asyncFactory == null;
|
|
5
|
+
const iterator = reader === undefined
|
|
6
|
+
? (asyncFactory ?? events[Symbol.iterator]).call(events)
|
|
7
|
+
: undefined;
|
|
8
|
+
let closed = false;
|
|
9
|
+
// Each read removes its listener; one shared pending promise would retain
|
|
10
|
+
// a Promise.race reaction for every event until the session ends.
|
|
11
|
+
const waitFor = (promise) => signal === undefined ? Promise.resolve(promise) : new Promise((resolve, reject) => {
|
|
12
|
+
let listening = true;
|
|
13
|
+
const detach = () => {
|
|
14
|
+
if (!listening) return;
|
|
15
|
+
listening = false;
|
|
16
|
+
signal.removeEventListener("abort", stop);
|
|
17
|
+
};
|
|
18
|
+
const stop = () => { detach(); reject(signal.reason); };
|
|
19
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
20
|
+
Promise.resolve(promise).then(
|
|
21
|
+
(value) => {
|
|
22
|
+
detach();
|
|
23
|
+
if (signal.aborted) reject(signal.reason);
|
|
24
|
+
else resolve(value);
|
|
25
|
+
},
|
|
26
|
+
(error) => { detach(); reject(signal.aborted ? signal.reason : error); },
|
|
27
|
+
);
|
|
28
|
+
if (signal.aborted) stop();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const close = (reason) => {
|
|
32
|
+
if (closed) return Promise.resolve();
|
|
33
|
+
closed = true;
|
|
34
|
+
return Promise.resolve().then(async () => {
|
|
35
|
+
if (reader !== undefined) {
|
|
36
|
+
try { return reader.cancel(reason); }
|
|
37
|
+
finally { reader.releaseLock(); }
|
|
38
|
+
} else {
|
|
39
|
+
await iterator.return?.();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const abort = () => {
|
|
44
|
+
try { onAbort(); } catch { /* Cancellation preserves the signal's reason. */ }
|
|
45
|
+
close(signal.reason).catch(() => {});
|
|
46
|
+
};
|
|
47
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
48
|
+
if (signal?.aborted) abort();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
[Symbol.asyncIterator]() { return this; },
|
|
52
|
+
async next() {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
const result = await waitFor(
|
|
55
|
+
Promise.resolve().then(async () => {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
const next = reader !== undefined ? await reader.read() : await iterator.next();
|
|
58
|
+
if (next === null || typeof next !== "object") {
|
|
59
|
+
throw new TypeError("Iterator result must be an object");
|
|
60
|
+
}
|
|
61
|
+
return synchronous ? { done: next.done, value: await next.value } : next;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
if (result.done) {
|
|
66
|
+
closed = true;
|
|
67
|
+
reader?.releaseLock();
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
},
|
|
71
|
+
async return() {
|
|
72
|
+
const cleanup = close();
|
|
73
|
+
cleanup.catch(() => {});
|
|
74
|
+
if (!signal?.aborted) await waitFor(cleanup);
|
|
75
|
+
return { done: true, value: undefined };
|
|
76
|
+
},
|
|
77
|
+
async dispose() {
|
|
78
|
+
try { await this.return(); }
|
|
79
|
+
finally { signal?.removeEventListener("abort", abort); }
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -196,6 +196,16 @@ export const createWasmParser = ({ maxBytes, maxDepth }) => {
|
|
|
196
196
|
};
|
|
197
197
|
|
|
198
198
|
const pushChunk = (parser, chunk) => {
|
|
199
|
+
// In Unicode mode this range matches lone surrogate code units, not valid
|
|
200
|
+
// pairs. TextEncoder would silently replace them with U+FFFD, while the
|
|
201
|
+
// pool's final JSON.parse would preserve them, producing different values.
|
|
202
|
+
// A trailing high surrogate is buffered by pushWasmParser before this check.
|
|
203
|
+
if (/[\uD800-\uDFFF]/u.test(chunk)) {
|
|
204
|
+
throw streamError(
|
|
205
|
+
new TypeError("Unpaired UTF-16 surrogate; use a JSON Unicode escape"),
|
|
206
|
+
"INVALID_CHUNK",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
199
209
|
const { wasm, handle } = parser;
|
|
200
210
|
const capacity = chunk.length * 3;
|
|
201
211
|
const pointer = wasm.streamfold_parser_input(handle, capacity);
|
package/src/read-structured.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createStructuredStreamPool } from "./index.js";
|
|
2
2
|
import { streamError } from "./internal/errors.js";
|
|
3
|
+
import { abortableSource } from "./internal/abortable-source.js";
|
|
3
4
|
|
|
4
5
|
export async function* readStructured(
|
|
5
6
|
events,
|
|
6
|
-
{ adapter, integration, limits, onDiagnostic },
|
|
7
|
+
{ adapter, integration, limits, onDiagnostic, signal },
|
|
7
8
|
) {
|
|
8
9
|
if (
|
|
9
10
|
(adapter === undefined) === (integration === undefined) ||
|
|
@@ -17,7 +18,14 @@ export async function* readStructured(
|
|
|
17
18
|
}
|
|
18
19
|
let pool;
|
|
19
20
|
let stream;
|
|
21
|
+
let source;
|
|
22
|
+
let failed = false;
|
|
23
|
+
const dispose = () => {
|
|
24
|
+
if (pool === undefined) stream?.dispose();
|
|
25
|
+
else for (const id of pool.activeIds) pool.abort(id);
|
|
26
|
+
};
|
|
20
27
|
try {
|
|
28
|
+
signal?.throwIfAborted();
|
|
21
29
|
if (integration !== undefined) {
|
|
22
30
|
pool = createStructuredStreamPool(limits);
|
|
23
31
|
stream = integration(pool, { onDiagnostic });
|
|
@@ -26,23 +34,43 @@ export async function* readStructured(
|
|
|
26
34
|
onDiagnostic === undefined ? limits : { ...limits, onDiagnostic },
|
|
27
35
|
);
|
|
28
36
|
}
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
// Own the source even without cancellation so a rejected next()
|
|
39
|
+
// still triggers upstream cleanup.
|
|
40
|
+
source = abortableSource(events, signal, dispose);
|
|
41
|
+
for await (const event of source) {
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
for (const update of stream.pushAll(event)) {
|
|
44
|
+
signal?.throwIfAborted();
|
|
45
|
+
yield update;
|
|
46
|
+
}
|
|
31
47
|
}
|
|
48
|
+
signal?.throwIfAborted();
|
|
32
49
|
if (pool === undefined) {
|
|
33
|
-
for (const completed of stream.finish())
|
|
50
|
+
for (const completed of stream.finish()) {
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
yield completed;
|
|
53
|
+
}
|
|
34
54
|
} else {
|
|
35
55
|
// SDK finish() appends pending calls to its legacy completion history.
|
|
36
56
|
const pending = pool.size;
|
|
37
57
|
const completed = stream.finish();
|
|
38
58
|
if (pending > 0) {
|
|
39
59
|
for (const result of completed.slice(-pending)) {
|
|
60
|
+
signal?.throwIfAborted();
|
|
40
61
|
yield { type: "complete", ...result };
|
|
41
62
|
}
|
|
42
63
|
}
|
|
43
64
|
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
failed = true;
|
|
67
|
+
throw error;
|
|
44
68
|
} finally {
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
try { dispose(); }
|
|
70
|
+
catch (error) { if (!failed) throw error; }
|
|
71
|
+
finally {
|
|
72
|
+
try { await source?.dispose(); }
|
|
73
|
+
catch (error) { if (!failed) throw error; }
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
}
|
package/src/testing.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StructuredStreamAdapter, StructuredStreamOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export interface AdapterContractTest {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
run(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Node-only helpers for node:test, Vitest, and other Node-based test runners. */
|
|
9
|
+
export function adapterContractTests<Event>(options: {
|
|
10
|
+
readonly adapter: StructuredStreamAdapter<Event, string>;
|
|
11
|
+
/** Encode every operation in order; may split or batch your decoded events. */
|
|
12
|
+
readonly encode: (operations: readonly StructuredStreamOperation<string>[]) => Iterable<Event>;
|
|
13
|
+
}): readonly AdapterContractTest[];
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readStructured } from "./read-structured.js";
|
|
3
|
+
|
|
4
|
+
/** Contract tests for defineAdapter-compatible factories, not legacy SDK adapters. */
|
|
5
|
+
export function adapterContractTests({ adapter, encode }) {
|
|
6
|
+
const start = (id) => ({ type: "start", id });
|
|
7
|
+
const delta = (id, text) => ({ type: "delta", id, text });
|
|
8
|
+
const end = (id) => ({ type: "end", id });
|
|
9
|
+
const push = (session, operations) => {
|
|
10
|
+
const updates = [];
|
|
11
|
+
for (const event of encode(operations)) updates.push(...session.pushAll(event));
|
|
12
|
+
return updates;
|
|
13
|
+
};
|
|
14
|
+
const check = (name, run) => ({
|
|
15
|
+
name,
|
|
16
|
+
async run() {
|
|
17
|
+
const session = adapter({ snapshots: "immutable" });
|
|
18
|
+
try { await run(session); }
|
|
19
|
+
finally { session.dispose(); }
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return [
|
|
23
|
+
check("interleaved calls preserve lifecycle order and values", (session) => {
|
|
24
|
+
const updates = push(session, [
|
|
25
|
+
start("a"), start("b"), delta("a", '{"city":"San'),
|
|
26
|
+
delta("b", "42"), delta("a", ' Francisco"}'), end("b"), end("a"),
|
|
27
|
+
]);
|
|
28
|
+
assert.deepEqual(updates.map(({ type, id }) => [type, id]), [
|
|
29
|
+
["start", "a"], ["start", "b"], ["update", "a"], ["update", "b"],
|
|
30
|
+
["update", "a"], ["complete", "b"], ["complete", "a"],
|
|
31
|
+
]);
|
|
32
|
+
assert.deepEqual(updates[2].partialValue, { city: "San" });
|
|
33
|
+
assert.equal(updates[5].value, 42);
|
|
34
|
+
assert.deepEqual(updates[6].value, { city: "San Francisco" });
|
|
35
|
+
assert.deepEqual(session.finish(), []);
|
|
36
|
+
}),
|
|
37
|
+
check("immutable snapshots retain nested values across later events", (session) => {
|
|
38
|
+
const first = push(session, [start("a"), delta("a", '{"items":[{"name":"A')]).at(-1);
|
|
39
|
+
push(session, [delta("a", 'B"}]}'), end("a")]);
|
|
40
|
+
assert.deepEqual(first.partialValue, { items: [{ name: "A" }] });
|
|
41
|
+
assert.ok(Object.isFrozen(first.partialValue));
|
|
42
|
+
assert.ok(Object.isFrozen(first.partialValue.items[0]));
|
|
43
|
+
}),
|
|
44
|
+
check("EOF finishes pending calls exactly once", (session) => {
|
|
45
|
+
push(session, [start("a"), delta("a", "null"), start("b"), delta("b", "[]"), end("b")]);
|
|
46
|
+
const completed = session.finish();
|
|
47
|
+
assert.equal(completed.length, 1);
|
|
48
|
+
assert.equal(completed[0].id, "a");
|
|
49
|
+
assert.equal(completed[0].type, "complete");
|
|
50
|
+
assert.equal(completed[0].value, null);
|
|
51
|
+
assert.deepEqual(session.finish(), []);
|
|
52
|
+
}),
|
|
53
|
+
check("abort abandons incomplete JSON and allows ID reuse", (session) => {
|
|
54
|
+
const updates = push(session, [
|
|
55
|
+
start("a"), delta("a", "{"), { type: "abort", id: "a" },
|
|
56
|
+
start("a"), delta("a", "true"), end("a"),
|
|
57
|
+
start("a"), delta("a", "false"), end("a"),
|
|
58
|
+
]);
|
|
59
|
+
assert.deepEqual(updates.filter(({ type }) => type === "complete").map(({ value }) => value), [true, false]);
|
|
60
|
+
assert.deepEqual(session.finish(), []);
|
|
61
|
+
}),
|
|
62
|
+
check("malformed input fails terminally instead of emitting a completion", (session) => {
|
|
63
|
+
assert.throws(() => push(session, [start("a"), delta("a", "{]")]), SyntaxError);
|
|
64
|
+
assert.throws(() => session.finish());
|
|
65
|
+
assert.throws(() => push(session, [start("b")]));
|
|
66
|
+
}),
|
|
67
|
+
check("unknown calls and duplicate starts are rejected", (session) => {
|
|
68
|
+
assert.throws(() => push(session, [delta("missing", "{}")]), { code: "UNKNOWN_STREAM" });
|
|
69
|
+
const other = adapter();
|
|
70
|
+
try {
|
|
71
|
+
assert.throws(() => push(other, [start("a"), start("a")]), { code: "DUPLICATE_STREAM" });
|
|
72
|
+
} finally { other.dispose(); }
|
|
73
|
+
}),
|
|
74
|
+
check("sessions are isolated and dispose is idempotent", (session) => {
|
|
75
|
+
push(session, [start("a"), delta("a", "{")]);
|
|
76
|
+
const other = adapter();
|
|
77
|
+
try {
|
|
78
|
+
const updates = push(other, [start("a"), delta("a", "42"), end("a")]);
|
|
79
|
+
assert.equal(updates.at(-1).value, 42);
|
|
80
|
+
} finally { other.dispose(); }
|
|
81
|
+
session.dispose();
|
|
82
|
+
session.dispose();
|
|
83
|
+
assert.throws(() => push(session, [start("b")]), { code: "STREAM_DISPOSED" });
|
|
84
|
+
}),
|
|
85
|
+
check("configured parser limits are forwarded", () => {
|
|
86
|
+
const limited = adapter({ maxActiveStreams: 1, maxBytes: 2 });
|
|
87
|
+
try {
|
|
88
|
+
assert.throws(() => push(limited, [start("a"), start("b")]), { code: "MAX_ACTIVE_STREAMS_EXCEEDED" });
|
|
89
|
+
} finally { limited.dispose(); }
|
|
90
|
+
const bytes = adapter({ maxBytes: 2 });
|
|
91
|
+
try {
|
|
92
|
+
assert.throws(() => push(bytes, [start("a"), delta("a", "true")]), { code: "MAX_BYTES_EXCEEDED" });
|
|
93
|
+
} finally { bytes.dispose(); }
|
|
94
|
+
}),
|
|
95
|
+
{
|
|
96
|
+
name: "managed early exit closes the source and disposes its session",
|
|
97
|
+
async run() {
|
|
98
|
+
let closed = false;
|
|
99
|
+
let disposed = false;
|
|
100
|
+
function* source() {
|
|
101
|
+
try { yield* encode([start("a"), delta("a", "{")]); }
|
|
102
|
+
finally { closed = true; }
|
|
103
|
+
}
|
|
104
|
+
const factory = (options) => {
|
|
105
|
+
const session = adapter(options);
|
|
106
|
+
return {
|
|
107
|
+
pushAll: (event) => session.pushAll(event),
|
|
108
|
+
finish: () => session.finish(),
|
|
109
|
+
dispose() { disposed = true; session.dispose(); },
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
for await (const update of readStructured(source(), { adapter: factory })) {
|
|
113
|
+
assert.equal(update.type, "start");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
assert.equal(closed, true);
|
|
117
|
+
assert.equal(disposed, true);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|