@zakkster/lite-stream 1.0.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/llms.txt ADDED
@@ -0,0 +1,212 @@
1
+ # @zakkster/lite-stream
2
+
3
+ Zero-GC bridge between async iterators and `@zakkster/lite-signal`. The
4
+ multi-shot dual of lite-await's `fromPromise`. Project async streams
5
+ (paginated APIs, SSE, network frames, pubsub topics) into signals with
6
+ bounded buffering and structural cleanup.
7
+
8
+ ESM-only. Node >= 18. ~5 KB minified. Single file `Stream.js`.
9
+ Peer dep: `@zakkster/lite-signal ^1.2.0`.
10
+
11
+ ## Install
12
+
13
+ npm i @zakkster/lite-stream @zakkster/lite-signal
14
+
15
+ ## Imports
16
+
17
+ import { fromAsyncIterable, pipeToSignal, toAsyncIterable } from "@zakkster/lite-stream";
18
+
19
+ ## Exports
20
+
21
+ ### `fromAsyncIterable(source, opts?) -> Signal<State<T>>`
22
+
23
+ Drive a signal from an async iterator. Returns a Signal whose value is a
24
+ tagged state object reflecting the iterator's lifecycle.
25
+
26
+ ```js
27
+ const live = fromAsyncIterable(asyncSource, {
28
+ mode: "latest" | "buffer", // default "latest"
29
+ maxBuffer: 1024, // REQUIRED if mode = "buffer"
30
+ initial: undefined, // initial signal value
31
+ signal: abortCtrl.signal, // abort to stop
32
+ onError: (e) => {},
33
+ onDone: () => {}
34
+ });
35
+ ```
36
+
37
+ State shape varies by mode:
38
+ - `"latest"`: `{ value, count, done, error }`. Signal value is the most
39
+ recent yielded item. Cheapest path.
40
+ - `"buffer"`: `{ values, count, droppedCount, done, error }`. `values` is a
41
+ bounded array (newest last). REQUIRES `maxBuffer`. Overflow drops oldest
42
+ and increments `droppedCount`.
43
+
44
+ Accepts `AsyncIterable<T>`, `AsyncIterator<T>` (just `.next`), or sync
45
+ `Iterable<T>` as a convenience.
46
+
47
+ Throws `TypeError` on null/undefined/wrong-shape source. Throws
48
+ `RangeError` on missing/invalid `maxBuffer` in buffer mode.
49
+
50
+ ### `pipeToSignal(source, target, opts?) -> stop`
51
+
52
+ Pump an existing writable signal from an async iterator. The signal's
53
+ value is replaced directly (no wrapper). Returns an idempotent stop fn.
54
+
55
+ ```js
56
+ const stop = pipeToSignal(source, targetSig, {
57
+ signal: abortCtrl.signal,
58
+ onError: (e) => {},
59
+ onDone: () => {},
60
+ transform: (v) => transform(v)
61
+ });
62
+ ```
63
+
64
+ Does NOT dispose the target signal. The caller owns its lifetime.
65
+
66
+ ### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount }`
67
+
68
+ Reverse direction: yield signal changes as an async iterable. Bounded
69
+ internal queue (default 1024) with drop-oldest overflow.
70
+
71
+ ```js
72
+ for await (const v of toAsyncIterable(sig, {
73
+ signal: abortCtrl.signal,
74
+ emitInitial: true, // default true
75
+ maxBuffer: 1024 // default 1024
76
+ })) {
77
+ // ...
78
+ }
79
+ ```
80
+
81
+ The iterator naturally completes when `opts.signal` aborts. Consumer-side
82
+ `break` triggers `iterator.return()` and cleans up the subscription.
83
+
84
+ ## Cleanup termination triplet
85
+
86
+ `fromAsyncIterable` ends on exactly three paths, all structural:
87
+
88
+ 1. Iterator natural completion (`{ done: true }`) -> done state, onDone fires.
89
+ 2. Iterator throws -> error state, onError fires.
90
+ 3. AbortSignal aborts -> `iter.return()` called, error state, onError fires.
91
+
92
+ The abort listener is always removed on any of the three paths -- no
93
+ AbortSignal accumulates dangling listeners.
94
+
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).
98
+
99
+ ## Key invariants
100
+
101
+ - **Buffer mode REQUIRES `maxBuffer`.** No default. Missing throws
102
+ RangeError with "unbounded buffering is a memory bug pretending to be a
103
+ feature". Pick a deliberate ceiling.
104
+ - **Drop-oldest on overflow.** Newer values win; `droppedCount` tracks
105
+ the loss. Observable via `live().droppedCount` for buffer mode and
106
+ `iterable.droppedCount` for toAsyncIterable.
107
+ - **Snapshot arrays are fresh per yield in buffer mode.** Past observers
108
+ hold stable, non-mutated array references.
109
+ - **First pull is microtask-deferred.** The caller's `effect()` can wire
110
+ up before the first yield arrives.
111
+ - **Pre-aborted AbortSignal short-circuits.** The iterator is never started.
112
+ - **Sync `Iterable` sources accepted.** Pulls happen async even for sync
113
+ sources, preserving wire-up semantics.
114
+
115
+ ## Modes -- when to pick which
116
+
117
+ `"latest"`:
118
+ - Live cursor positions
119
+ - Current pubsub message
120
+ - Latest network frame
121
+ - Current SSE event
122
+ - Anything where intermediate values can be safely discarded
123
+
124
+ `"buffer"`:
125
+ - Helix paginated results (each page must be processed)
126
+ - EBS SSE event log (must not miss events)
127
+ - Replay queue (every frame applies in order)
128
+ - Anything where every value matters
129
+
130
+ ## Integration recipes
131
+
132
+ ### Helix pagination
133
+
134
+ ```js
135
+ async function* helixPages(url, signal) {
136
+ let cursor = "";
137
+ while (true) {
138
+ const res = await fetch(url + "?after=" + cursor, { signal });
139
+ const { data, pagination } = await res.json();
140
+ yield data;
141
+ if (!pagination?.cursor) return;
142
+ cursor = pagination.cursor;
143
+ }
144
+ }
145
+
146
+ const pages = fromAsyncIterable(helixPages(url, ctrl.signal), {
147
+ mode: "buffer", maxBuffer: 100, signal: ctrl.signal
148
+ });
149
+ ```
150
+
151
+ ### EBS server-sent events
152
+
153
+ ```js
154
+ async function* sseEvents(url, signal) {
155
+ const res = await fetch(url, { signal });
156
+ const reader = res.body.getReader();
157
+ // ... parse SSE frames ...
158
+ while (true) { ...; yield parsedFrame; }
159
+ }
160
+
161
+ const events = fromAsyncIterable(sseEvents(url, ctrl.signal), {
162
+ mode: "latest", signal: ctrl.signal
163
+ });
164
+ ```
165
+
166
+ ### lite-statechart entry action with ctx.signal
167
+
168
+ ```js
169
+ states: {
170
+ fetching: {
171
+ entry: async (payload, ctx) => {
172
+ const live = fromAsyncIterable(somePager(), { signal: ctx.signal });
173
+ // ctx.signal aborts when machine leaves `fetching`
174
+ // -> fromAsyncIterable's signal aborts
175
+ // -> iter.return() called
176
+ // -> no leaks
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ ## What this is NOT
183
+
184
+ - Not a full reactive stream library (no map/filter/merge combinators).
185
+ Compose iterators yourself with async generators.
186
+ - Not a backpressure protocol. Async iterators in JS have no producer-side
187
+ flow control; buffer mode just bounds in-flight values.
188
+ - Not a replacement for lite-clock for frame-rate loops.
189
+ - Not for one-shot promises -- use lite-await's `fromPromise`.
190
+
191
+ ## Performance
192
+
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.
196
+
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.
199
+
200
+ ## Files
201
+
202
+ - `Stream.js` -- single-file ESM implementation (~600 lines)
203
+ - `Stream.d.ts` -- TypeScript types with discriminated state union
204
+ - `README.md` -- full docs with integration recipes
205
+ - `llms.txt` -- this file
206
+ - `CHANGELOG.md` -- version history
207
+ - `ROADMAP.md` -- deferred features
208
+
209
+ ## Author
210
+
211
+ Zahary Shinikchiev <shinikchiev@yahoo.com>
212
+ MIT License
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
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.",
5
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./Stream.js",
9
+ "module": "./Stream.js",
10
+ "types": "./Stream.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "node": "./Stream.js",
14
+ "import": "./Stream.js",
15
+ "types": "./Stream.d.ts",
16
+ "default": "./Stream.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "Stream.js",
21
+ "Stream.d.ts",
22
+ "README.md",
23
+ "llms.txt",
24
+ "LICENSE.txt",
25
+ "CHANGELOG.md",
26
+ "ROADMAP.md"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test --test-reporter=spec",
30
+ "test:gc": "node --expose-gc --test --test-reporter=spec",
31
+ "bench": "node --expose-gc bench/bench.mjs",
32
+ "verify": "npm test && npm run test:gc && npm run bench"
33
+ },
34
+ "keywords": [
35
+ "stream",
36
+ "async-iterator",
37
+ "signal",
38
+ "signals",
39
+ "reactive",
40
+ "abortcontroller",
41
+ "abortsignal",
42
+ "backpressure",
43
+ "ring-buffer",
44
+ "zero-gc",
45
+ "esm",
46
+ "no-dependencies"
47
+ ],
48
+ "homepage": "https://github.com/PeshoVurtoleta/lite-stream#readme",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/PeshoVurtoleta/lite-stream.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/PeshoVurtoleta/lite-stream/issues",
55
+ "email": "shinikchiev@yahoo.com"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "funding": {
61
+ "type": "github",
62
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
63
+ },
64
+ "sideEffects": false,
65
+ "peerDependencies": {
66
+ "@zakkster/lite-signal": "^1.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "@zakkster/lite-signal": "^1.2.2"
70
+ }
71
+ }