@zakkster/lite-stream 1.1.0 → 1.1.1

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/Stream.js CHANGED
@@ -1,4 +1,4 @@
1
- // @zakkster/lite-stream 1.1.0
1
+ // @zakkster/lite-stream 1.1.1
2
2
  //
3
3
  // Zero-GC bridge between async iterators and @zakkster/lite-signal. The
4
4
  // multi-shot dual of lite-await's fromPromise: project an async source of N
@@ -22,7 +22,8 @@
22
22
  //
23
23
  // 1.1 added `mode: "latest"`, `filter`, `timeout`, `Symbol.asyncDispose`,
24
24
  // multi-waiter FIFO queue, and `overflowCount` on `toAsyncIterable`. See
25
- // CONTRACT-toAsyncIterable-1.1.md and CHANGELOG.md for the full 1.1 story.
25
+ // CHANGELOG.md [1.1.0] and llms.txt (toAsyncIterable section) for the full
26
+ // 1.1 story.
26
27
  //
27
28
  // Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
28
29
  // MIT License
@@ -40,9 +41,10 @@ import { signal as _signal } from "@zakkster/lite-signal";
40
41
  * duck-check via `e.name === "TimeoutError"` across both packages -- but
41
42
  * NOT imported from lite-await, to preserve lite-stream's zero-dep story.
42
43
  *
43
- * The wiring (start / clear / reject with) is Session B's territory; this
44
- * class exists in Session A only so the exported shape is stable and the
45
- * failing test suite can compile.
44
+ * A structural twin of lite-await's TimeoutError, deliberately not imported
45
+ * (zero-dep by design), carrying `name === "TimeoutError"` and a `.timeout`
46
+ * field (the elapsed deadline in ms). The duck-check is suite-wide vocabulary;
47
+ * renaming it would be a breaking change everywhere it is consumed.
46
48
  */
47
49
  class TimeoutError extends Error {
48
50
  constructor(timeoutMs) {
@@ -145,11 +147,11 @@ function closeIterator(iter) {
145
147
  * - iterator throws
146
148
  * - opts.signal aborts (the iterator's return() is called best-effort)
147
149
  *
148
- * To stop the stream without an AbortSignal, you can either:
149
- * - dispose the returned signal via lite-signal's dispose(sig); pending
150
- * iterator pulls will see the signal as disposed on next set() (no-op),
151
- * but the iterator continues until natural completion. PREFER AbortSignal.
152
- * - pass opts.signal and abort it externally.
150
+ * Disposing the result signal does NOT stop the pump: the iterator keeps
151
+ * pulling. AbortSignal or natural completion are the only stop mechanisms.
152
+ * A disposed signal's set() is a silent no-op, so the pump never learns of
153
+ * the disposal; an infinite source becomes an unbounded background loop.
154
+ * Pass opts.signal and abort it to stop early. See ROADMAP LS-01.
153
155
  *
154
156
  * @template T
155
157
  * @param {AsyncIterable<T> | AsyncIterator<T> | Iterable<T>} source
@@ -301,9 +303,11 @@ function fromAsyncIterable(source, opts) {
301
303
  }
302
304
  const v = result.value;
303
305
  // Build the new state from lastState (never sig.peek), then
304
- // commit to both lastState and sig. If sig.set throws (consumer
305
- // disposed the signal externally), bail and tear down the
306
- // iterator so we don't keep pulling into the void.
306
+ // commit to both lastState and sig. The try/catch around set()
307
+ // is defensive against a peer whose set() throws; on a throw we
308
+ // bail and tear down the iterator rather than pull into the
309
+ // void. Disposal is NOT the trigger -- set() after dispose() is
310
+ // a silent no-op in lite-signal 1.2.2 and 1.5.0 (see LS-01).
307
311
  let newState;
308
312
  if (mode === "latest") {
309
313
  newState = {
@@ -341,16 +345,22 @@ function fromAsyncIterable(source, opts) {
341
345
  try {
342
346
  sig.set(newState);
343
347
  } catch (_e) {
344
- // Signal disposed externally. Tear down quietly without
345
- // firing onError -- this is consumer-initiated cleanup,
346
- // not a stream error.
348
+ // Defensive against a peer signal whose set() throws. This
349
+ // is NOT disposal detection: set() after dispose() is a
350
+ // silent no-op in lite-signal 1.2.2 and 1.5.0 (probe P0),
351
+ // so this branch has never fired against any published
352
+ // peer. If it ever does, tear down without firing onError.
347
353
  stopped = true;
348
354
  cleanup();
349
355
  closeIterator(iter);
350
356
  return;
351
357
  }
352
- // Defer the next pull through the microtask queue so back-to-back
353
- // synchronous yields don't blow the stack.
358
+ // pump() is called DIRECTLY here -- no explicit deferral at
359
+ // this site. The stack is already unwound because this
360
+ // continuation runs from a .then() handler, i.e. a fresh
361
+ // microtask. Proven safe: 1,000,000 synchronous values drained
362
+ // in ~80ms with no stack overflow (probe P4, node v26.3.1,
363
+ // 2026-09-01).
354
364
  pump();
355
365
  },
356
366
  function (err) {
@@ -420,6 +430,8 @@ function makeErrorState(prevState, err, mode) {
420
430
  * - You want a stop fn instead of an AbortController for cleanup
421
431
  *
422
432
  * NOTE: this does NOT dispose the target signal. The caller owns its lifetime.
433
+ * Disposing the target signal does NOT stop the pump: call the returned
434
+ * stop fn or abort opts.signal. See ROADMAP LS-01.
423
435
  *
424
436
  * @template T
425
437
  * @param {AsyncIterable<T> | AsyncIterator<T>} source
@@ -557,7 +569,8 @@ const ASYNC_DISPOSE = (typeof Symbol !== "undefined" && Symbol.asyncDispose)
557
569
  * where Promise.all([iter.next(), iter.next()]) silently lost the first
558
570
  * resolver.
559
571
  *
560
- * See CONTRACT-toAsyncIterable-1.1.md for the full locked semantics.
572
+ * See CHANGELOG.md [1.1.0] and llms.txt (toAsyncIterable section) for the
573
+ * full locked semantics.
561
574
  *
562
575
  * @template T
563
576
  * @param {import("@zakkster/lite-signal").Signal<T> | import("@zakkster/lite-signal").Computed<T>} sig
package/llms.txt CHANGED
@@ -61,7 +61,9 @@ const stop = pipeToSignal(source, targetSig, {
61
61
  });
62
62
  ```
63
63
 
64
- Does NOT dispose the target signal. The caller owns its lifetime.
64
+ Does NOT dispose the target signal. The caller owns its lifetime. Disposing
65
+ the target signal does NOT stop the pump: call the returned stop fn or abort
66
+ opts.signal.
65
67
 
66
68
  ### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount, overflowCount }`
67
69
 
@@ -129,9 +131,13 @@ imported from lite-await -- lite-stream stays zero-dep.
129
131
  The abort listener is always removed on any of the three paths -- no
130
132
  AbortSignal accumulates dangling listeners.
131
133
 
132
- If the consumer disposes the result signal via lite-signal's `dispose()`,
133
- the pump detects on next `sig.set()` and tears down silently (no onError --
134
- this is consumer-initiated cleanup, not a stream error).
134
+ Disposing the result signal does NOT stop the pump: the iterator keeps
135
+ pulling. AbortSignal or natural completion are the only stop mechanisms.
136
+ `iterator.return()` is never called and neither `onError` nor `onDone` ever
137
+ fire, because lite-signal's `set()` after `dispose()` is a silent no-op (in
138
+ 1.2.2 and 1.5.0), so the pump never learns of the disposal. For an infinite
139
+ source (SSE, a live feed) this turns the stream into an unbounded background
140
+ loop. Abort `opts.signal` to stop early. See ROADMAP LS-01.
135
141
 
136
142
  ## Key invariants
137
143
 
@@ -243,31 +249,40 @@ states: {
243
249
  true}` per the async iteration protocol; only `return()`'s own returned
244
250
  Promise carries the value.
245
251
  - DO NOT dispose the source signal while an iterable is still consuming
246
- from it. Call `iter.return()` first; disposing an in-flight source is
247
- undefined behavior.
252
+ from it. Call `iter.return()` first. A pending `next()` at the moment of
253
+ disposal never settles -- it neither resolves nor rejects; the only escape
254
+ hatch is the `timeout` option, which rejects the pending `next()` with
255
+ `TimeoutError` once its deadline elapses. See ROADMAP LS-04.
248
256
 
249
257
  ## Performance
250
258
 
251
- 178K ops/s for the full lifecycle in `fromAsyncIterable` latest mode
252
- (construct + pull + settle + dispose), sub-byte/op retained heap. 235K
253
- ops/s for `pipeToSignal` direct. 84K ops/s for the full abort cycle.
259
+ Measured 2026-09-01 on node v26.3.1 (macOS arm64, laptop class), 150ms
260
+ warmup / 500ms runs, via npm run bench. Your numbers will differ -- re-run
261
+ npm run bench on your target.
254
262
 
255
- 1.1 `toAsyncIterable` throughput (Node v22, 500ms runs):
256
- - `mode: "latest"` 1:1 alternation: 119K cycles/s (2.4M ops/s per pair)
257
- - `mode: "latest"` churn (100 writes coalesced): 60K cycles/s (6M
258
- writes/s absorbed into the single slot)
259
- - `filter + timeout`: 141K ops/s, negative retained (the timer-clear
260
- discipline on `iter.return()` helps V8 reclaim more than baseline)
261
- - multi-waiter x3 concurrent .next(): 120K cycles/s
263
+ The six scenarios `bench/bench.mjs` actually runs (ops/s, retained B/op):
264
+
265
+ - `from-latest-10`: 68,156 ops/s, 0.3218 B/op
266
+ - `from-buffer-20-drop`: 61,960 ops/s, 0.4715 B/op
267
+ - `abort-cycle`: 29,078 ops/s, 1.5132 B/op
268
+ - `pipe-to-signal`: 50,098 ops/s, 0.0853 B/op
269
+ - `to-async-iterable`: 198,948 ops/s, -8.4923 B/op
270
+ - `to-async-iterable-overflow`: 208,790 ops/s, 0.1013 B/op
271
+
272
+ The negative B/op on `to-async-iterable` is a GC-timing artifact (the
273
+ post-run gc() reclaims more than the pre-run baseline held); it is not a
274
+ zero-alloc guarantee. Per-scenario numbers only -- there is no "sub-byte
275
+ across all scenarios" claim.
262
276
 
263
277
  Per-yield, `fromAsyncIterable` latest mode allocates one wrapper state
264
278
  object; buffer mode adds one snapshot array. `toAsyncIterable` `mode:
265
279
  "latest"` allocates zero on the steady-state hot path when the consumer
266
- is caught up; `mode: "buffer"` also zero when no waiter is pending.
280
+ is caught up; `mode: "buffer"` also zero when no waiter is pending
281
+ (structural claim; gated by the alloc tier in 1.2.0).
267
282
 
268
283
  ## Files
269
284
 
270
- - `Stream.js` -- single-file ESM implementation (~830 lines)
285
+ - `Stream.js` -- single-file ESM implementation (~875 lines)
271
286
  - `Stream.d.ts` -- TypeScript types with discriminated state union
272
287
  - `README.md` -- full docs with integration recipes
273
288
  - `llms.txt` -- this file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-stream",
3
- "version": "1.1.0",
4
- "description": "Zero-GC bridge between async iterators and @zakkster/lite-signal. Project async streams (paginated APIs, SSE, network frames, pubsub topics) into signals. Bounded buffering with explicit overflow diagnostics, structural cleanup on three termination paths (iterator done, abort, dispose). The multi-shot dual of lite-await's fromPromise.",
3
+ "version": "1.1.1",
4
+ "description": "Zero-GC bridge between async iterators and @zakkster/lite-signal. Project async streams (paginated APIs, SSE, network frames, pubsub topics) into signals. Bounded buffering with explicit overflow diagnostics, structural cleanup on three termination paths (iterator done, iterator throws, AbortSignal abort). The multi-shot dual of lite-await's fromPromise.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "ROADMAP.md"
27
27
  ],
28
28
  "scripts": {
29
- "test": "node --test --test-reporter=spec",
29
+ "test": "node --expose-gc --test --test-reporter=spec",
30
30
  "test:gc": "node --expose-gc --test --test-reporter=spec",
31
31
  "bench": "node --expose-gc bench/bench.mjs",
32
32
  "verify": "npm test && npm run test:gc && npm run bench"