@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/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 };