@zakkster/lite-stream 1.0.0 → 1.1.1

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.
Files changed (6) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +119 -63
  3. package/ROADMAP.md +679 -159
  4. package/Stream.js +316 -80
  5. package/llms.txt +101 -18
  6. package/package.json +3 -3
package/Stream.js CHANGED
@@ -1,4 +1,4 @@
1
- // @zakkster/lite-stream 1.0.0
1
+ // @zakkster/lite-stream 1.1.1
2
2
  //
3
3
  // Zero-GC bridge between async iterators and @zakkster/lite-signal. The
4
4
  // multi-shot dual of lite-await's fromPromise: project an async source of N
@@ -20,11 +20,40 @@
20
20
  // droppedCount. Best for "process every helix page in order, don't miss
21
21
  // events".
22
22
  //
23
+ // 1.1 added `mode: "latest"`, `filter`, `timeout`, `Symbol.asyncDispose`,
24
+ // multi-waiter FIFO queue, and `overflowCount` on `toAsyncIterable`. See
25
+ // CHANGELOG.md [1.1.0] and llms.txt (toAsyncIterable section) for the full
26
+ // 1.1 story.
27
+ //
23
28
  // Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
24
29
  // MIT License
25
30
 
26
31
  import { signal as _signal } from "@zakkster/lite-signal";
27
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Errors
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Thrown when a `timeout` deadline elapses before iterator settlement.
39
+ * Introduced in 1.1 alongside `toAsyncIterable`'s `timeout` option.
40
+ * Structurally identical to lite-await's `TimeoutError` so users can
41
+ * duck-check via `e.name === "TimeoutError"` across both packages -- but
42
+ * NOT imported from lite-await, to preserve lite-stream's zero-dep story.
43
+ *
44
+ * A structural twin of lite-await's TimeoutError, deliberately not imported
45
+ * (zero-dep by design), carrying `name === "TimeoutError"` and a `.timeout`
46
+ * field (the elapsed deadline in ms). The duck-check is suite-wide vocabulary;
47
+ * renaming it would be a breaking change everywhere it is consumed.
48
+ */
49
+ class TimeoutError extends Error {
50
+ constructor(timeoutMs) {
51
+ super("lite-stream: timed out after " + timeoutMs + "ms");
52
+ this.name = "TimeoutError";
53
+ this.timeout = timeoutMs;
54
+ }
55
+ }
56
+
28
57
  // ---------------------------------------------------------------------------
29
58
  // Internal helpers
30
59
  // ---------------------------------------------------------------------------
@@ -118,11 +147,11 @@ function closeIterator(iter) {
118
147
  * - iterator throws
119
148
  * - opts.signal aborts (the iterator's return() is called best-effort)
120
149
  *
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.
150
+ * Disposing the result signal does NOT stop the pump: the iterator keeps
151
+ * pulling. AbortSignal or natural completion are the only stop mechanisms.
152
+ * A disposed signal's set() is a silent no-op, so the pump never learns of
153
+ * the disposal; an infinite source becomes an unbounded background loop.
154
+ * Pass opts.signal and abort it to stop early. See ROADMAP LS-01.
126
155
  *
127
156
  * @template T
128
157
  * @param {AsyncIterable<T> | AsyncIterator<T> | Iterable<T>} source
@@ -274,9 +303,11 @@ function fromAsyncIterable(source, opts) {
274
303
  }
275
304
  const v = result.value;
276
305
  // 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.
306
+ // commit to both lastState and sig. The try/catch around set()
307
+ // is defensive against a peer whose set() throws; on a throw we
308
+ // bail and tear down the iterator rather than pull into the
309
+ // void. Disposal is NOT the trigger -- set() after dispose() is
310
+ // a silent no-op in lite-signal 1.2.2 and 1.5.0 (see LS-01).
280
311
  let newState;
281
312
  if (mode === "latest") {
282
313
  newState = {
@@ -314,16 +345,22 @@ function fromAsyncIterable(source, opts) {
314
345
  try {
315
346
  sig.set(newState);
316
347
  } catch (_e) {
317
- // Signal disposed externally. Tear down quietly without
318
- // firing onError -- this is consumer-initiated cleanup,
319
- // not a stream error.
348
+ // Defensive against a peer signal whose set() throws. This
349
+ // is NOT disposal detection: set() after dispose() is a
350
+ // silent no-op in lite-signal 1.2.2 and 1.5.0 (probe P0),
351
+ // so this branch has never fired against any published
352
+ // peer. If it ever does, tear down without firing onError.
320
353
  stopped = true;
321
354
  cleanup();
322
355
  closeIterator(iter);
323
356
  return;
324
357
  }
325
- // Defer the next pull through the microtask queue so back-to-back
326
- // synchronous yields don't blow the stack.
358
+ // pump() is called DIRECTLY here -- no explicit deferral at
359
+ // this site. The stack is already unwound because this
360
+ // continuation runs from a .then() handler, i.e. a fresh
361
+ // microtask. Proven safe: 1,000,000 synchronous values drained
362
+ // in ~80ms with no stack overflow (probe P4, node v26.3.1,
363
+ // 2026-09-01).
327
364
  pump();
328
365
  },
329
366
  function (err) {
@@ -393,6 +430,8 @@ function makeErrorState(prevState, err, mode) {
393
430
  * - You want a stop fn instead of an AbortController for cleanup
394
431
  *
395
432
  * NOTE: this does NOT dispose the target signal. The caller owns its lifetime.
433
+ * Disposing the target signal does NOT stop the pump: call the returned
434
+ * stop fn or abort opts.signal. See ROADMAP LS-01.
396
435
  *
397
436
  * @template T
398
437
  * @param {AsyncIterable<T> | AsyncIterator<T>} source
@@ -498,31 +537,55 @@ function pipeToSignal(source, target, opts) {
498
537
  return stop;
499
538
  }
500
539
 
540
+ // ---------------------------------------------------------------------------
541
+ // Symbol.asyncDispose feature detection (Node 20+)
542
+ // ---------------------------------------------------------------------------
543
+ // Cached once at module load. When the runtime lacks Symbol.asyncDispose
544
+ // (Node 18/19), the iterable's disposer property is simply absent;
545
+ // iter.return() is the portable path.
546
+
547
+ const ASYNC_DISPOSE = (typeof Symbol !== "undefined" && Symbol.asyncDispose)
548
+ ? Symbol.asyncDispose
549
+ : null;
550
+
501
551
  // ---------------------------------------------------------------------------
502
552
  // toAsyncIterable -- the reverse direction
503
553
  // ---------------------------------------------------------------------------
504
554
 
505
555
  /**
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).
556
+ * Yield signal changes as an async iterable. Each change resolves a pending
557
+ * `next()` call. Two backpressure modes (1.1):
512
558
  *
513
- * The iterator naturally completes when `opts.signal` aborts; iterator.return()
514
- * resolves cleanly.
559
+ * - "buffer" (default; matches 1.0.0): FIFO ring buffer of maxBuffer size;
560
+ * on overflow the OLDEST is dropped and both droppedCount and
561
+ * overflowCount are incremented.
562
+ * - "latest" (1.1): single mutable slot; producer overwrites; every
563
+ * overwrite bumps overflowCount (and droppedCount for compat).
564
+ *
565
+ * 1.1 also adds `filter` (per-value gate; throwing filter rejects the
566
+ * current next() and terminates without a writer surface), `timeout`
567
+ * (overall deadline; rejects with TimeoutError), Symbol.asyncDispose
568
+ * on Node 20+, and a multi-waiter queue that fixes the 1.0.0 latent bug
569
+ * where Promise.all([iter.next(), iter.next()]) silently lost the first
570
+ * resolver.
571
+ *
572
+ * See CHANGELOG.md [1.1.0] and llms.txt (toAsyncIterable section) for the
573
+ * full locked semantics.
515
574
  *
516
575
  * @template T
517
576
  * @param {import("@zakkster/lite-signal").Signal<T> | import("@zakkster/lite-signal").Computed<T>} sig
518
577
  * @param {{
519
- * signal?: AbortSignal,
520
- * emitInitial?: boolean, // default true: yield the current value first
521
- * maxBuffer?: number // default 1024
578
+ * signal?: AbortSignal,
579
+ * emitInitial?: boolean,
580
+ * maxBuffer?: number,
581
+ * mode?: "latest" | "buffer",
582
+ * filter?: (v: T) => unknown,
583
+ * timeout?: number
522
584
  * }} [opts]
523
- * @returns {AsyncIterable<T> & { readonly droppedCount: number }}
585
+ * @returns {AsyncIterable<T> & { readonly droppedCount: number, readonly overflowCount: number }}
524
586
  */
525
587
  function toAsyncIterable(sig, opts) {
588
+ // --- Validation ---
526
589
  if (sig === null || sig === undefined || typeof sig.subscribe !== "function" || typeof sig.peek !== "function") {
527
590
  throw new TypeError(
528
591
  "lite-stream: toAsyncIterable expects a readable lite-signal (Signal/Computed with .peek and .subscribe)"
@@ -530,101 +593,273 @@ function toAsyncIterable(sig, opts) {
530
593
  }
531
594
  const abortSig = (opts !== undefined && opts !== null) ? opts.signal : undefined;
532
595
  const emitInitial = (opts !== undefined && opts !== null && opts.emitInitial === false) ? false : true;
533
- const maxBuffer = (opts !== undefined && opts !== null && opts.maxBuffer !== undefined) ? opts.maxBuffer : 1024;
596
+ const filter = (opts !== undefined && opts !== null) ? opts.filter : undefined;
597
+ const timeoutMs = (opts !== undefined && opts !== null) ? opts.timeout : undefined;
598
+ const modeOpt = (opts !== undefined && opts !== null) ? opts.mode : undefined;
599
+ const maxBufferOpt = (opts !== undefined && opts !== null) ? opts.maxBuffer : undefined;
600
+
601
+ // Mode. Default "buffer" preserves 1.0.0 behavior.
602
+ let mode;
603
+ if (modeOpt === undefined || modeOpt === "buffer") {
604
+ mode = "buffer";
605
+ } else if (modeOpt === "latest") {
606
+ mode = "latest";
607
+ } else {
608
+ throw new TypeError(
609
+ "lite-stream: opts.mode must be \"latest\" or \"buffer\" (got " + JSON.stringify(modeOpt) + ")"
610
+ );
611
+ }
612
+ const isLatestWins = mode === "latest";
534
613
 
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) + ")"
614
+ // maxBuffer only meaningful in buffer mode. Combining latest + maxBuffer
615
+ // is user error; silently ignoring one would hide the mistake.
616
+ if (isLatestWins && maxBufferOpt !== undefined) {
617
+ throw new TypeError(
618
+ "lite-stream: opts.maxBuffer is not allowed with mode: \"latest\" -- latest-wins uses a single slot"
538
619
  );
539
620
  }
621
+ const maxBuffer = maxBufferOpt !== undefined ? maxBufferOpt : 1024;
622
+ if (!isLatestWins) {
623
+ if (typeof maxBuffer !== "number" || !Number.isFinite(maxBuffer) || maxBuffer < 1 || (maxBuffer | 0) !== maxBuffer) {
624
+ throw new RangeError(
625
+ "lite-stream: opts.maxBuffer must be a positive integer (got " + JSON.stringify(maxBuffer) + ")"
626
+ );
627
+ }
628
+ }
540
629
 
541
- const queue = new Array(maxBuffer);
542
- let qHead = 0;
543
- let qLen = 0;
544
- let droppedCount = 0;
545
- let pendingResolve = null;
630
+ if (filter !== undefined && typeof filter !== "function") {
631
+ throw new TypeError("lite-stream: opts.filter must be a function");
632
+ }
633
+ if (timeoutMs !== undefined) {
634
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
635
+ throw new RangeError(
636
+ "lite-stream: opts.timeout must be a finite non-negative number (got " + JSON.stringify(timeoutMs) + ")"
637
+ );
638
+ }
639
+ }
640
+
641
+ // --- State ---
642
+ // Buffer-mode ring (unchanged from 1.0.0 layout); null in latest mode.
643
+ const queue = isLatestWins ? null : new Array(maxBuffer);
644
+ let qHead = 0;
645
+ let qLen = 0;
646
+ // Latest-mode slot.
647
+ let pendingValue;
648
+ let hasPending = false;
649
+ // Overflow. `droppedCount` and `overflowCount` are aliases for the same
650
+ // counter; 1.0.0 exposed `droppedCount`, 1.1 adds `overflowCount` as the
651
+ // mode-neutral vocabulary. Both getters read this variable.
652
+ let overflowCount = 0;
653
+ // Multi-waiter FIFO queue (fixes the 1.0.0 single-slot bug where
654
+ // concurrent .next() calls silently lost the first resolver).
655
+ const waiters = [];
656
+ // Termination
546
657
  let done = false;
658
+ let firstNextErr = null; // stashed for the next .next() when no waiter was pending
659
+ // Cleanup handles
547
660
  let unsubscribe = null;
661
+ let unsubscribePending = false;
548
662
  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;
663
+ let timeoutId = null;
664
+
665
+ // Late-binding unsubscribe: needed when a throwing filter (or throwing
666
+ // subscription callback body) fires during the synchronous initial
667
+ // subscribe call, at which point `unsubscribe` is still null. Mirror of
668
+ // lite-await's stop/stopPending pattern.
669
+ function doUnsubscribe() {
670
+ if (unsubscribe !== null) {
671
+ unsubscribe();
672
+ unsubscribe = null;
561
673
  } else {
562
- // Drop oldest.
563
- queue[qHead] = v;
564
- qHead = (qHead + 1) % maxBuffer;
565
- droppedCount = (droppedCount + 1) | 0;
674
+ unsubscribePending = true;
566
675
  }
567
676
  }
568
677
 
569
- function teardown() {
570
- if (done) return;
571
- done = true;
572
- if (unsubscribe !== null) { unsubscribe(); unsubscribe = null; }
678
+ function fullCleanup() {
679
+ doUnsubscribe();
680
+ if (timeoutId !== null) {
681
+ clearTimeout(timeoutId);
682
+ timeoutId = null;
683
+ }
573
684
  if (abortListener !== null && abortSig !== undefined && abortSig !== null) {
574
685
  abortSig.removeEventListener("abort", abortListener);
575
686
  abortListener = null;
576
687
  }
577
- if (pendingResolve !== null) {
578
- const r = pendingResolve;
579
- pendingResolve = null;
580
- r({ value: undefined, done: true });
688
+ // Release unread values so they can be GC'd before the iterable
689
+ // object itself is. One-time cost per iterable lifetime.
690
+ pendingValue = undefined;
691
+ hasPending = false;
692
+ if (queue !== null) {
693
+ for (let i = 0; i < maxBuffer; i = (i + 1) | 0) queue[i] = undefined;
694
+ qHead = 0;
695
+ qLen = 0;
581
696
  }
582
697
  }
583
698
 
584
- if (abortSig !== undefined && abortSig !== null) {
585
- if (abortSig.aborted) {
586
- done = true;
699
+ // Termination via error: reject all pending waiters, or stash for the
700
+ // next .next() call. Route for timeout and throwing-filter paths.
701
+ function terminateWithError(err) {
702
+ if (done) return;
703
+ done = true;
704
+ fullCleanup();
705
+ if (waiters.length > 0) {
706
+ while (waiters.length > 0) {
707
+ const w = waiters.shift();
708
+ w.reject(err);
709
+ }
587
710
  } else {
588
- abortListener = function () { teardown(); };
589
- abortSig.addEventListener("abort", abortListener);
711
+ firstNextErr = err;
590
712
  }
591
713
  }
592
714
 
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
- });
715
+ // Termination via done (abort, natural end via consumer return()): resolve
716
+ // pending waiters with {done: true}. Preserves 1.0.0 abort-is-graceful
717
+ // behavior (mid-iteration abort ends for-await without throw).
718
+ function terminateAsDone() {
719
+ if (done) return;
720
+ done = true;
721
+ fullCleanup();
722
+ while (waiters.length > 0) {
723
+ const w = waiters.shift();
724
+ w.resolve({ value: undefined, done: true });
725
+ }
602
726
  }
603
727
 
728
+ function enqueue(v) {
729
+ if (done) return;
730
+ // Deliver directly to the oldest waiter if any.
731
+ if (waiters.length > 0) {
732
+ const w = waiters.shift();
733
+ w.resolve({ value: v, done: false });
734
+ return;
735
+ }
736
+ // Otherwise stash in the mode-appropriate structure.
737
+ if (isLatestWins) {
738
+ if (hasPending) overflowCount = (overflowCount + 1) | 0;
739
+ pendingValue = v;
740
+ hasPending = true;
741
+ } else {
742
+ if (qLen < maxBuffer) {
743
+ queue[(qHead + qLen) % maxBuffer] = v;
744
+ qLen = (qLen + 1) | 0;
745
+ } else {
746
+ // Drop oldest.
747
+ queue[qHead] = v;
748
+ qHead = (qHead + 1) % maxBuffer;
749
+ overflowCount = (overflowCount + 1) | 0;
750
+ }
751
+ }
752
+ }
753
+
754
+ // --- Iterable object ---
604
755
  const iterable = {
605
756
  [Symbol.asyncIterator]() { return this; },
606
757
  next() {
607
- if (qLen > 0) {
758
+ if (done) {
759
+ if (firstNextErr !== null) {
760
+ const err = firstNextErr;
761
+ firstNextErr = null;
762
+ return Promise.reject(err);
763
+ }
764
+ return Promise.resolve({ value: undefined, done: true });
765
+ }
766
+ // Value available in slot / ring?
767
+ if (isLatestWins && hasPending) {
768
+ const v = pendingValue;
769
+ pendingValue = undefined;
770
+ hasPending = false;
771
+ return Promise.resolve({ value: v, done: false });
772
+ }
773
+ if (!isLatestWins && qLen > 0) {
608
774
  const v = queue[qHead];
609
775
  queue[qHead] = undefined;
610
776
  qHead = (qHead + 1) % maxBuffer;
611
777
  qLen = (qLen - 1) | 0;
612
778
  return Promise.resolve({ value: v, done: false });
613
779
  }
614
- if (done) return Promise.resolve({ value: undefined, done: true });
615
- return new Promise(function (resolve) { pendingResolve = resolve; });
780
+ // Nothing available -- queue a waiter.
781
+ return new Promise(function (resolve, reject) {
782
+ waiters.push({ resolve: resolve, reject: reject });
783
+ });
616
784
  },
617
785
  return(value) {
618
- teardown();
786
+ if (done) return Promise.resolve({ value: undefined, done: true });
787
+ terminateAsDone();
619
788
  return Promise.resolve({ value: value, done: true });
620
789
  },
621
790
  throw(err) {
622
- teardown();
791
+ if (done) return Promise.reject(err);
792
+ // Consumer-driven throw: pending waiters reject with the error,
793
+ // subsequent next() returns done. Same shape as terminateWithError
794
+ // but without stashing (throw() call site gets its own rejection).
795
+ done = true;
796
+ fullCleanup();
797
+ while (waiters.length > 0) {
798
+ const w = waiters.shift();
799
+ w.reject(err);
800
+ }
623
801
  return Promise.reject(err);
624
802
  },
625
- get droppedCount() { return droppedCount; }
803
+ get droppedCount() { return overflowCount; },
804
+ get overflowCount() { return overflowCount; }
626
805
  };
627
806
 
807
+ if (ASYNC_DISPOSE !== null) {
808
+ iterable[ASYNC_DISPOSE] = function () { return iterable.return(); };
809
+ }
810
+
811
+ // --- Pre-aborted signal: mark done, skip all subscription/timer setup.
812
+ // Matches 1.0.0 behavior: first next() resolves with {done: true} rather
813
+ // than rejecting. ---
814
+ if (abortSig !== undefined && abortSig !== null && abortSig.aborted) {
815
+ done = true;
816
+ return iterable;
817
+ }
818
+
819
+ // --- Timeout setup ---
820
+ if (timeoutMs !== undefined) {
821
+ timeoutId = setTimeout(function () {
822
+ terminateWithError(new TimeoutError(timeoutMs));
823
+ }, timeoutMs);
824
+ }
825
+
826
+ // --- AbortSignal listener ---
827
+ if (abortSig !== undefined && abortSig !== null) {
828
+ abortListener = function () { terminateAsDone(); };
829
+ abortSig.addEventListener("abort", abortListener);
830
+ }
831
+
832
+ // --- Subscribe. lite-signal's subscribe fires synchronously with the
833
+ // current value on registration; use a flag to suppress that initial
834
+ // fire if emitInitial is false. Filter (if provided) applies to the
835
+ // initial fire when emitInitial is true. A throwing filter is routed
836
+ // via terminateWithError so the throw never surfaces at the signal
837
+ // writer's .set() call site. ---
838
+ let suppressedInitial = !emitInitial;
839
+ unsubscribe = sig.subscribe(function (v) {
840
+ if (done) return;
841
+ if (suppressedInitial) { suppressedInitial = false; return; }
842
+ if (filter !== undefined) {
843
+ let ok;
844
+ try {
845
+ ok = filter(v);
846
+ } catch (e) {
847
+ terminateWithError(e);
848
+ return;
849
+ }
850
+ if (!ok) return;
851
+ }
852
+ enqueue(v);
853
+ });
854
+
855
+ // If terminateWithError was called from inside the synchronous initial
856
+ // subscribe fire (throwing filter on the initial value), honor the
857
+ // deferred unsubscribe now that we have the handle.
858
+ if (unsubscribePending && unsubscribe !== null) {
859
+ unsubscribe();
860
+ unsubscribe = null;
861
+ }
862
+
628
863
  return iterable;
629
864
  }
630
865
 
@@ -635,5 +870,6 @@ function toAsyncIterable(sig, opts) {
635
870
  export {
636
871
  fromAsyncIterable,
637
872
  pipeToSignal,
638
- toAsyncIterable
873
+ toAsyncIterable,
874
+ TimeoutError
639
875
  };