@zakkster/lite-stream 1.0.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +119 -63
  3. package/ROADMAP.md +679 -159
  4. package/Stream.js +316 -80
  5. package/llms.txt +101 -18
  6. package/package.json +3 -3
package/llms.txt CHANGED
@@ -14,7 +14,7 @@ Peer dep: `@zakkster/lite-signal ^1.2.0`.
14
14
 
15
15
  ## Imports
16
16
 
17
- import { fromAsyncIterable, pipeToSignal, toAsyncIterable } from "@zakkster/lite-stream";
17
+ import { fromAsyncIterable, pipeToSignal, toAsyncIterable, TimeoutError } from "@zakkster/lite-stream";
18
18
 
19
19
  ## Exports
20
20
 
@@ -61,25 +61,64 @@ 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
- ### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount }`
68
+ ### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount, overflowCount }`
67
69
 
68
- Reverse direction: yield signal changes as an async iterable. Bounded
69
- internal queue (default 1024) with drop-oldest overflow.
70
+ Reverse direction: yield signal changes as an async iterable.
71
+
72
+ **1.0.0 shape (still the default):** bounded FIFO ring buffer, drop-oldest
73
+ overflow, `droppedCount` observable.
74
+
75
+ **1.1 additions (all opt-in, no default changes):**
76
+ - `mode: "latest"` -- single-slot overwrite semantics; use for reactive
77
+ state / "current frame" style consumers
78
+ - `filter` -- per-value gate; throwing filter rejects pending next() and
79
+ terminates (no writer surface)
80
+ - `timeout` -- overall deadline; pending next() rejects with `TimeoutError`
81
+ - `Symbol.asyncDispose` on Node 20+ -- enables `await using iter = ...`
82
+ - `overflowCount` -- alias for `droppedCount`, mode-neutral vocabulary
83
+ - Multi-waiter queue -- concurrent .next() calls resolve in FIFO order
84
+ (1.0.0 silently overwrote the first resolver)
70
85
 
71
86
  ```js
87
+ // 1.0.0-style: bounded FIFO buffer (default)
72
88
  for await (const v of toAsyncIterable(sig, {
73
89
  signal: abortCtrl.signal,
74
- emitInitial: true, // default true
75
- maxBuffer: 1024 // default 1024
90
+ emitInitial: true, // default true
91
+ maxBuffer: 1024 // default 1024
92
+ })) {
93
+ // ...
94
+ }
95
+
96
+ // 1.1: latest-wins slot for reactive state
97
+ for await (const v of toAsyncIterable(sig, {
98
+ mode: "latest", // opt in to single-slot overwrite
99
+ filter: (v) => v.priority > 0,
100
+ timeout: 5000 // overall deadline
76
101
  })) {
77
102
  // ...
78
103
  }
79
104
  ```
80
105
 
81
- The iterator naturally completes when `opts.signal` aborts. Consumer-side
82
- `break` triggers `iterator.return()` and cleans up the subscription.
106
+ Constraints:
107
+ - `mode: "latest"` + `maxBuffer` throws TypeError (single slot has no buffer size)
108
+ - Negative or non-finite `timeout` throws RangeError
109
+ - Non-function `filter` throws TypeError
110
+
111
+ The iterator naturally completes when `opts.signal` aborts (resolves as
112
+ done, not rejection -- graceful termination). Consumer-side `break`
113
+ triggers `iterator.return()` and cleans up. `timeout` elapsing rejects
114
+ the pending next() with `TimeoutError`; subsequent calls return done.
115
+
116
+ ### `TimeoutError` (added 1.1)
117
+
118
+ Thrown when the `timeout` option on `toAsyncIterable` elapses.
119
+ Structurally identical to `@zakkster/lite-await`'s TimeoutError so
120
+ `e.name === "TimeoutError"` duck-checks work across both packages. Not
121
+ imported from lite-await -- lite-stream stays zero-dep.
83
122
 
84
123
  ## Cleanup termination triplet
85
124
 
@@ -92,9 +131,13 @@ The iterator naturally completes when `opts.signal` aborts. Consumer-side
92
131
  The abort listener is always removed on any of the three paths -- no
93
132
  AbortSignal accumulates dangling listeners.
94
133
 
95
- If the consumer disposes the result signal via lite-signal's `dispose()`,
96
- the pump detects on next `sig.set()` and tears down silently (no onError --
97
- 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.
98
141
 
99
142
  ## Key invariants
100
143
 
@@ -188,18 +231,58 @@ states: {
188
231
  - Not a replacement for lite-clock for frame-rate loops.
189
232
  - Not for one-shot promises -- use lite-await's `fromPromise`.
190
233
 
234
+ ## Anti-patterns
235
+
236
+ - DO NOT combine `mode: "latest"` with `maxBuffer` on `toAsyncIterable`.
237
+ It throws TypeError at construction -- a single-slot mode with a buffer
238
+ size is user error. Pick one or the other.
239
+ - DO NOT reach for lite-stream for one-shot Signal<->Promise coordination.
240
+ That is `@zakkster/lite-await`'s job (`whenSignal`, `fromPromise`,
241
+ `allOf`/`anyOf`/`raceOf`). lite-stream owns the AsyncIterable boundary;
242
+ lite-await owns the Promise boundary.
243
+ - DO NOT wrap `toAsyncIterable` with an async-generator debounce / throttle
244
+ for reactive-state use cases. Use `mode: "latest"` directly -- it
245
+ already gives you "consume newest, discard intermediates" semantics with
246
+ zero per-value allocation.
247
+ - DO NOT rely on the value passed to `iter.return(value)` reaching pending
248
+ waiters. Pending `next()` calls resolve with `{value: undefined, done:
249
+ true}` per the async iteration protocol; only `return()`'s own returned
250
+ Promise carries the value.
251
+ - DO NOT dispose the source signal while an iterable is still consuming
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.
256
+
191
257
  ## Performance
192
258
 
193
- 178K ops/s for the full lifecycle in latest mode (construct + pull + settle
194
- + dispose), sub-byte/op retained heap. 235K ops/s for pipeToSignal direct.
195
- 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.
262
+
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.
196
276
 
197
- Per-yield, latest mode allocates one wrapper state object. Buffer mode
198
- adds one snapshot array per yield. Ring beneath is fixed pre-allocated.
277
+ Per-yield, `fromAsyncIterable` latest mode allocates one wrapper state
278
+ object; buffer mode adds one snapshot array. `toAsyncIterable` `mode:
279
+ "latest"` allocates zero on the steady-state hot path when the consumer
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).
199
282
 
200
283
  ## Files
201
284
 
202
- - `Stream.js` -- single-file ESM implementation (~600 lines)
285
+ - `Stream.js` -- single-file ESM implementation (~875 lines)
203
286
  - `Stream.d.ts` -- TypeScript types with discriminated state union
204
287
  - `README.md` -- full docs with integration recipes
205
288
  - `llms.txt` -- this file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-stream",
3
- "version": "1.0.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"