@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/Stream.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// @zakkster/lite-stream 1.0.0
|
|
2
|
+
//
|
|
3
|
+
// Zero-GC bridge between async iterators and @zakkster/lite-signal. The
|
|
4
|
+
// multi-shot dual of lite-await's fromPromise: project an async source of N
|
|
5
|
+
// values (paginated APIs, SSE streams, network frame queues, pubsub topics)
|
|
6
|
+
// into a signal-shaped reactive surface.
|
|
7
|
+
//
|
|
8
|
+
// Three cleanup paths, all structural:
|
|
9
|
+
// 1. Iterator natural completion ({done: true}) -> signal state goes done
|
|
10
|
+
// 2. Iterator throws -> signal state goes error
|
|
11
|
+
// 3. Caller-provided AbortSignal aborts -> iterator.return() called
|
|
12
|
+
//
|
|
13
|
+
// Two modes:
|
|
14
|
+
// - "latest" (default): signal value = most recent yielded value. Cheap;
|
|
15
|
+
// consumer reads sig().value. Best for "show the current frame / latest
|
|
16
|
+
// pubsub message / live cursor position".
|
|
17
|
+
// - "buffer": signal value = bounded ring of recent values, newest
|
|
18
|
+
// last. REQUIRES maxBuffer (no unbounded buffering -- that is a memory
|
|
19
|
+
// bug pretending to be a feature). Overflow drops oldest and increments
|
|
20
|
+
// droppedCount. Best for "process every helix page in order, don't miss
|
|
21
|
+
// events".
|
|
22
|
+
//
|
|
23
|
+
// Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
24
|
+
// MIT License
|
|
25
|
+
|
|
26
|
+
import { signal as _signal } from "@zakkster/lite-signal";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Internal helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function makeAbortError(signal) {
|
|
33
|
+
if (signal !== undefined && signal !== null && signal.reason !== undefined) {
|
|
34
|
+
return signal.reason;
|
|
35
|
+
}
|
|
36
|
+
if (typeof DOMException !== "undefined") {
|
|
37
|
+
return new DOMException("Aborted", "AbortError");
|
|
38
|
+
}
|
|
39
|
+
const err = new Error("Aborted");
|
|
40
|
+
err.name = "AbortError";
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Obtain an async iterator from an async iterable. Accepts both forms
|
|
45
|
+
// (iterator-already-iterator, or iterable-needing-Symbol.asyncIterator).
|
|
46
|
+
function toIterator(source) {
|
|
47
|
+
if (source === null || source === undefined) {
|
|
48
|
+
throw new TypeError("lite-stream: source must be an async iterable or iterator");
|
|
49
|
+
}
|
|
50
|
+
// Already an iterator (has next())
|
|
51
|
+
if (typeof source.next === "function") return source;
|
|
52
|
+
// Async iterable (has Symbol.asyncIterator)
|
|
53
|
+
if (typeof source[Symbol.asyncIterator] === "function") {
|
|
54
|
+
return source[Symbol.asyncIterator]();
|
|
55
|
+
}
|
|
56
|
+
// Sync iterable (has Symbol.iterator) -- accepted as a convenience.
|
|
57
|
+
if (typeof source[Symbol.iterator] === "function") {
|
|
58
|
+
return source[Symbol.iterator]();
|
|
59
|
+
}
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
"lite-stream: source must be an async iterable, async iterator, or iterable"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Best-effort iterator close. Async iterators may expose `return()` to signal
|
|
66
|
+
// "no more values will be pulled"; calling it releases resources held by the
|
|
67
|
+
// generator. We swallow any error -- the consumer already moved on.
|
|
68
|
+
function closeIterator(iter) {
|
|
69
|
+
if (iter !== null && typeof iter.return === "function") {
|
|
70
|
+
try {
|
|
71
|
+
const ret = iter.return();
|
|
72
|
+
// `return()` returns a Promise. We don't await it; we just need
|
|
73
|
+
// to give the generator a chance to clean up. Attach a noop catch
|
|
74
|
+
// so an unhandled rejection doesn't blow up the process.
|
|
75
|
+
if (ret !== undefined && typeof ret.then === "function") {
|
|
76
|
+
ret.then(undefined, () => {});
|
|
77
|
+
}
|
|
78
|
+
} catch (_e) { /* swallowed: cleanup is best-effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// State shapes (per-mode)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
//
|
|
86
|
+
// "latest" mode signal value:
|
|
87
|
+
// { value, count, done, error }
|
|
88
|
+
//
|
|
89
|
+
// "buffer" mode signal value:
|
|
90
|
+
// { values, count, droppedCount, done, error }
|
|
91
|
+
//
|
|
92
|
+
// We allocate ONE state object per signal lifecycle and mutate-in-place via
|
|
93
|
+
// `signal.set(state)`. But lite-signal compares with Object.is; mutating the
|
|
94
|
+
// same object would cause subsequent set()s to be no-ops. So we shallow-clone
|
|
95
|
+
// on every update. That clone is the only per-yield JS-heap allocation in the
|
|
96
|
+
// hot path (besides the iterator's own intrinsic cost).
|
|
97
|
+
//
|
|
98
|
+
// For "buffer" mode, the underlying `values` array is the user-facing snapshot;
|
|
99
|
+
// we allocate a fresh array on overflow and on each set to avoid in-place
|
|
100
|
+
// mutation that would be visible to past observers. The ring buffer beneath
|
|
101
|
+
// the snapshot is a fixed-size Array that we cycle through.
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// fromAsyncIterable
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drive a signal from an async iterator. Returns a Signal whose value is a
|
|
109
|
+
* tagged state object reflecting the iterator's lifecycle.
|
|
110
|
+
*
|
|
111
|
+
* In "latest" mode (default), the signal value is the most recent yielded
|
|
112
|
+
* value. In "buffer" mode, the signal value is a bounded array of recent
|
|
113
|
+
* values (newest last); `maxBuffer` is REQUIRED -- unbounded buffering is
|
|
114
|
+
* rejected.
|
|
115
|
+
*
|
|
116
|
+
* Termination on any of:
|
|
117
|
+
* - iterator natural completion ({done: true})
|
|
118
|
+
* - iterator throws
|
|
119
|
+
* - opts.signal aborts (the iterator's return() is called best-effort)
|
|
120
|
+
*
|
|
121
|
+
* To stop the stream without an AbortSignal, you can either:
|
|
122
|
+
* - dispose the returned signal via lite-signal's dispose(sig); pending
|
|
123
|
+
* iterator pulls will see the signal as disposed on next set() (no-op),
|
|
124
|
+
* but the iterator continues until natural completion. PREFER AbortSignal.
|
|
125
|
+
* - pass opts.signal and abort it externally.
|
|
126
|
+
*
|
|
127
|
+
* @template T
|
|
128
|
+
* @param {AsyncIterable<T> | AsyncIterator<T> | Iterable<T>} source
|
|
129
|
+
* @param {{
|
|
130
|
+
* mode?: "latest" | "buffer",
|
|
131
|
+
* maxBuffer?: number,
|
|
132
|
+
* initial?: T,
|
|
133
|
+
* signal?: AbortSignal,
|
|
134
|
+
* onError?: (err: unknown) => void,
|
|
135
|
+
* onDone?: () => void
|
|
136
|
+
* }} [opts]
|
|
137
|
+
* @returns {import("@zakkster/lite-signal").Signal<unknown>}
|
|
138
|
+
*/
|
|
139
|
+
function fromAsyncIterable(source, opts) {
|
|
140
|
+
const mode = (opts !== undefined && opts !== null) ? (opts.mode || "latest") : "latest";
|
|
141
|
+
const maxBuffer = (opts !== undefined && opts !== null) ? opts.maxBuffer : undefined;
|
|
142
|
+
const initial = (opts !== undefined && opts !== null) ? opts.initial : undefined;
|
|
143
|
+
const abortSig = (opts !== undefined && opts !== null) ? opts.signal : undefined;
|
|
144
|
+
const onError = (opts !== undefined && opts !== null) ? opts.onError : undefined;
|
|
145
|
+
const onDone = (opts !== undefined && opts !== null) ? opts.onDone : undefined;
|
|
146
|
+
|
|
147
|
+
if (mode !== "latest" && mode !== "buffer") {
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
"lite-stream: opts.mode must be \"latest\" or \"buffer\" (got " + JSON.stringify(mode) + ")"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (mode === "buffer") {
|
|
153
|
+
if (typeof maxBuffer !== "number" || !Number.isFinite(maxBuffer) || maxBuffer < 1 || (maxBuffer | 0) !== maxBuffer) {
|
|
154
|
+
throw new RangeError(
|
|
155
|
+
"lite-stream: \"buffer\" mode requires opts.maxBuffer to be a positive integer "
|
|
156
|
+
+ "(got " + JSON.stringify(maxBuffer) + "). Unbounded buffering is a memory "
|
|
157
|
+
+ "bug pretending to be a feature; pick a deliberate ceiling."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Initial state.
|
|
163
|
+
let state;
|
|
164
|
+
if (mode === "latest") {
|
|
165
|
+
state = { value: initial, count: 0, done: false, error: undefined };
|
|
166
|
+
} else {
|
|
167
|
+
state = { values: [], count: 0, droppedCount: 0, done: false, error: undefined };
|
|
168
|
+
}
|
|
169
|
+
const sig = _signal(state);
|
|
170
|
+
// Track current state locally so we never depend on sig.peek() in the
|
|
171
|
+
// pump's resolve handler. If the consumer disposes the signal between
|
|
172
|
+
// construction and the next pull, sig.peek() returns undefined; reading
|
|
173
|
+
// .count or .droppedCount off undefined would crash the pump and surface
|
|
174
|
+
// as an unhandledRejection. We mutate lastState in lockstep with sig.set()
|
|
175
|
+
// and use a guarded write helper to short-circuit when the signal has
|
|
176
|
+
// been disposed externally.
|
|
177
|
+
let lastState = state;
|
|
178
|
+
|
|
179
|
+
// Pre-aborted: settle synchronously, never start the iterator.
|
|
180
|
+
if (abortSig !== undefined && abortSig !== null && abortSig.aborted) {
|
|
181
|
+
const err = makeAbortError(abortSig);
|
|
182
|
+
const errState = makeErrorState(lastState, err, mode);
|
|
183
|
+
lastState = errState;
|
|
184
|
+
try { sig.set(errState); } catch (_e) {}
|
|
185
|
+
if (onError !== undefined) {
|
|
186
|
+
try { onError(err); } catch (_e) {}
|
|
187
|
+
}
|
|
188
|
+
return sig;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Invalid source -- throw synchronously. Construction-time input errors
|
|
192
|
+
// are a programmer bug; surfacing them via signal state would just defer
|
|
193
|
+
// a crash to a later read.
|
|
194
|
+
const iter = toIterator(source);
|
|
195
|
+
|
|
196
|
+
let stopped = false;
|
|
197
|
+
let abortListener = null;
|
|
198
|
+
|
|
199
|
+
const cleanup = function () {
|
|
200
|
+
if (abortListener !== null && abortSig !== undefined && abortSig !== null) {
|
|
201
|
+
abortSig.removeEventListener("abort", abortListener);
|
|
202
|
+
abortListener = null;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const stop = function (cause) {
|
|
207
|
+
if (stopped) return;
|
|
208
|
+
stopped = true;
|
|
209
|
+
cleanup();
|
|
210
|
+
closeIterator(iter);
|
|
211
|
+
// `cause` is null on natural done; an Error otherwise.
|
|
212
|
+
let terminalState;
|
|
213
|
+
if (cause === null) {
|
|
214
|
+
terminalState = makeDoneState(lastState, mode);
|
|
215
|
+
} else {
|
|
216
|
+
terminalState = makeErrorState(lastState, cause, mode);
|
|
217
|
+
}
|
|
218
|
+
lastState = terminalState;
|
|
219
|
+
// sig.set may throw or no-op if the signal was disposed externally;
|
|
220
|
+
// we've already recorded the terminal state in lastState.
|
|
221
|
+
try { sig.set(terminalState); } catch (_e) {}
|
|
222
|
+
if (cause === null) {
|
|
223
|
+
if (onDone !== undefined) {
|
|
224
|
+
try { onDone(); } catch (_e) {}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
if (onError !== undefined) {
|
|
228
|
+
try { onError(cause); } catch (_e) {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (abortSig !== undefined && abortSig !== null) {
|
|
234
|
+
abortListener = function () { stop(makeAbortError(abortSig)); };
|
|
235
|
+
abortSig.addEventListener("abort", abortListener);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Ring buffer for "buffer" mode. Fixed-size, indexed via a head pointer
|
|
239
|
+
// that wraps. On overflow, head advances over the oldest slot. The
|
|
240
|
+
// user-facing `values` snapshot is rebuilt on each yield by reading the
|
|
241
|
+
// ring in order; cheap because maxBuffer is a chosen ceiling.
|
|
242
|
+
let ring = null;
|
|
243
|
+
let ringHead = 0;
|
|
244
|
+
let ringLen = 0;
|
|
245
|
+
if (mode === "buffer") {
|
|
246
|
+
ring = new Array(maxBuffer);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The pump. Pull values until done/error/abort. We use a recursive .then
|
|
250
|
+
// chain rather than for-await so that we can hand a stable handle to the
|
|
251
|
+
// iterator and bail synchronously on stop without leaving a dangling
|
|
252
|
+
// for-await waiter.
|
|
253
|
+
function pump() {
|
|
254
|
+
if (stopped) return;
|
|
255
|
+
let nextPromise;
|
|
256
|
+
try {
|
|
257
|
+
nextPromise = iter.next();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
// Synchronous throw from .next() (rare; mostly sync iterators
|
|
260
|
+
// adapted into the async path).
|
|
261
|
+
stop(err);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
Promise.resolve(nextPromise).then(
|
|
265
|
+
function (result) {
|
|
266
|
+
if (stopped) return;
|
|
267
|
+
if (result === null || typeof result !== "object") {
|
|
268
|
+
stop(new TypeError("lite-stream: iterator.next() returned non-object"));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (result.done === true) {
|
|
272
|
+
stop(null);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const v = result.value;
|
|
276
|
+
// Build the new state from lastState (never sig.peek), then
|
|
277
|
+
// commit to both lastState and sig. If sig.set throws (consumer
|
|
278
|
+
// disposed the signal externally), bail and tear down the
|
|
279
|
+
// iterator so we don't keep pulling into the void.
|
|
280
|
+
let newState;
|
|
281
|
+
if (mode === "latest") {
|
|
282
|
+
newState = {
|
|
283
|
+
value: v,
|
|
284
|
+
count: (lastState.count + 1) | 0,
|
|
285
|
+
done: false,
|
|
286
|
+
error: undefined
|
|
287
|
+
};
|
|
288
|
+
} else {
|
|
289
|
+
// Buffer mode: push into ring; build snapshot array.
|
|
290
|
+
let dropped = lastState.droppedCount;
|
|
291
|
+
if (ringLen < maxBuffer) {
|
|
292
|
+
ring[(ringHead + ringLen) % maxBuffer] = v;
|
|
293
|
+
ringLen = (ringLen + 1) | 0;
|
|
294
|
+
} else {
|
|
295
|
+
// Overwrite at ringHead (oldest); advance head.
|
|
296
|
+
ring[ringHead] = v;
|
|
297
|
+
ringHead = (ringHead + 1) % maxBuffer;
|
|
298
|
+
dropped = (dropped + 1) | 0;
|
|
299
|
+
}
|
|
300
|
+
// Snapshot: a fresh array in newest-last order.
|
|
301
|
+
const values = new Array(ringLen);
|
|
302
|
+
for (let i = 0; i < ringLen; i = (i + 1) | 0) {
|
|
303
|
+
values[i] = ring[(ringHead + i) % maxBuffer];
|
|
304
|
+
}
|
|
305
|
+
newState = {
|
|
306
|
+
values: values,
|
|
307
|
+
count: (lastState.count + 1) | 0,
|
|
308
|
+
droppedCount: dropped,
|
|
309
|
+
done: false,
|
|
310
|
+
error: undefined
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
lastState = newState;
|
|
314
|
+
try {
|
|
315
|
+
sig.set(newState);
|
|
316
|
+
} catch (_e) {
|
|
317
|
+
// Signal disposed externally. Tear down quietly without
|
|
318
|
+
// firing onError -- this is consumer-initiated cleanup,
|
|
319
|
+
// not a stream error.
|
|
320
|
+
stopped = true;
|
|
321
|
+
cleanup();
|
|
322
|
+
closeIterator(iter);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Defer the next pull through the microtask queue so back-to-back
|
|
326
|
+
// synchronous yields don't blow the stack.
|
|
327
|
+
pump();
|
|
328
|
+
},
|
|
329
|
+
function (err) {
|
|
330
|
+
stop(err);
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Kick off. queueMicrotask to defer the first pull until after the caller
|
|
336
|
+
// can subscribe; we don't want a synchronous yield to fire before the
|
|
337
|
+
// caller's effect() is wired.
|
|
338
|
+
queueMicrotask(pump);
|
|
339
|
+
|
|
340
|
+
return sig;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Build a terminal state object preserving the most recent value/values.
|
|
344
|
+
function makeDoneState(prevState, mode) {
|
|
345
|
+
if (mode === "latest") {
|
|
346
|
+
return {
|
|
347
|
+
value: prevState.value,
|
|
348
|
+
count: prevState.count,
|
|
349
|
+
done: true,
|
|
350
|
+
error: undefined
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
values: prevState.values,
|
|
355
|
+
count: prevState.count,
|
|
356
|
+
droppedCount: prevState.droppedCount,
|
|
357
|
+
done: true,
|
|
358
|
+
error: undefined
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function makeErrorState(prevState, err, mode) {
|
|
363
|
+
if (mode === "latest") {
|
|
364
|
+
return {
|
|
365
|
+
value: prevState.value,
|
|
366
|
+
count: prevState.count,
|
|
367
|
+
done: true,
|
|
368
|
+
error: err
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
values: prevState.values,
|
|
373
|
+
count: prevState.count,
|
|
374
|
+
droppedCount: prevState.droppedCount,
|
|
375
|
+
done: true,
|
|
376
|
+
error: err
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// pipeToSignal
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Lower-level companion to `fromAsyncIterable`: pump an existing signal from
|
|
386
|
+
* an async iterator. The signal's value is replaced directly with each yielded
|
|
387
|
+
* value (no wrapper state). Returns a `stop` function that ends the pump,
|
|
388
|
+
* calls iterator.return(), and removes the abort listener.
|
|
389
|
+
*
|
|
390
|
+
* Use this when:
|
|
391
|
+
* - You already have a signal you want to drive
|
|
392
|
+
* - You don't want the {value, count, done, error} wrapper
|
|
393
|
+
* - You want a stop fn instead of an AbortController for cleanup
|
|
394
|
+
*
|
|
395
|
+
* NOTE: this does NOT dispose the target signal. The caller owns its lifetime.
|
|
396
|
+
*
|
|
397
|
+
* @template T
|
|
398
|
+
* @param {AsyncIterable<T> | AsyncIterator<T>} source
|
|
399
|
+
* @param {import("@zakkster/lite-signal").Signal<T>} target
|
|
400
|
+
* @param {{
|
|
401
|
+
* signal?: AbortSignal,
|
|
402
|
+
* onError?: (err: unknown) => void,
|
|
403
|
+
* onDone?: () => void,
|
|
404
|
+
* transform?: (value: T) => T
|
|
405
|
+
* }} [opts]
|
|
406
|
+
* @returns {() => void} stop fn (idempotent)
|
|
407
|
+
*/
|
|
408
|
+
function pipeToSignal(source, target, opts) {
|
|
409
|
+
if (target === null || target === undefined || typeof target.set !== "function") {
|
|
410
|
+
throw new TypeError("lite-stream: pipeToSignal target must be a writable signal");
|
|
411
|
+
}
|
|
412
|
+
const abortSig = (opts !== undefined && opts !== null) ? opts.signal : undefined;
|
|
413
|
+
const onError = (opts !== undefined && opts !== null) ? opts.onError : undefined;
|
|
414
|
+
const onDone = (opts !== undefined && opts !== null) ? opts.onDone : undefined;
|
|
415
|
+
const transform = (opts !== undefined && opts !== null) ? opts.transform : undefined;
|
|
416
|
+
|
|
417
|
+
if (abortSig !== undefined && abortSig !== null && abortSig.aborted) {
|
|
418
|
+
if (onError !== undefined) {
|
|
419
|
+
try { onError(makeAbortError(abortSig)); } catch (_e) {}
|
|
420
|
+
}
|
|
421
|
+
return function noopStop() {};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const iter = toIterator(source);
|
|
425
|
+
let stopped = false;
|
|
426
|
+
let abortListener = null;
|
|
427
|
+
|
|
428
|
+
const stop = function () {
|
|
429
|
+
if (stopped) return;
|
|
430
|
+
stopped = true;
|
|
431
|
+
if (abortListener !== null && abortSig !== undefined && abortSig !== null) {
|
|
432
|
+
abortSig.removeEventListener("abort", abortListener);
|
|
433
|
+
abortListener = null;
|
|
434
|
+
}
|
|
435
|
+
closeIterator(iter);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (abortSig !== undefined && abortSig !== null) {
|
|
439
|
+
abortListener = function () {
|
|
440
|
+
const err = makeAbortError(abortSig);
|
|
441
|
+
stop();
|
|
442
|
+
if (onError !== undefined) {
|
|
443
|
+
try { onError(err); } catch (_e) {}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
abortSig.addEventListener("abort", abortListener);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function pump() {
|
|
450
|
+
if (stopped) return;
|
|
451
|
+
let nextPromise;
|
|
452
|
+
try { nextPromise = iter.next(); }
|
|
453
|
+
catch (err) {
|
|
454
|
+
stop();
|
|
455
|
+
if (onError !== undefined) {
|
|
456
|
+
try { onError(err); } catch (_e) {}
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
Promise.resolve(nextPromise).then(
|
|
461
|
+
function (result) {
|
|
462
|
+
if (stopped) return;
|
|
463
|
+
if (result === null || typeof result !== "object") {
|
|
464
|
+
stop();
|
|
465
|
+
if (onError !== undefined) {
|
|
466
|
+
try { onError(new TypeError("lite-stream: iterator.next() returned non-object")); } catch (_e) {}
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (result.done === true) {
|
|
471
|
+
stop();
|
|
472
|
+
if (onDone !== undefined) {
|
|
473
|
+
try { onDone(); } catch (_e) {}
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
target.set(transform === undefined ? result.value : transform(result.value));
|
|
479
|
+
} catch (err) {
|
|
480
|
+
stop();
|
|
481
|
+
if (onError !== undefined) {
|
|
482
|
+
try { onError(err); } catch (_e) {}
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
pump();
|
|
487
|
+
},
|
|
488
|
+
function (err) {
|
|
489
|
+
stop();
|
|
490
|
+
if (onError !== undefined) {
|
|
491
|
+
try { onError(err); } catch (_e) {}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
queueMicrotask(pump);
|
|
498
|
+
return stop;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// toAsyncIterable -- the reverse direction
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Yield signal changes as an async iterable. Each change to the signal
|
|
507
|
+
* resolves a pending `next()` call. If the consumer is slower than the
|
|
508
|
+
* producer, values queue in an internal bounded buffer; on overflow, the
|
|
509
|
+
* OLDEST queued value is dropped and `droppedCount` is incremented. The
|
|
510
|
+
* dropped count is observable via the iterable's `.droppedCount` property
|
|
511
|
+
* (read after consuming).
|
|
512
|
+
*
|
|
513
|
+
* The iterator naturally completes when `opts.signal` aborts; iterator.return()
|
|
514
|
+
* resolves cleanly.
|
|
515
|
+
*
|
|
516
|
+
* @template T
|
|
517
|
+
* @param {import("@zakkster/lite-signal").Signal<T> | import("@zakkster/lite-signal").Computed<T>} sig
|
|
518
|
+
* @param {{
|
|
519
|
+
* signal?: AbortSignal,
|
|
520
|
+
* emitInitial?: boolean, // default true: yield the current value first
|
|
521
|
+
* maxBuffer?: number // default 1024
|
|
522
|
+
* }} [opts]
|
|
523
|
+
* @returns {AsyncIterable<T> & { readonly droppedCount: number }}
|
|
524
|
+
*/
|
|
525
|
+
function toAsyncIterable(sig, opts) {
|
|
526
|
+
if (sig === null || sig === undefined || typeof sig.subscribe !== "function" || typeof sig.peek !== "function") {
|
|
527
|
+
throw new TypeError(
|
|
528
|
+
"lite-stream: toAsyncIterable expects a readable lite-signal (Signal/Computed with .peek and .subscribe)"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const abortSig = (opts !== undefined && opts !== null) ? opts.signal : undefined;
|
|
532
|
+
const emitInitial = (opts !== undefined && opts !== null && opts.emitInitial === false) ? false : true;
|
|
533
|
+
const maxBuffer = (opts !== undefined && opts !== null && opts.maxBuffer !== undefined) ? opts.maxBuffer : 1024;
|
|
534
|
+
|
|
535
|
+
if (typeof maxBuffer !== "number" || !Number.isFinite(maxBuffer) || maxBuffer < 1 || (maxBuffer | 0) !== maxBuffer) {
|
|
536
|
+
throw new RangeError(
|
|
537
|
+
"lite-stream: opts.maxBuffer must be a positive integer (got " + JSON.stringify(maxBuffer) + ")"
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const queue = new Array(maxBuffer);
|
|
542
|
+
let qHead = 0;
|
|
543
|
+
let qLen = 0;
|
|
544
|
+
let droppedCount = 0;
|
|
545
|
+
let pendingResolve = null;
|
|
546
|
+
let done = false;
|
|
547
|
+
let unsubscribe = null;
|
|
548
|
+
let abortListener = null;
|
|
549
|
+
|
|
550
|
+
function enqueue(v) {
|
|
551
|
+
if (done) return;
|
|
552
|
+
if (pendingResolve !== null) {
|
|
553
|
+
const r = pendingResolve;
|
|
554
|
+
pendingResolve = null;
|
|
555
|
+
r({ value: v, done: false });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (qLen < maxBuffer) {
|
|
559
|
+
queue[(qHead + qLen) % maxBuffer] = v;
|
|
560
|
+
qLen = (qLen + 1) | 0;
|
|
561
|
+
} else {
|
|
562
|
+
// Drop oldest.
|
|
563
|
+
queue[qHead] = v;
|
|
564
|
+
qHead = (qHead + 1) % maxBuffer;
|
|
565
|
+
droppedCount = (droppedCount + 1) | 0;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function teardown() {
|
|
570
|
+
if (done) return;
|
|
571
|
+
done = true;
|
|
572
|
+
if (unsubscribe !== null) { unsubscribe(); unsubscribe = null; }
|
|
573
|
+
if (abortListener !== null && abortSig !== undefined && abortSig !== null) {
|
|
574
|
+
abortSig.removeEventListener("abort", abortListener);
|
|
575
|
+
abortListener = null;
|
|
576
|
+
}
|
|
577
|
+
if (pendingResolve !== null) {
|
|
578
|
+
const r = pendingResolve;
|
|
579
|
+
pendingResolve = null;
|
|
580
|
+
r({ value: undefined, done: true });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (abortSig !== undefined && abortSig !== null) {
|
|
585
|
+
if (abortSig.aborted) {
|
|
586
|
+
done = true;
|
|
587
|
+
} else {
|
|
588
|
+
abortListener = function () { teardown(); };
|
|
589
|
+
abortSig.addEventListener("abort", abortListener);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Subscribe to signal changes. lite-signal's subscribe fires once
|
|
594
|
+
// synchronously with the current value when called; we use a flag to
|
|
595
|
+
// suppress that initial fire if emitInitial is false.
|
|
596
|
+
let suppressedInitial = !emitInitial;
|
|
597
|
+
if (!done) {
|
|
598
|
+
unsubscribe = sig.subscribe(function (v) {
|
|
599
|
+
if (suppressedInitial) { suppressedInitial = false; return; }
|
|
600
|
+
enqueue(v);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const iterable = {
|
|
605
|
+
[Symbol.asyncIterator]() { return this; },
|
|
606
|
+
next() {
|
|
607
|
+
if (qLen > 0) {
|
|
608
|
+
const v = queue[qHead];
|
|
609
|
+
queue[qHead] = undefined;
|
|
610
|
+
qHead = (qHead + 1) % maxBuffer;
|
|
611
|
+
qLen = (qLen - 1) | 0;
|
|
612
|
+
return Promise.resolve({ value: v, done: false });
|
|
613
|
+
}
|
|
614
|
+
if (done) return Promise.resolve({ value: undefined, done: true });
|
|
615
|
+
return new Promise(function (resolve) { pendingResolve = resolve; });
|
|
616
|
+
},
|
|
617
|
+
return(value) {
|
|
618
|
+
teardown();
|
|
619
|
+
return Promise.resolve({ value: value, done: true });
|
|
620
|
+
},
|
|
621
|
+
throw(err) {
|
|
622
|
+
teardown();
|
|
623
|
+
return Promise.reject(err);
|
|
624
|
+
},
|
|
625
|
+
get droppedCount() { return droppedCount; }
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
return iterable;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Exports
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
export {
|
|
636
|
+
fromAsyncIterable,
|
|
637
|
+
pipeToSignal,
|
|
638
|
+
toAsyncIterable
|
|
639
|
+
};
|