streamfold 0.1.6 → 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.6",
3
+ "version": "0.1.7",
4
4
  "description": "Protocol-neutral incremental state for structured AI streams",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.d.ts CHANGED
@@ -193,7 +193,7 @@ export interface ReadStructuredIntegrationOptions<Event, Id = string>
193
193
 
194
194
  /** Consume decoded events, finalizing at EOF and disposing on every exit. */
195
195
  export function readStructured<Event, Id = string>(
196
- events: AsyncIterable<Event> | Iterable<Event>,
196
+ events: AsyncIterable<Event> | Iterable<Event> | ReadableStream<Event>,
197
197
  options:
198
198
  | ReadStructuredOptions<Event, Id>
199
199
  | ReadStructuredIntegrationOptions<Event, Id>,
@@ -8,7 +8,7 @@ export function abortableSource(events, signal, onAbort) {
8
8
  let closed = false;
9
9
  // Each read removes its listener; one shared pending promise would retain
10
10
  // a Promise.race reaction for every event until the session ends.
11
- const waitFor = (promise) => new Promise((resolve, reject) => {
11
+ const waitFor = (promise) => signal === undefined ? Promise.resolve(promise) : new Promise((resolve, reject) => {
12
12
  let listening = true;
13
13
  const detach = () => {
14
14
  if (!listening) return;
@@ -44,16 +44,16 @@ export function abortableSource(events, signal, onAbort) {
44
44
  try { onAbort(); } catch { /* Cancellation preserves the signal's reason. */ }
45
45
  close(signal.reason).catch(() => {});
46
46
  };
47
- signal.addEventListener("abort", abort, { once: true });
48
- if (signal.aborted) abort();
47
+ signal?.addEventListener("abort", abort, { once: true });
48
+ if (signal?.aborted) abort();
49
49
 
50
50
  return {
51
51
  [Symbol.asyncIterator]() { return this; },
52
52
  async next() {
53
- signal.throwIfAborted();
53
+ signal?.throwIfAborted();
54
54
  const result = await waitFor(
55
55
  Promise.resolve().then(async () => {
56
- signal.throwIfAborted();
56
+ signal?.throwIfAborted();
57
57
  const next = reader !== undefined ? await reader.read() : await iterator.next();
58
58
  if (next === null || typeof next !== "object") {
59
59
  throw new TypeError("Iterator result must be an object");
@@ -61,7 +61,7 @@ export function abortableSource(events, signal, onAbort) {
61
61
  return synchronous ? { done: next.done, value: await next.value } : next;
62
62
  }),
63
63
  );
64
- signal.throwIfAborted();
64
+ signal?.throwIfAborted();
65
65
  if (result.done) {
66
66
  closed = true;
67
67
  reader?.releaseLock();
@@ -71,12 +71,12 @@ export function abortableSource(events, signal, onAbort) {
71
71
  async return() {
72
72
  const cleanup = close();
73
73
  cleanup.catch(() => {});
74
- if (!signal.aborted) await waitFor(cleanup);
74
+ if (!signal?.aborted) await waitFor(cleanup);
75
75
  return { done: true, value: undefined };
76
76
  },
77
77
  async dispose() {
78
78
  try { await this.return(); }
79
- finally { signal.removeEventListener("abort", abort); }
79
+ finally { signal?.removeEventListener("abort", abort); }
80
80
  },
81
81
  };
82
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);
@@ -35,8 +35,10 @@ export async function* readStructured(
35
35
  );
36
36
  }
37
37
  signal?.throwIfAborted();
38
- source = signal === undefined ? undefined : abortableSource(events, signal, dispose);
39
- for await (const event of source ?? events) {
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) {
40
42
  signal?.throwIfAborted();
41
43
  for (const update of stream.pushAll(event)) {
42
44
  signal?.throwIfAborted();