@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/CHANGELOG.md +85 -0
- package/LICENSE.txt +21 -0
- package/README.md +521 -0
- package/ROADMAP.md +191 -0
- package/Stream.d.ts +137 -0
- package/Stream.js +639 -0
- package/llms.txt +212 -0
- package/package.json +71 -0
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
|
+
}
|