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 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.5",
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);
@@ -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
- for await (const event of events) {
30
- for (const update of stream.pushAll(event)) yield update;
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()) yield completed;
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
- if (pool === undefined) stream?.dispose();
46
- else for (const id of pool.activeIds) pool.abort(id);
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
  }
@@ -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
+ }