import { StreamRouteUnknownKeyError } from "../../errors/stream-error.js"; import type { RouteOptions } from "./route.types.js"; /** * Internal: build a `Map>` of routed branches, single-pass * (no chunk is duplicated). The reader drives the source and dispatches each * chunk to the controller of exactly one branch — chosen by `keyFn`. On * unknown key under `onUnknownKey: 'error'`, every branch errors with * `StreamRouteUnknownKeyError` and the source is cancelled. */ function buildRoutedMap( source: ReadableStream, keyFn: (chunk: T) => K, keys: readonly K[], options: RouteOptions = {} ): Map> { const onUnknownKey = options.onUnknownKey ?? "drop"; const controllers = new Map>(); const knownKeys = new Set(keys); const result = new Map>(); // We need every branch's controller wired up before the reader starts so // that early chunks are not lost when the source resolves quickly. const controllerReady: Promise[] = []; for (const k of keys) { let resolveReady!: () => void; const ready = new Promise((r) => { resolveReady = r; }); controllerReady.push(ready); const branch = new ReadableStream({ start(controller) { controllers.set(k, controller); resolveReady(); }, cancel() { // Per-branch cancel is non-fatal; other branches keep flowing. // Drop the controller so writes to it become no-ops. controllers.delete(k); }, }); result.set(k, branch); } const startReader = async (): Promise => { // Wait until every branch has registered its controller. await Promise.all(controllerReady); const reader = source.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; const k = keyFn(value as T); if (!knownKeys.has(k)) { if (onUnknownKey === "error") { const err = new StreamRouteUnknownKeyError({ key: String(k), }); for (const c of controllers.values()) c.error(err); controllers.clear(); try { await reader.cancel(err); } catch { // ignore cancel failures } return; } // 'drop' — silently discard. continue; } const c = controllers.get(k); if (c !== undefined) { c.enqueue(value as T); } } for (const c of controllers.values()) c.close(); controllers.clear(); } catch (err) { for (const c of controllers.values()) c.error(err); controllers.clear(); } finally { try { reader.releaseLock(); } catch { // already released } } }; // Kick the reader on first pull from any branch. We bind a `pull` by // wrapping each branch in a tee-style trigger: simplest correct approach // is to start eagerly on the next microtask once all branches are // constructed. Since branches are constructed synchronously above, this // microtask fires after the caller has the Map in hand. queueMicrotask(() => { void startReader(); }); return result; } /** * Routes each chunk of `source` to exactly one of N output branches, keyed by * the result of `keyFn`. Returns a record keyed by the declared `keys`. Each * chunk is delivered to a single branch (no duplication, single-pass). * * Per-key ordering is preserved. On unknown key, behaviour depends on * `options.onUnknownKey` (`'drop'` default, or `'error'`). */ export function route( source: ReadableStream, keyFn: (chunk: T) => K, keys: readonly K[], options?: RouteOptions ): Record> { const m = buildRoutedMap(source, keyFn, keys, options); const out = {} as Record>; for (const k of keys) { out[k] = m.get(k) as ReadableStream; } return out; } /** * Like `route`, but returns a `Map>` — supporting key * types beyond `string` (numbers, symbols, objects). */ export function routeBy( source: ReadableStream, keyFn: (chunk: T) => K, keys: readonly K[], options?: RouteOptions ): Map> { return buildRoutedMap(source, keyFn, keys, options); }