@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/CHANGELOG.md +57 -0
- package/README.md +30 -69
- package/ROADMAP.md +680 -208
- package/Stream.js +32 -19
- package/llms.txt +33 -18
- package/package.json +3 -3
package/Stream.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @zakkster/lite-stream 1.1.
|
|
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
|
-
//
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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.
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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
|
-
//
|
|
353
|
-
//
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
- `
|
|
258
|
-
|
|
259
|
-
- `
|
|
260
|
-
|
|
261
|
-
-
|
|
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 (~
|
|
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.
|
|
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,
|
|
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"
|