UNPKG

stream-chain

Version:

Chain functions, generators, Node streams, and Web streams into a pipeline with backpressure support.

170 lines (161 loc) 6.46 kB
// @ts-self-types="./exec.d.ts" // Sync-when-possible, value-or-promise executor. Threads a value through a // function-list, emitting terminal values via a `push` callback. Unlike // `fun()`'s collect sink (whose return is ignored) the push return is HONORED: when // push returns a Promise — a downstream backpressure signal — the executor // suspends AT that push and chains the remainder, preserving a bounded queue. // Returns undefined when the whole traversal ran synchronously, or a Promise // when it had to suspend. // // Duck-types returned values the way the original 1.x `fun()` did: thenable → // chain; many() → expand; .next → iterate as a generator; none/null → drop; // stop → throw Stop; finalValue → emit and short-circuit. An async generator is // not special — it's a generator whose next() returned a promise, handled by // the same suspend/resume path. A sync generator stays synchronous. // // This is the engine `asStream` / `asWebStream` will adopt in place of their // `async function applyFns` (see // [[projects/stream-chain/design/sync-when-possible-executor]]); kept here as a // standalone module so it can be unit-tested in isolation. import * as defs from './defs.js'; const next = (value, fns, index, push) => { for (let i = index; ; ) { if (value && typeof value.then == 'function') { const ii = i; return value.then(v => next(v, fns, ii, push)); } if (value == null || value === defs.none) return; if (value === defs.stop) throw new defs.Stop(); if (defs.isFinalValue(value)) { return push(defs.getFinalValue(value)); // emit, bypass remaining fns } if (defs.isMany(value)) { return nextMany(defs.getManyValues(value), fns, i, push); } if (value && typeof value.next == 'function') { return nextGen(value, fns, i, push); } if (i >= fns.length) { return push(value); // terminal plain value } value = fns[i++](value); } }; // Iterate a many() array, threading each element. A resumable `step` stays // synchronous until one element returns a promise (a backpressure push, or // genuine async); then it suspends AT that element and resumes the remainder // via .then(step). Allocating one closure per actual suspension — not one per // element — keeps live allocation O(1) in the array length even when the // consumer backpressures from element 0 (the bug a per-element .then chain hit: // a chunk-sized many() exploded into O(N) live promises). Mirrors nextGen. const nextMany = (values, fns, i, push) => { const step = j => { for (; j < values.length; ++j) { const r = next(values[j], fns, i, push); if (r && typeof r.then == 'function') { const jj = j; return r.then(() => step(jj + 1)); } } }; return step(0); }; // Iterate a generator, threading each yield. A resumable `step` keeps a sync // generator with a draining queue fully synchronous; it goes async only when // next() returns a promise (async generator) or a yield's push backpressures — // then resumes via .then(step). const nextGen = (it, fns, i, push) => { const step = () => { for (;;) { let data = it.next(); if (data && typeof data.then == 'function') { return data.then(d => { if (d.done) return; const r = next(d.value, fns, i, push); return r && typeof r.then == 'function' ? r.then(step) : step(); }); } if (data.done) return; const r = next(data.value, fns, i, push); if (r && typeof r.then == 'function') return r.then(step); } }; // Abnormal termination — a downstream stage threw (sync or rejected promise), // or a push rejected (the gen bridge's consumer CANCEL) — leaves the source // generator suspended at a yield, so its `finally {}` never runs and a // resource-owning source (e.g. asyncBlockReader's FileHandle) leaks. Run // `it.return()` to fire that finally, awaiting it for an async generator, then // re-throw the ORIGINAL error. Normal completion (`data.done`) already ran the // generator's own finally — don't touch it. The sync fast path stays // overhead-free: a plain `try` plus one `.then(undefined, abort)` only when // iteration actually suspended. const abort = err => { let ret; try { ret = it.return ? it.return() : undefined; } catch { throw err; } if (ret && typeof ret.then == 'function') { const rethrow = () => { throw err; }; return ret.then(rethrow, rethrow); } throw err; }; let r; try { r = step(); } catch (err) { return abort(err); } return r && typeof r.then == 'function' ? r.then(undefined, abort) : r; }; // Flush flushable stages (called when the factory's driver receives `none`). // Mirrors fun.flush: each flushable's output threads through the stages after // it, value-or-promise chained. Resumable `step` (same shape as nextMany) keeps // live allocation O(1) in the number of flushable stages under backpressure. const flush = (fns, index, push) => { const step = i => { for (; i < fns.length; ++i) { const f = fns[i]; if (!defs.isFlushable(f)) continue; const r = next(f(defs.none), fns, i + 1, push); if (r && typeof r.then == 'function') { const ii = i; return r.then(() => step(ii + 1)); } } }; return step(index); }; // Factory parallel to fun()/gen(): normalize the fn-list (flatten, unwrap nested // function-lists, default to identity) and return a push-driven function tagged // as a function-list. The driver is `(value, push) => void | Promise`; calling // it with `none` flushes. Note `stop` halts without flushing buffered // flushables — same as gen() (only `fun()` flushes on stop). const exec = (...fns) => { fns = fns .filter(fn => fn) .flat(Infinity) .map(fn => (defs.isFunctionList(fn) ? defs.getFunctionList(fn) : fn)) .flat(Infinity); if (!fns.length) { fns = [x => x]; } let flushed = false; let g = (value, push) => { if (flushed) throw Error('Call to a flushed pipe.'); if (value === defs.none) { flushed = true; return flush(fns, 0, push); } return next(value, fns, 0, push); }; const needToFlush = fns.some(fn => defs.isFlushable(fn)); if (needToFlush) g = defs.flushable(g); return defs.setFunctionList(g, fns); }; export default exec; export {exec, next, flush};