@zakkster/lite-stream 1.2.0 → 1.3.0
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 +81 -0
- package/README.md +57 -11
- package/Stream.d.ts +57 -4
- package/Stream.js +130 -13
- package/llms.txt +64 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## 1.3.0 -- 2026-09-02
|
|
11
|
+
|
|
12
|
+
**The consumer contract.** The one real bug the torture harness found is fixed
|
|
13
|
+
(LS-13), and the three things the flagship consumer hand-rolled around
|
|
14
|
+
`pipeToSignal` -- a buffer window, a first-value tap, an abort filter -- are
|
|
15
|
+
offered natively as additive options. Every 1.1.0 call shape stays
|
|
16
|
+
byte-identical; the abort vocabulary is pinned in a decision doc.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **LS-13** -- a `next()` result object whose `done` or `value` is a THROWING
|
|
21
|
+
getter now routes to the terminal error state with `onError` fired exactly
|
|
22
|
+
once, in both pumps (`fromAsyncIterable` and `pipeToSignal`). Previously the
|
|
23
|
+
getter fired inside the pump's `.then` onFulfilled with no catch at that
|
|
24
|
+
site: the throw escaped as an `unhandledRejection` and the signal never
|
|
25
|
+
settled. Both pumps now read the two result properties once into locals inside
|
|
26
|
+
a dedicated try/catch that routes to the existing "iterator throws" leg
|
|
27
|
+
(triplet leg 2). The wrap covers ONLY the two property reads -- the LS-01
|
|
28
|
+
peer-defense catch around `set()` is separate and untouched.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`pipeToSignal` enrichment** (all additive; the un-optioned path is
|
|
33
|
+
byte-identical to 1.1.0) -- `mode: "buffer"` with a REQUIRED `maxBuffer`
|
|
34
|
+
drives the target with a bounded newest-last snapshot array (a fresh array per
|
|
35
|
+
set), dropping the oldest on overflow; `mode: "latest"` (default) is the
|
|
36
|
+
1.1.0 direct-set behavior. Validated once at construction: `TypeError` on an
|
|
37
|
+
unknown mode and on `mode: "latest"` + `maxBuffer`; `RangeError` on
|
|
38
|
+
missing/invalid `maxBuffer` in buffer mode (the `fromAsyncIterable` message
|
|
39
|
+
voice). The returned stop fn gains `droppedCount` and `overflowCount` getters
|
|
40
|
+
(aliases of one overflow counter, 0 in latest mode); the stop fn stays
|
|
41
|
+
callable and idempotent.
|
|
42
|
+
- **`onValue(v)` tap** on `pipeToSignal` -- fires exactly once per value BEFORE
|
|
43
|
+
each set (after `transform`). A throwing `onValue` routes through the same
|
|
44
|
+
`stop` + `onError` leg as a throwing transform (the test/04 precedent).
|
|
45
|
+
- **`onAbort(reason)`** on `pipeToSignal` -- when present, aborts route to it
|
|
46
|
+
INSTEAD of `onError` (both at the abort listener and the pre-aborted early
|
|
47
|
+
return), so consumers stop filtering intentional aborts out of their error
|
|
48
|
+
handler. Absent -> 1.1.0 behavior byte-identical.
|
|
49
|
+
- **`decisions/0001-abort-vocabulary.md`** (LS-12) -- a repo-only decision doc
|
|
50
|
+
(NOT in `files[]`, never in the tarball) pinning the from-side/to-side abort
|
|
51
|
+
asymmetry as deliberate and recording the `onAbort` addition and the rejected
|
|
52
|
+
alternatives. llms.txt carries the contract table citing it.
|
|
53
|
+
- **Torture extensions** -- the `s7e` enriched-surface control (10 controls
|
|
54
|
+
total: s0-s8 + s7e, each individually non-zero) and the `pipeBuffer64`
|
|
55
|
+
allocation scenario in s5 (~5 B/op, budget 32 B/op). The s1 LS-13 pin is now a
|
|
56
|
+
hard assertion; the `pipeSteady` default-path budget is unchanged (the
|
|
57
|
+
purity proof).
|
|
58
|
+
- **21 new tests** -- `test/10-pipe-enriched.test.mjs` (15: mode/maxBuffer/
|
|
59
|
+
onValue/onAbort matrix, validation throws, stop-fn observability, abort
|
|
60
|
+
routing with and without `onAbort`, and a StreamQuery-shaped rewrite parity
|
|
61
|
+
case) plus 6 LS-13 pins in `test/08-dispose-behavior.test.mjs` (throwing
|
|
62
|
+
getter -> error state, `onError` once, zero unhandledRejection via a scoped
|
|
63
|
+
process capture; includes the pinned decision that a `done: true` result
|
|
64
|
+
with a throwing `value` getter routes to the ERROR leg, not the done leg).
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- `pipeToSignal`'s `Stream.d.ts` return type is now the callable
|
|
69
|
+
`PipeToSignalStop` (a stop fn with `readonly droppedCount` / `overflowCount`),
|
|
70
|
+
and its options are the discriminated `PipeToSignalOptions<T>` union. No new
|
|
71
|
+
value export -- the surface stays exactly five names.
|
|
72
|
+
|
|
73
|
+
### Testing
|
|
74
|
+
|
|
75
|
+
- **111 tests** across `test/01-*` through `test/10-*` (up from 90), 0 fail,
|
|
76
|
+
0 skip under `npm test` and `npm run test:gc`.
|
|
77
|
+
- `npm run torture` -> `ok`, ~1.77s wall (node v26.3.1, macOS arm64,
|
|
78
|
+
2026-09-02); `maxMajorsPerKOp` 0.000 in every s5 scenario; `pipeSteady`
|
|
79
|
+
1.38 B/op (budget 32, UNCHANGED) and the new `pipeBuffer64` ~5 B/op
|
|
80
|
+
(budget 32). Controls are 10/10 individually non-zero; `TORTURE_SEED` replay
|
|
81
|
+
ok; probes P0-P4 digit-normalized identical.
|
|
82
|
+
- s7 rewrite-parity case: N=12 values, `maxBuffer=4` -> `droppedCount` 8,
|
|
83
|
+
newest-last order, semantics identical to the hand-rolled StreamQuery shape.
|
|
84
|
+
- Floor-matrix doctrine unchanged from 1.2.0: `npm run test:floor` runs
|
|
85
|
+
`npm test` against the pinned `@zakkster/lite-signal@1.2.2` and against the
|
|
86
|
+
resolved latest; per-scenario s5 budgets, the global `maxMajor` rule never
|
|
87
|
+
widens.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
10
91
|
## 1.2.0 -- 2026-09-02
|
|
11
92
|
|
|
12
93
|
**The law's gate, able to fail.** A torture harness that proves the package's
|
package/README.md
CHANGED
|
@@ -165,16 +165,22 @@ pipeToSignal<T>(
|
|
|
165
165
|
target: Signal<T>,
|
|
166
166
|
opts?: {
|
|
167
167
|
signal?: AbortSignal,
|
|
168
|
+
mode?: "latest" | "buffer", // default "latest"
|
|
169
|
+
maxBuffer?: number, // REQUIRED if mode = "buffer"
|
|
170
|
+
transform?: (value: T) => T, // per-value map, runs first
|
|
171
|
+
onValue?: (value: T) => void, // tap: fires once BEFORE set
|
|
172
|
+
onAbort?: (reason: unknown) => void, // aborts route here, not onError
|
|
168
173
|
onError?: (err: unknown) => void,
|
|
169
|
-
onDone?: () => void
|
|
170
|
-
transform?: (value: T) => T
|
|
174
|
+
onDone?: () => void
|
|
171
175
|
}
|
|
172
|
-
): () => void
|
|
176
|
+
): (() => void) & { readonly droppedCount: number, readonly overflowCount: number }
|
|
173
177
|
```
|
|
174
178
|
|
|
175
179
|
Lower-level companion: pump an existing writable signal from an async
|
|
176
|
-
iterator.
|
|
177
|
-
(no `{ value, count, ... }` wrapper).
|
|
180
|
+
iterator. In the default `"latest"` mode the signal's value is replaced
|
|
181
|
+
directly with each yielded value (no `{ value, count, ... }` wrapper).
|
|
182
|
+
Returns an idempotent `stop` function that also carries `droppedCount` /
|
|
183
|
+
`overflowCount` getters.
|
|
178
184
|
|
|
179
185
|
Use `pipeToSignal` when:
|
|
180
186
|
|
|
@@ -182,10 +188,41 @@ Use `pipeToSignal` when:
|
|
|
182
188
|
- You don't need the lifecycle metadata wrapper
|
|
183
189
|
- You want a `stop` fn instead of an `AbortController` for cleanup
|
|
184
190
|
|
|
191
|
+
**Enrichment (1.3.0, all additive -- the un-optioned path is byte-identical
|
|
192
|
+
to 1.1.0):**
|
|
193
|
+
|
|
194
|
+
- `mode: "buffer"` (with required `maxBuffer`) drives the target with a bounded
|
|
195
|
+
newest-last snapshot array instead of the raw value; overflow drops the
|
|
196
|
+
oldest and increments `stop.droppedCount`. `mode: "latest"` + `maxBuffer`
|
|
197
|
+
throws `TypeError`; missing/invalid `maxBuffer` in buffer mode throws
|
|
198
|
+
`RangeError`.
|
|
199
|
+
- `onValue(v)` is a per-value tap that fires once BEFORE each set (after
|
|
200
|
+
`transform`). A throwing `onValue` routes through the same `stop` + `onError`
|
|
201
|
+
leg as a throwing transform.
|
|
202
|
+
- `onAbort(reason)`, when present, receives aborts INSTEAD of `onError`, so a
|
|
203
|
+
consumer aborting on purpose (detach / restart) no longer filters
|
|
204
|
+
`signal.aborted` out of its error handler. See the abort-vocabulary contract
|
|
205
|
+
in `llms.txt` and `decisions/0001-abort-vocabulary.md`.
|
|
206
|
+
|
|
185
207
|
**Does NOT dispose the target signal.** The caller owns its lifetime.
|
|
186
208
|
Disposing the target signal does NOT stop the pump: call the returned stop
|
|
187
209
|
fn or abort `opts.signal`.
|
|
188
210
|
|
|
211
|
+
```js
|
|
212
|
+
// Enriched buffer-mode pipe -- replaces a hand-rolled ring + status tap +
|
|
213
|
+
// abort filter (the StreamQuery shape) with native options.
|
|
214
|
+
const stop = pipeToSignal(topicSource, entrySig, {
|
|
215
|
+
signal: ctrl.signal,
|
|
216
|
+
mode: "buffer",
|
|
217
|
+
maxBuffer: 64, // bounded, drop-oldest
|
|
218
|
+
onValue: (v) => { if (first) status.set("streaming"); first = false; },
|
|
219
|
+
onAbort: () => {}, // intentional abort, not an error
|
|
220
|
+
onError: (e) => status.set("error"), // real failures only
|
|
221
|
+
onDone: () => status.set("done")
|
|
222
|
+
});
|
|
223
|
+
// stop.droppedCount tells you how many values overflowed the window.
|
|
224
|
+
```
|
|
225
|
+
|
|
189
226
|
### toAsyncIterable
|
|
190
227
|
|
|
191
228
|
```ts
|
|
@@ -496,7 +533,7 @@ target.
|
|
|
496
533
|
|
|
497
534
|
### Tier 1 -- behavior (unit tests, fast)
|
|
498
535
|
|
|
499
|
-
|
|
536
|
+
111 tests across `test/01-*` through `test/10-*`:
|
|
500
537
|
|
|
501
538
|
- `01-from-async-iterable-latest.test.mjs` -- state shape, lifecycle,
|
|
502
539
|
Iterable acceptance variants, pre-aborted, subscriber observability
|
|
@@ -515,12 +552,18 @@ target.
|
|
|
515
552
|
- `08-dispose-behavior.test.mjs` -- pins the TRUE dispose semantics
|
|
516
553
|
(LS-01: disposing a result/target signal does NOT stop the pump;
|
|
517
554
|
LS-04: a pending `next()` on a disposed source never settles, `timeout`
|
|
518
|
-
is the only escape hatch)
|
|
555
|
+
is the only escape hatch) plus the LS-13 pins (a throwing `done`/`value`
|
|
556
|
+
result getter routes to the error state, `onError` once, no
|
|
557
|
+
unhandledRejection)
|
|
519
558
|
- `09-guards.test.mjs` -- drift guards: ascii-guard (every shipped +
|
|
520
559
|
test/bench/demo byte is printable ASCII or LF) and surface-guard (runtime
|
|
521
560
|
exports == llms.txt == Stream.d.ts, VERSION == package.json version, the
|
|
522
561
|
declared peer is present in both docs). Each family carries an inline
|
|
523
562
|
failing control proving the guard can fail.
|
|
563
|
+
- `10-pipe-enriched.test.mjs` (1.3.0) -- the enriched `pipeToSignal`:
|
|
564
|
+
`mode`/`maxBuffer` validation, buffer-window snapshots and drop counting,
|
|
565
|
+
`onValue` ordering and throw policy, `onAbort` routing with and without the
|
|
566
|
+
hook, and the stop-fn `droppedCount`/`overflowCount` observability
|
|
524
567
|
|
|
525
568
|
Run via `npm test`.
|
|
526
569
|
|
|
@@ -545,11 +588,14 @@ retained as an alias.
|
|
|
545
588
|
`npm run torture` (`node --expose-gc test/torture.mjs`) runs tiers s0-s8
|
|
546
589
|
sequentially -- metamorphic laws, degenerate values, protocol conformance,
|
|
547
590
|
API abuse, seeded fuzz-vs-oracle, the s5 per-scenario allocation gate
|
|
548
|
-
(maxMajor 0
|
|
591
|
+
(maxMajor 0, including the 1.3.0 `pipeBuffer64` scenario alongside the
|
|
592
|
+
unchanged `pipeSteady` default-path budget), a lite-leak retention soak, the
|
|
593
|
+
StreamQuery conformance corpus (with the enriched-shape rewrite parity case),
|
|
549
594
|
and the controls tier. It prints `ok` and exits 0 on success.
|
|
550
|
-
`STREAM_TORTURE_BREAK=<s0..s8|1>` arms one tier's (or every tier's)
|
|
551
|
-
breakage so the control lane exits non-zero; `npm run
|
|
552
|
-
the all-tiers form
|
|
595
|
+
`STREAM_TORTURE_BREAK=<s0..s8|s7e|1>` arms one tier's (or every tier's)
|
|
596
|
+
injected breakage so the control lane exits non-zero; `npm run
|
|
597
|
+
torture:control` runs the all-tiers form (10 controls: s0-s8 plus the s7e
|
|
598
|
+
enriched-surface control, each individually non-zero). `npm run test:floor` runs `npm test` against the peer
|
|
553
599
|
floor `@zakkster/lite-signal@1.2.2` and then `npm test` + torture against the
|
|
554
600
|
resolved latest, printing both verdicts.
|
|
555
601
|
|
package/Stream.d.ts
CHANGED
|
@@ -64,11 +64,57 @@ export interface FromAsyncIterableBufferOptions<T> {
|
|
|
64
64
|
onDone?: () => void;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Common `pipeToSignal` options, shared by both modes. `onValue` is a per-value
|
|
69
|
+
* tap that fires once BEFORE each `set` (after `transform`); a throw from it
|
|
70
|
+
* routes through the same stop + `onError` leg as a throwing transform.
|
|
71
|
+
* `onAbort`, when present, receives aborts INSTEAD of `onError` (see
|
|
72
|
+
* decisions/0001-abort-vocabulary.md) -- both at the abort listener and the
|
|
73
|
+
* pre-aborted early return.
|
|
74
|
+
*/
|
|
75
|
+
export interface PipeToSignalCommonOptions<T> {
|
|
68
76
|
signal?: AbortSignal;
|
|
69
77
|
onError?: (err: unknown) => void;
|
|
70
78
|
onDone?: () => void;
|
|
71
79
|
transform?: (value: T) => T;
|
|
80
|
+
onValue?: (value: T) => void;
|
|
81
|
+
onAbort?: (reason: unknown) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PipeToSignalLatestOptions<T> extends PipeToSignalCommonOptions<T> {
|
|
85
|
+
/** Default. The target signal's value is replaced with the raw value. */
|
|
86
|
+
mode?: "latest";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PipeToSignalBufferOptions<T> extends PipeToSignalCommonOptions<T> {
|
|
90
|
+
/**
|
|
91
|
+
* Buffer mode: the target signal's value is a bounded newest-last snapshot
|
|
92
|
+
* array (a fresh array per set); overflow drops the oldest and increments
|
|
93
|
+
* the stop fn's `droppedCount`.
|
|
94
|
+
*/
|
|
95
|
+
mode: "buffer";
|
|
96
|
+
/**
|
|
97
|
+
* Required when mode === "buffer". Positive integer. Unbounded buffering is
|
|
98
|
+
* rejected as a memory bug pretending to be a feature (RangeError); combining
|
|
99
|
+
* it with mode "latest" throws TypeError.
|
|
100
|
+
*/
|
|
101
|
+
maxBuffer: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type PipeToSignalOptions<T> =
|
|
105
|
+
| PipeToSignalLatestOptions<T>
|
|
106
|
+
| PipeToSignalBufferOptions<T>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `pipeToSignal` return value: an idempotent stop function (calling it more
|
|
110
|
+
* than once is a no-op) that also carries two read-only overflow counters.
|
|
111
|
+
* `droppedCount` and `overflowCount` are aliases of the same buffer-mode
|
|
112
|
+
* overflow counter (0 in latest mode); reading them never allocates.
|
|
113
|
+
*/
|
|
114
|
+
export interface PipeToSignalStop {
|
|
115
|
+
(): void;
|
|
116
|
+
readonly droppedCount: number;
|
|
117
|
+
readonly overflowCount: number;
|
|
72
118
|
}
|
|
73
119
|
|
|
74
120
|
export interface ToAsyncIterableOptions {
|
|
@@ -110,16 +156,23 @@ export function fromAsyncIterable<T>(
|
|
|
110
156
|
/**
|
|
111
157
|
* Lower-level companion to `fromAsyncIterable`: pump an existing writable
|
|
112
158
|
* signal from an async iterator. The signal's value is replaced directly
|
|
113
|
-
* with each yielded value (no wrapper state)
|
|
114
|
-
*
|
|
159
|
+
* with each yielded value (no wrapper state) in the default "latest" mode, or
|
|
160
|
+
* with a bounded newest-last snapshot array in "buffer" mode. Returns a stop
|
|
161
|
+
* function that ends the pump and calls `iterator.return()`; the stop fn also
|
|
162
|
+
* carries `droppedCount` / `overflowCount` getters.
|
|
115
163
|
*
|
|
116
164
|
* Does NOT dispose the target signal; the caller owns its lifetime.
|
|
165
|
+
*
|
|
166
|
+
* Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
|
|
167
|
+
* 1.1.0): `mode: "buffer"` + required `maxBuffer`, an `onValue` tap firing
|
|
168
|
+
* before each set, and `onAbort` routing aborts instead of `onError` (see
|
|
169
|
+
* decisions/0001-abort-vocabulary.md).
|
|
117
170
|
*/
|
|
118
171
|
export function pipeToSignal<T>(
|
|
119
172
|
source: AsyncIterable<T> | AsyncIterator<T>,
|
|
120
173
|
target: Signal<T>,
|
|
121
174
|
opts?: PipeToSignalOptions<T>
|
|
122
|
-
):
|
|
175
|
+
): PipeToSignalStop;
|
|
123
176
|
|
|
124
177
|
/**
|
|
125
178
|
* Yield signal changes as an async iterable. Each change resolves a pending
|
package/Stream.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @zakkster/lite-stream 1.
|
|
1
|
+
// @zakkster/lite-stream 1.3.0
|
|
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
|
|
@@ -297,11 +297,23 @@ function fromAsyncIterable(source, opts) {
|
|
|
297
297
|
stop(new TypeError("lite-stream: iterator.next() returned non-object"));
|
|
298
298
|
return;
|
|
299
299
|
}
|
|
300
|
-
|
|
300
|
+
// LS-13: a throwing done/value getter on the result routes to
|
|
301
|
+
// the "iterator throws" leg (stop(err)). Wrap ONLY the two
|
|
302
|
+
// property reads -- widening this try over sig.set() below would
|
|
303
|
+
// swallow the LS-01 peer-defense catch semantics. Each property
|
|
304
|
+
// is read exactly once into a local.
|
|
305
|
+
let rDone, v;
|
|
306
|
+
try {
|
|
307
|
+
rDone = result.done;
|
|
308
|
+
v = result.value;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
stop(err);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (rDone === true) {
|
|
301
314
|
stop(null);
|
|
302
315
|
return;
|
|
303
316
|
}
|
|
304
|
-
const v = result.value;
|
|
305
317
|
// Build the new state from lastState (never sig.peek), then
|
|
306
318
|
// commit to both lastState and sig. The try/catch around set()
|
|
307
319
|
// is defensive against a peer whose set() throws; on a throw we
|
|
@@ -433,6 +445,17 @@ function makeErrorState(prevState, err, mode) {
|
|
|
433
445
|
* Disposing the target signal does NOT stop the pump: call the returned
|
|
434
446
|
* stop fn or abort opts.signal. See ROADMAP LS-01.
|
|
435
447
|
*
|
|
448
|
+
* Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
|
|
449
|
+
* 1.1.0): `mode: "buffer"` (with REQUIRED `maxBuffer`) drives the target with a
|
|
450
|
+
* bounded newest-last snapshot window instead of the raw value, dropping oldest
|
|
451
|
+
* on overflow and counting the drops; `onValue(v)` is a per-value tap that fires
|
|
452
|
+
* once BEFORE each set (a throw routes through the same leg as a throwing
|
|
453
|
+
* transform -- stop + onError); `onAbort(reason)` receives aborts INSTEAD of
|
|
454
|
+
* onError when present (so consumers stop filtering intentional aborts out of
|
|
455
|
+
* their error handler). The returned stop fn carries `droppedCount` and
|
|
456
|
+
* `overflowCount` getters (aliases of the same overflow counter); it stays
|
|
457
|
+
* callable and idempotent exactly as 1.1.0.
|
|
458
|
+
*
|
|
436
459
|
* @template T
|
|
437
460
|
* @param {AsyncIterable<T> | AsyncIterator<T>} source
|
|
438
461
|
* @param {import("@zakkster/lite-signal").Signal<T>} target
|
|
@@ -440,9 +463,13 @@ function makeErrorState(prevState, err, mode) {
|
|
|
440
463
|
* signal?: AbortSignal,
|
|
441
464
|
* onError?: (err: unknown) => void,
|
|
442
465
|
* onDone?: () => void,
|
|
443
|
-
* transform?: (value: T) => T
|
|
466
|
+
* transform?: (value: T) => T,
|
|
467
|
+
* mode?: "latest" | "buffer",
|
|
468
|
+
* maxBuffer?: number,
|
|
469
|
+
* onValue?: (value: T) => void,
|
|
470
|
+
* onAbort?: (reason: unknown) => void
|
|
444
471
|
* }} [opts]
|
|
445
|
-
* @returns {() => void} stop fn (idempotent)
|
|
472
|
+
* @returns {(() => void) & { readonly droppedCount: number, readonly overflowCount: number }} stop fn (idempotent)
|
|
446
473
|
*/
|
|
447
474
|
function pipeToSignal(source, target, opts) {
|
|
448
475
|
if (target === null || target === undefined || typeof target.set !== "function") {
|
|
@@ -452,18 +479,68 @@ function pipeToSignal(source, target, opts) {
|
|
|
452
479
|
const onError = (opts !== undefined && opts !== null) ? opts.onError : undefined;
|
|
453
480
|
const onDone = (opts !== undefined && opts !== null) ? opts.onDone : undefined;
|
|
454
481
|
const transform = (opts !== undefined && opts !== null) ? opts.transform : undefined;
|
|
482
|
+
const mode = (opts !== undefined && opts !== null) ? (opts.mode || "latest") : "latest";
|
|
483
|
+
const maxBuffer = (opts !== undefined && opts !== null) ? opts.maxBuffer : undefined;
|
|
484
|
+
const onValue = (opts !== undefined && opts !== null) ? opts.onValue : undefined;
|
|
485
|
+
const onAbort = (opts !== undefined && opts !== null) ? opts.onAbort : undefined;
|
|
486
|
+
|
|
487
|
+
// Validate ONCE at construction. Unknown mode / latest+maxBuffer are
|
|
488
|
+
// programmer bugs surfaced synchronously; buffer mode requires a deliberate
|
|
489
|
+
// ceiling (unbounded buffering is a memory bug pretending to be a feature).
|
|
490
|
+
if (mode !== "latest" && mode !== "buffer") {
|
|
491
|
+
throw new TypeError(
|
|
492
|
+
"lite-stream: opts.mode must be \"latest\" or \"buffer\" (got " + JSON.stringify(mode) + ")"
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (mode === "latest" && maxBuffer !== undefined) {
|
|
496
|
+
throw new TypeError(
|
|
497
|
+
"lite-stream: opts.maxBuffer is not allowed with mode: \"latest\" -- latest-wins sets the raw value"
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (mode === "buffer") {
|
|
501
|
+
if (typeof maxBuffer !== "number" || !Number.isFinite(maxBuffer) || maxBuffer < 1 || (maxBuffer | 0) !== maxBuffer) {
|
|
502
|
+
throw new RangeError(
|
|
503
|
+
"lite-stream: \"buffer\" mode requires opts.maxBuffer to be a positive integer "
|
|
504
|
+
+ "(got " + JSON.stringify(maxBuffer) + "). Unbounded buffering is a memory "
|
|
505
|
+
+ "bug pretending to be a feature; pick a deliberate ceiling."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const isBuffer = mode === "buffer";
|
|
510
|
+
|
|
511
|
+
// Overflow counter. droppedCount and overflowCount are aliases (a drop is
|
|
512
|
+
// an overflow), matching toAsyncIterable's vocabulary. Both getters on the
|
|
513
|
+
// returned stop fn read this one variable; no per-value allocation.
|
|
514
|
+
let dropped = 0;
|
|
515
|
+
// Attach the observability getters to whatever stop fn we return. Closes
|
|
516
|
+
// over `dropped`; defined once at construction, never on the hot path.
|
|
517
|
+
const attachCounters = function (fn) {
|
|
518
|
+
Object.defineProperty(fn, "droppedCount", { get() { return dropped; }, enumerable: true, configurable: true });
|
|
519
|
+
Object.defineProperty(fn, "overflowCount", { get() { return dropped; }, enumerable: true, configurable: true });
|
|
520
|
+
return fn;
|
|
521
|
+
};
|
|
455
522
|
|
|
456
523
|
if (abortSig !== undefined && abortSig !== null && abortSig.aborted) {
|
|
457
|
-
|
|
458
|
-
|
|
524
|
+
const err = makeAbortError(abortSig);
|
|
525
|
+
if (onAbort !== undefined) {
|
|
526
|
+
try { onAbort(err); } catch (_e) {}
|
|
527
|
+
} else if (onError !== undefined) {
|
|
528
|
+
try { onError(err); } catch (_e) {}
|
|
459
529
|
}
|
|
460
|
-
return function noopStop() {};
|
|
530
|
+
return attachCounters(function noopStop() {});
|
|
461
531
|
}
|
|
462
532
|
|
|
463
533
|
const iter = toIterator(source);
|
|
464
534
|
let stopped = false;
|
|
465
535
|
let abortListener = null;
|
|
466
536
|
|
|
537
|
+
// Buffer-mode ring: fixed-size, head-pointer wrap, oldest dropped on
|
|
538
|
+
// overflow. Snapshot rebuilt newest-last per set (fromAsyncIterable pattern).
|
|
539
|
+
let ring = null;
|
|
540
|
+
let ringHead = 0;
|
|
541
|
+
let ringLen = 0;
|
|
542
|
+
if (isBuffer) ring = new Array(maxBuffer);
|
|
543
|
+
|
|
467
544
|
const stop = function () {
|
|
468
545
|
if (stopped) return;
|
|
469
546
|
stopped = true;
|
|
@@ -478,7 +555,10 @@ function pipeToSignal(source, target, opts) {
|
|
|
478
555
|
abortListener = function () {
|
|
479
556
|
const err = makeAbortError(abortSig);
|
|
480
557
|
stop();
|
|
481
|
-
|
|
558
|
+
// onAbort, when present, receives aborts INSTEAD of onError.
|
|
559
|
+
if (onAbort !== undefined) {
|
|
560
|
+
try { onAbort(err); } catch (_e) {}
|
|
561
|
+
} else if (onError !== undefined) {
|
|
482
562
|
try { onError(err); } catch (_e) {}
|
|
483
563
|
}
|
|
484
564
|
};
|
|
@@ -506,15 +586,52 @@ function pipeToSignal(source, target, opts) {
|
|
|
506
586
|
}
|
|
507
587
|
return;
|
|
508
588
|
}
|
|
509
|
-
|
|
589
|
+
// LS-13: a throwing done/value getter routes to the "iterator
|
|
590
|
+
// throws" leg (stop + onError). Wrap ONLY the two property reads
|
|
591
|
+
// -- widening this try over transform()/target.set() below would
|
|
592
|
+
// fold the transform-throw leg into this one. Each property is
|
|
593
|
+
// read exactly once into a local.
|
|
594
|
+
let rDone, rValue;
|
|
595
|
+
try {
|
|
596
|
+
rDone = result.done;
|
|
597
|
+
rValue = result.value;
|
|
598
|
+
} catch (err) {
|
|
599
|
+
stop();
|
|
600
|
+
if (onError !== undefined) {
|
|
601
|
+
try { onError(err); } catch (_e) {}
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (rDone === true) {
|
|
510
606
|
stop();
|
|
511
607
|
if (onDone !== undefined) {
|
|
512
608
|
try { onDone(); } catch (_e) {}
|
|
513
609
|
}
|
|
514
610
|
return;
|
|
515
611
|
}
|
|
612
|
+
// transform -> onValue tap -> set. A throw anywhere here routes
|
|
613
|
+
// to the same stop + onError leg (the 1.1.0 transform-throw
|
|
614
|
+
// precedent; see test/04 "transform throwing stops the pump").
|
|
516
615
|
try {
|
|
517
|
-
|
|
616
|
+
const v = transform === undefined ? rValue : transform(rValue);
|
|
617
|
+
if (onValue !== undefined) onValue(v);
|
|
618
|
+
if (isBuffer) {
|
|
619
|
+
if (ringLen < maxBuffer) {
|
|
620
|
+
ring[(ringHead + ringLen) % maxBuffer] = v;
|
|
621
|
+
ringLen = (ringLen + 1) | 0;
|
|
622
|
+
} else {
|
|
623
|
+
ring[ringHead] = v;
|
|
624
|
+
ringHead = (ringHead + 1) % maxBuffer;
|
|
625
|
+
dropped = (dropped + 1) | 0;
|
|
626
|
+
}
|
|
627
|
+
const snapshot = new Array(ringLen);
|
|
628
|
+
for (let i = 0; i < ringLen; i = (i + 1) | 0) {
|
|
629
|
+
snapshot[i] = ring[(ringHead + i) % maxBuffer];
|
|
630
|
+
}
|
|
631
|
+
target.set(snapshot);
|
|
632
|
+
} else {
|
|
633
|
+
target.set(v);
|
|
634
|
+
}
|
|
518
635
|
} catch (err) {
|
|
519
636
|
stop();
|
|
520
637
|
if (onError !== undefined) {
|
|
@@ -534,7 +651,7 @@ function pipeToSignal(source, target, opts) {
|
|
|
534
651
|
}
|
|
535
652
|
|
|
536
653
|
queueMicrotask(pump);
|
|
537
|
-
return stop;
|
|
654
|
+
return attachCounters(stop);
|
|
538
655
|
}
|
|
539
656
|
|
|
540
657
|
// ---------------------------------------------------------------------------
|
|
@@ -876,4 +993,4 @@ export {
|
|
|
876
993
|
|
|
877
994
|
// Always equals the installed package.json version (single source of truth is
|
|
878
995
|
// package.json; the /release drill bumps both sites in the same commit).
|
|
879
|
-
export const VERSION = "1.
|
|
996
|
+
export const VERSION = "1.3.0";
|
package/llms.txt
CHANGED
|
@@ -5,7 +5,7 @@ multi-shot dual of lite-await's `fromPromise`. Project async streams
|
|
|
5
5
|
(paginated APIs, SSE, network frames, pubsub topics) into signals with
|
|
6
6
|
bounded buffering and structural cleanup.
|
|
7
7
|
|
|
8
|
-
ESM-only. Node >= 18.
|
|
8
|
+
ESM-only. Node >= 18. Zero runtime dependencies. Single file `Stream.js`.
|
|
9
9
|
Peer dep: `@zakkster/lite-signal ^1.2.0`.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
@@ -49,22 +49,72 @@ Throws `TypeError` on null/undefined/wrong-shape source. Throws
|
|
|
49
49
|
|
|
50
50
|
### `pipeToSignal(source, target, opts?) -> stop`
|
|
51
51
|
|
|
52
|
-
Pump an existing writable signal from an async iterator.
|
|
53
|
-
value is replaced directly (no wrapper). Returns
|
|
52
|
+
Pump an existing writable signal from an async iterator. In the default
|
|
53
|
+
`"latest"` mode the signal's value is replaced directly (no wrapper). Returns
|
|
54
|
+
an idempotent stop fn that also carries `droppedCount` / `overflowCount`
|
|
55
|
+
getters.
|
|
54
56
|
|
|
55
57
|
```js
|
|
56
58
|
const stop = pipeToSignal(source, targetSig, {
|
|
57
59
|
signal: abortCtrl.signal,
|
|
60
|
+
mode: "latest" | "buffer", // default "latest"
|
|
61
|
+
maxBuffer: 64, // REQUIRED if mode = "buffer"
|
|
62
|
+
transform: (v) => transform(v), // per-value map, runs first
|
|
63
|
+
onValue: (v) => {}, // tap: fires ONCE per value BEFORE set
|
|
64
|
+
onAbort: (reason) => {}, // aborts route HERE instead of onError
|
|
58
65
|
onError: (e) => {},
|
|
59
|
-
onDone: () => {}
|
|
60
|
-
transform: (v) => transform(v)
|
|
66
|
+
onDone: () => {}
|
|
61
67
|
});
|
|
68
|
+
|
|
69
|
+
stop(); // idempotent -- calling again is a no-op
|
|
70
|
+
stop.droppedCount; // buffer-mode overflow count (0 in latest mode)
|
|
71
|
+
stop.overflowCount; // alias of droppedCount
|
|
62
72
|
```
|
|
63
73
|
|
|
74
|
+
Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
|
|
75
|
+
1.1.0):
|
|
76
|
+
|
|
77
|
+
- `mode: "buffer"` drives the target with a bounded newest-last snapshot array
|
|
78
|
+
(a fresh array per set) instead of the raw value. REQUIRES `maxBuffer` (a
|
|
79
|
+
positive integer; missing/invalid throws `RangeError` -- same
|
|
80
|
+
"unbounded buffering is a memory bug pretending to be a feature" voice as
|
|
81
|
+
`fromAsyncIterable`). Overflow drops the oldest and increments the stop fn's
|
|
82
|
+
`droppedCount`. `mode: "latest"` + `maxBuffer` throws `TypeError`; an unknown
|
|
83
|
+
mode throws `TypeError`. All validated ONCE at construction.
|
|
84
|
+
- `onValue(v)` is a per-value tap that fires exactly once per value BEFORE the
|
|
85
|
+
`set` (and after `transform`). A THROWING `onValue` routes through the same
|
|
86
|
+
leg as a throwing transform -- `stop()` then `onError` (precedent: test/04
|
|
87
|
+
"transform throwing stops the pump cleanly").
|
|
88
|
+
- `onAbort(reason)`, when present, receives aborts INSTEAD of `onError` (both at
|
|
89
|
+
the abort listener and the pre-aborted early return), so consumers stop
|
|
90
|
+
filtering intentional aborts out of their error handler. Absent -> 1.1.0
|
|
91
|
+
behavior byte-identical (aborts flow through `onError`). See the abort
|
|
92
|
+
vocabulary contract table below and `decisions/0001-abort-vocabulary.md`.
|
|
93
|
+
- `stop.droppedCount` and `stop.overflowCount` are getters ON the returned stop
|
|
94
|
+
fn (aliases of one overflow counter); the stop fn stays callable and
|
|
95
|
+
idempotent exactly as 1.1.0.
|
|
96
|
+
|
|
64
97
|
Does NOT dispose the target signal. The caller owns its lifetime. Disposing
|
|
65
98
|
the target signal does NOT stop the pump: call the returned stop fn or abort
|
|
66
99
|
opts.signal.
|
|
67
100
|
|
|
101
|
+
#### Abort vocabulary contract
|
|
102
|
+
|
|
103
|
+
The two directions frame an AbortSignal abort differently, by design. See
|
|
104
|
+
`decisions/0001-abort-vocabulary.md` (a repo-only doc, not shipped in the
|
|
105
|
+
tarball).
|
|
106
|
+
|
|
107
|
+
| Direction | On abort | Framing | Escape hatch |
|
|
108
|
+
| --- | --- | --- | --- |
|
|
109
|
+
| `fromAsyncIterable` (from-side) | interrupt production; `iter.return()` best-effort; terminal error state | error (`onError`, `state.error`) | -- |
|
|
110
|
+
| `pipeToSignal` (from-side) | interrupt production; `iter.return()` best-effort | error (`onError`) by default | `onAbort` routes aborts away from `onError` |
|
|
111
|
+
| `toAsyncIterable` (to-side) | stop listening; pending `next()` resolves `{ done: true }` | graceful done | already graceful; nothing to filter |
|
|
112
|
+
|
|
113
|
+
Aborting a producer is an interruption of work in flight (error framing);
|
|
114
|
+
aborting a consumer is a subscriber leaving (graceful done). `onAbort` is added
|
|
115
|
+
to `pipeToSignal` ONLY -- the sole direction that carried a shipped consumer
|
|
116
|
+
workaround.
|
|
117
|
+
|
|
68
118
|
### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount, overflowCount }`
|
|
69
119
|
|
|
70
120
|
Reverse direction: yield signal changes as an async iterable.
|
|
@@ -131,7 +181,14 @@ every test run.
|
|
|
131
181
|
`fromAsyncIterable` ends on exactly three paths, all structural:
|
|
132
182
|
|
|
133
183
|
1. Iterator natural completion (`{ done: true }`) -> done state, onDone fires.
|
|
134
|
-
2. Iterator throws -> error state, onError fires.
|
|
184
|
+
2. Iterator throws -> error state, onError fires. As of 1.3.0 (LS-13) this leg
|
|
185
|
+
also covers a `next()` result whose `done` or `value` is a THROWING getter:
|
|
186
|
+
the thrown value routes to the error state with onError fired exactly once,
|
|
187
|
+
instead of escaping as an unhandledRejection with the signal never settling.
|
|
188
|
+
`pipeToSignal` gets the same fix (stop + onError). Both pumps read the two
|
|
189
|
+
result properties once, inside a dedicated try/catch that routes to the
|
|
190
|
+
existing "iterator throws" leg; the peer-defense catch around `set()` (LS-01)
|
|
191
|
+
is separate and untouched.
|
|
135
192
|
3. AbortSignal aborts -> `iter.return()` called, error state, onError fires.
|
|
136
193
|
|
|
137
194
|
The abort listener is always removed on any of the three paths -- no
|
|
@@ -288,7 +345,7 @@ is caught up; `mode: "buffer"` also zero when no waiter is pending
|
|
|
288
345
|
|
|
289
346
|
## Files
|
|
290
347
|
|
|
291
|
-
- `Stream.js` -- single-file ESM implementation (~
|
|
348
|
+
- `Stream.js` -- single-file ESM implementation (~995 lines)
|
|
292
349
|
- `Stream.d.ts` -- TypeScript types with discriminated state union
|
|
293
350
|
- `README.md` -- full docs with integration recipes
|
|
294
351
|
- `llms.txt` -- this file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-stream",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test:gc": "node --expose-gc --test --test-reporter=spec test/*.test.mjs",
|
|
30
30
|
"bench": "node --expose-gc bench/bench.mjs",
|
|
31
31
|
"torture": "node --expose-gc test/torture.mjs",
|
|
32
|
-
"torture:control": "for t in s0 s1 s2 s3 s4 s5 s6 s7 s8; do if STREAM_TORTURE_BREAK=$t node --expose-gc test/torture.mjs >/dev/null 2>&1; then echo \"control $t FAILED-TO-FAIL\"; exit 1; else echo \"control $t failed-as-expected\"; fi; done",
|
|
32
|
+
"torture:control": "for t in s0 s1 s2 s3 s4 s5 s6 s7 s7e s8; do if STREAM_TORTURE_BREAK=$t node --expose-gc test/torture.mjs >/dev/null 2>&1; then echo \"control $t FAILED-TO-FAIL\"; exit 1; else echo \"control $t failed-as-expected\"; fi; done",
|
|
33
33
|
"test:floor": "echo '== floor lane: pin @zakkster/lite-signal@1.2.2 (torture NOT run here -- lite-leak peers lite-signal>=1.5.0, LS-09 amendment) ==' && npm i --no-save --no-package-lock @zakkster/lite-signal@1.2.2 --legacy-peer-deps && npm test && echo 'FLOOR-PASS: npm test green @ lite-signal@1.2.2' && echo '== latest lane: restore @zakkster/lite-signal@^1.2.0 ==' && npm i --no-save --no-package-lock @zakkster/lite-signal@^1.2.0 && npm test && npm run torture && echo 'LATEST-PASS: npm test + torture green @ lite-signal latest'",
|
|
34
34
|
"verify": "npm test && npm run test:gc && npm run bench && npm run torture"
|
|
35
35
|
},
|