@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/ROADMAP.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @zakkster/lite-stream Roadmap
|
|
2
|
+
|
|
3
|
+
Features deliberately deferred from 1.0 with the rationale. Each is gated
|
|
4
|
+
on a specific triggering signal from a real consumer rather than
|
|
5
|
+
speculative inclusion.
|
|
6
|
+
|
|
7
|
+
## Triggering signals
|
|
8
|
+
|
|
9
|
+
This roadmap exists because of the same discipline that drove
|
|
10
|
+
`lite-signal`'s and `lite-statechart`'s development: ship the minimum that
|
|
11
|
+
solves the actual problem, defer everything else until a concrete consumer
|
|
12
|
+
demonstrates the need with a real code site. Nothing in this file is
|
|
13
|
+
"planned for 1.x" -- everything is "considered, deferred, waiting for a
|
|
14
|
+
triggering signal."
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1.1.0 candidates
|
|
19
|
+
|
|
20
|
+
### Concurrent mapping (`mapLimit`)
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
const live = fromAsyncIterable(urls, {
|
|
24
|
+
transform: async (url, ctx) => {
|
|
25
|
+
return await fetch(url, { signal: ctx.signal }).then((r) => r.json());
|
|
26
|
+
},
|
|
27
|
+
concurrency: 4 // up to 4 in-flight transforms
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A consumer-side pattern where each yielded value needs an async
|
|
32
|
+
transformation, and the user wants bounded concurrency. Currently doable
|
|
33
|
+
by composing async generators externally, but if multiple consumers (helix
|
|
34
|
+
batch enrichment, EBS request fan-out) end up writing the same pattern,
|
|
35
|
+
add it.
|
|
36
|
+
|
|
37
|
+
**Triggering signal:** Two or more consumers in the ecosystem writing
|
|
38
|
+
identical "transform-with-concurrency-limit" wrappers around
|
|
39
|
+
`fromAsyncIterable`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Reverse-direction buffering modes
|
|
44
|
+
|
|
45
|
+
`toAsyncIterable` currently only does drop-oldest on overflow. Some
|
|
46
|
+
consumers might want:
|
|
47
|
+
|
|
48
|
+
- `overflow: "drop-newest"` -- reject incoming when full
|
|
49
|
+
- `overflow: "throw"` -- iterator throws on overflow
|
|
50
|
+
- `overflow: "block"` -- producer-side signal back-channel (requires
|
|
51
|
+
consumer cooperation; significant API change)
|
|
52
|
+
|
|
53
|
+
**Why deferred from 1.0:** Drop-oldest is the only mode any real consumer
|
|
54
|
+
in the roadmap actually wants. Adding alternatives speculatively grows the
|
|
55
|
+
API surface for no measurable benefit.
|
|
56
|
+
|
|
57
|
+
**Triggering signal:** A consumer demonstrating that drop-oldest causes
|
|
58
|
+
observable correctness issues for their use case.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### `mergeIterables(iterA, iterB, ...)`
|
|
63
|
+
|
|
64
|
+
Merge multiple async iterators into one signal, interleaving values as
|
|
65
|
+
they arrive. Useful for "watch both helix and pubsub for follower
|
|
66
|
+
changes" patterns.
|
|
67
|
+
|
|
68
|
+
**Why deferred from 1.0:** Doable today by composing iterators externally
|
|
69
|
+
(an async generator that races multiple sources). Built-in support cleans
|
|
70
|
+
up the cancellation story (one AbortSignal cancels all sources) but adds
|
|
71
|
+
complexity.
|
|
72
|
+
|
|
73
|
+
**Triggering signal:** First Twitch consumer that needs to merge >= 2
|
|
74
|
+
async sources into a single reactive surface with shared cancellation.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### `splitSignal(sig, predicate) -> [matchingSig, restSig]`
|
|
79
|
+
|
|
80
|
+
The dual of `mergeIterables`: split a signal's value stream into two based
|
|
81
|
+
on a predicate, exposing both halves as new signals.
|
|
82
|
+
|
|
83
|
+
**Why deferred from 1.0:** No concrete consumer in the roadmap needs this.
|
|
84
|
+
Speculative.
|
|
85
|
+
|
|
86
|
+
**Triggering signal:** A consumer demonstrating a real need to fan-out a
|
|
87
|
+
single async source into two reactive surfaces.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 1.2.0+ candidates (smaller, lower priority)
|
|
92
|
+
|
|
93
|
+
- **`fromEventTarget(target, eventName)`** -- a wrapper that turns DOM
|
|
94
|
+
EventTarget events into an async iterable feeding a signal. Doable
|
|
95
|
+
today via async generators, but ergonomically common. Add if the
|
|
96
|
+
pattern recurs across browser-facing consumers (e.g. window resize
|
|
97
|
+
observers, pointer event streams).
|
|
98
|
+
|
|
99
|
+
- **`backpressure: "block"` for pipeToSignal** -- await the target
|
|
100
|
+
signal's effects before pulling the next value. Requires effect-
|
|
101
|
+
completion semantics from lite-signal, which don't exist today.
|
|
102
|
+
|
|
103
|
+
- **`opts.equals` for diff-mode signals** -- by default the underlying
|
|
104
|
+
signal uses lite-signal's default `Object.is`. Allow opt-in to a custom
|
|
105
|
+
equality predicate for buffer-mode snapshot arrays (where Object.is
|
|
106
|
+
always reports them as distinct).
|
|
107
|
+
|
|
108
|
+
- **Diagnostic surface** -- `live.stats() -> { yields, drops, errors,
|
|
109
|
+
startedAt, settledAt }` for telemetry. Useful but no consumer asking
|
|
110
|
+
for it yet.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Deferred indefinitely
|
|
115
|
+
|
|
116
|
+
### Full reactive stream combinators
|
|
117
|
+
|
|
118
|
+
`map`, `filter`, `take`, `skip`, `debounce`, `throttle`, etc. -- the
|
|
119
|
+
ReactiveX / RxJS surface area.
|
|
120
|
+
|
|
121
|
+
**Why:** Once you have async iterators, all of these are five-line async
|
|
122
|
+
generators. Adding them to lite-stream would create two ways to do the
|
|
123
|
+
same thing, where the second way (combinator API) hides the source code
|
|
124
|
+
and makes the cancellation model harder to reason about. The async
|
|
125
|
+
generator form is more inspectable and composes naturally with all of
|
|
126
|
+
JS's existing async tooling.
|
|
127
|
+
|
|
128
|
+
If a consumer wants `debounce`, they write:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
async function* debounce(source, ms, signal) {
|
|
132
|
+
let pending;
|
|
133
|
+
for await (const v of source) {
|
|
134
|
+
if (pending) clearTimeout(pending);
|
|
135
|
+
pending = await new Promise((r) => {
|
|
136
|
+
setTimeout(() => r(v), ms);
|
|
137
|
+
signal?.addEventListener("abort", () => r(undefined));
|
|
138
|
+
});
|
|
139
|
+
if (pending !== undefined) yield pending;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Not as pretty as `source.pipe(debounce(500))`, but inspectable, debuggable,
|
|
145
|
+
and composes with everything else in the JS async ecosystem.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### `propagate: "always"` mode
|
|
150
|
+
|
|
151
|
+
A mode where every yielded value triggers an effect run even if
|
|
152
|
+
`Object.is`-equal to the previous. Currently the underlying signal's
|
|
153
|
+
default equality (`Object.is`) is sufficient for all known consumers --
|
|
154
|
+
our wrapper state object is fresh per yield, so dedup never kicks in.
|
|
155
|
+
|
|
156
|
+
**Why deferred:** No consumer wants force-propagate semantics in
|
|
157
|
+
practice; the wrapper state shape already guarantees per-yield
|
|
158
|
+
propagation.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Non-goals
|
|
163
|
+
|
|
164
|
+
These will never ship in `lite-stream`:
|
|
165
|
+
|
|
166
|
+
- **A standalone scheduler.** Microtask / setImmediate decisions stay
|
|
167
|
+
inside the pump. The library does not expose a scheduling API.
|
|
168
|
+
- **A retry/backoff policy.** Consumers compose with `lite-await`'s
|
|
169
|
+
`withTimeout`, `withAbort`, and a plain `for` loop. Every Twitch SDK
|
|
170
|
+
endpoint has different retry semantics; a single policy would be
|
|
171
|
+
opinionated wrong for half of them.
|
|
172
|
+
- **WebSocket / EventSource adapters.** Those belong in the SDK that
|
|
173
|
+
consumes them (lite-twitch-ebs, lite-twitch-pubsub), not in lite-stream.
|
|
174
|
+
- **Concurrent multi-iterator orchestration beyond `mergeIterables`** --
|
|
175
|
+
use `lite-await`'s `allOf` / `anyOf` / `raceOf` for that.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Versioning policy
|
|
180
|
+
|
|
181
|
+
`@zakkster/lite-stream` follows strict semver. 1.x is a stable API; only
|
|
182
|
+
additive changes (new exports, new opts fields, new state-shape fields)
|
|
183
|
+
ship in minor releases. Breaking changes wait for 2.0. Patch releases are
|
|
184
|
+
bug fixes only.
|
|
185
|
+
|
|
186
|
+
Each addition will land with:
|
|
187
|
+
|
|
188
|
+
1. A concrete triggering consumer in the `@zakkster/lite-*` ecosystem
|
|
189
|
+
2. Tests covering the full lifecycle (happy + all three cleanup paths)
|
|
190
|
+
3. A bench measuring throughput + retained heap
|
|
191
|
+
4. A line item in CHANGELOG.md with a hot-path-cost note
|
package/Stream.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-stream -- zero-GC bridge between async iterators and
|
|
3
|
+
* `@zakkster/lite-signal`.
|
|
4
|
+
*
|
|
5
|
+
* Public type surface for the JavaScript implementation in `Stream.js`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Signal, Computed } from "@zakkster/lite-signal";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// State shapes (discriminated by mode)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-yield state for `fromAsyncIterable` in "latest" mode. The signal value
|
|
16
|
+
* is the most recent yielded item; on completion or error, `done: true` is
|
|
17
|
+
* set and `value` is preserved.
|
|
18
|
+
*/
|
|
19
|
+
export interface LatestState<T> {
|
|
20
|
+
readonly value: T | undefined;
|
|
21
|
+
readonly count: number;
|
|
22
|
+
readonly done: boolean;
|
|
23
|
+
readonly error: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-yield state for `fromAsyncIterable` in "buffer" mode. The signal value
|
|
28
|
+
* is a bounded array of recent yields (newest last). On overflow, the OLDEST
|
|
29
|
+
* value is dropped and `droppedCount` is incremented. Snapshot is a fresh
|
|
30
|
+
* array per update; the user-facing array is never mutated in place.
|
|
31
|
+
*/
|
|
32
|
+
export interface BufferState<T> {
|
|
33
|
+
readonly values: ReadonlyArray<T>;
|
|
34
|
+
readonly count: number;
|
|
35
|
+
readonly droppedCount: number;
|
|
36
|
+
readonly done: boolean;
|
|
37
|
+
readonly error: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Options
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export interface FromAsyncIterableLatestOptions<T> {
|
|
45
|
+
mode?: "latest";
|
|
46
|
+
initial?: T;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
onError?: (err: unknown) => void;
|
|
49
|
+
onDone?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FromAsyncIterableBufferOptions<T> {
|
|
53
|
+
mode: "buffer";
|
|
54
|
+
/**
|
|
55
|
+
* Required when mode === "buffer". Positive integer. Unbounded buffering
|
|
56
|
+
* is explicitly rejected as a memory bug pretending to be a feature; pick
|
|
57
|
+
* a deliberate ceiling and observe `droppedCount` to know when you're
|
|
58
|
+
* losing data.
|
|
59
|
+
*/
|
|
60
|
+
maxBuffer: number;
|
|
61
|
+
initial?: T;
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
onError?: (err: unknown) => void;
|
|
64
|
+
onDone?: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PipeToSignalOptions<T> {
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
onError?: (err: unknown) => void;
|
|
70
|
+
onDone?: () => void;
|
|
71
|
+
transform?: (value: T) => T;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ToAsyncIterableOptions {
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
/** Default `true`: yield the current signal value first. */
|
|
77
|
+
emitInitial?: boolean;
|
|
78
|
+
/** Default `1024`. Positive integer. */
|
|
79
|
+
maxBuffer?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Public functions
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drive a signal from an async iterator. Returns a Signal whose value is a
|
|
88
|
+
* tagged state object reflecting the iterator's lifecycle.
|
|
89
|
+
*
|
|
90
|
+
* **Termination** (cleanup is structural on all three paths):
|
|
91
|
+
* 1. Iterator natural completion (`{ done: true }`) -> signal state goes done.
|
|
92
|
+
* 2. Iterator throws -> signal state goes error.
|
|
93
|
+
* 3. `opts.signal` aborts -> the iterator's `return()` is called best-effort.
|
|
94
|
+
*
|
|
95
|
+
* **Modes:**
|
|
96
|
+
* - `"latest"` (default): signal value is `{ value, count, done, error }`.
|
|
97
|
+
* Cheapest path; allocates one wrapper object per yield.
|
|
98
|
+
* - `"buffer"`: signal value is `{ values, count, droppedCount, done, error }`.
|
|
99
|
+
* `maxBuffer` REQUIRED. Overflow drops oldest and increments `droppedCount`.
|
|
100
|
+
*/
|
|
101
|
+
export function fromAsyncIterable<T>(
|
|
102
|
+
source: AsyncIterable<T> | AsyncIterator<T> | Iterable<T>,
|
|
103
|
+
opts?: FromAsyncIterableLatestOptions<T>
|
|
104
|
+
): Signal<LatestState<T>>;
|
|
105
|
+
export function fromAsyncIterable<T>(
|
|
106
|
+
source: AsyncIterable<T> | AsyncIterator<T> | Iterable<T>,
|
|
107
|
+
opts: FromAsyncIterableBufferOptions<T>
|
|
108
|
+
): Signal<BufferState<T>>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Lower-level companion to `fromAsyncIterable`: pump an existing writable
|
|
112
|
+
* signal from an async iterator. The signal's value is replaced directly
|
|
113
|
+
* with each yielded value (no wrapper state). Returns a stop function that
|
|
114
|
+
* ends the pump and calls `iterator.return()`.
|
|
115
|
+
*
|
|
116
|
+
* Does NOT dispose the target signal; the caller owns its lifetime.
|
|
117
|
+
*/
|
|
118
|
+
export function pipeToSignal<T>(
|
|
119
|
+
source: AsyncIterable<T> | AsyncIterator<T>,
|
|
120
|
+
target: Signal<T>,
|
|
121
|
+
opts?: PipeToSignalOptions<T>
|
|
122
|
+
): () => void;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Yield signal changes as an async iterable. Each change resolves a pending
|
|
126
|
+
* `next()`. If the consumer is slower than the producer, values queue in a
|
|
127
|
+
* bounded ring (default size 1024); on overflow, OLDEST is dropped and
|
|
128
|
+
* `droppedCount` increments. Inspect via the iterable's `.droppedCount`
|
|
129
|
+
* property at any time during or after consumption.
|
|
130
|
+
*
|
|
131
|
+
* The iterator naturally completes when `opts.signal` aborts; consumer-side
|
|
132
|
+
* `break` triggers `iterator.return()` and cleans up the subscription.
|
|
133
|
+
*/
|
|
134
|
+
export function toAsyncIterable<T>(
|
|
135
|
+
sig: Signal<T> | Computed<T>,
|
|
136
|
+
opts?: ToAsyncIterableOptions
|
|
137
|
+
): AsyncIterable<T> & { readonly droppedCount: number };
|