Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
export { BaseTransform } from "./transform.js";
export { TransformOptionsSchema } from "./transform.schemas.js";
export type {
EventMap,
Hook,
HookMap,
IBaseTransform,
Listener,
TransformOptions,
Unsubscribe,
} from "./transform.types.js";

View File

@@ -0,0 +1,176 @@
# BaseTransform
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BaseTransform` is the abstract plugin contract every stream transform in `@suaveplan/streams` extends. It provides a uniform, type-safe surface for hooks, events, abort handling, WHATWG-Streams interop, and disposal — so subclasses (such as `CryptoTransform`, `MapTransform`, `SplitJoinTransform`) can focus on their actual operation without re-implementing pipeline plumbing.
The class is parameterized by two maps: `THooks extends HookMap` declares hook names and their payload types, and `TEvents extends EventMap` declares event names and their payload types. This means consumers who register hooks or listeners get full TypeScript inference on the payload — no `any`, no string-typed callbacks. Subclasses that don't need declared hooks or events can omit both type parameters and use the lenient defaults.
## Features
- Abstract `process<T>(input, options?)` — every subclass declares its own operation
- Type-safe `hook(name, fn)` / `removeHook(name, fn)` with payload-chaining: each hook receives the previous hook's return value
- Type-safe `on(name, fn)` / `off(name, fn)` — fire-and-forget event listeners, sync, with errors swallowed and forwarded to `options.logger.error`
- Protected `applyHooks(name, payload, options?)` and `emit(name, payload, options?)` helpers for subclasses to fire hooks/events at appropriate points in their `process` implementation
- Protected `assertNotAborted(options)` helper that throws `StreamAbortedError` when `options.signal` has fired
- Default `toTransformStream<TIn, TOut>()` bridge — pipes WHATWG `ReadableStream` chunks through `process(chunk)`
- Idempotent `dispose()` — clears all hooks and listeners; safe to call twice
- Strict `TransformOptionsSchema` rejects unknown options at the boundary
## Usage: Basic Example
```ts
import { BaseTransform } from "@suaveplan/streams/base/transform/transform";
class UpperTransform extends BaseTransform {
constructor() {
super("upper");
}
async process<T = unknown>(input: T): Promise<T> {
if (typeof input !== "string") return input;
return input.toUpperCase() as unknown as T;
}
}
const t = new UpperTransform();
const out = await t.process("hello"); // "HELLO"
```
## Usage: Advanced Example
```ts
import { BaseTransform } from "@suaveplan/streams/base/transform/transform";
import type { TransformOptions } from "@suaveplan/streams/base/transform/transform.types";
interface CryptoHooks {
"before-chunk": { chunk: Uint8Array; counter: number };
"after-chunk": { chunk: Uint8Array; counter: number };
}
interface CryptoEvents {
start: { engineId: string };
chunk: { counter: number; bytes: number };
complete: { totalChunks: number };
}
class XorTransform extends BaseTransform<CryptoHooks, CryptoEvents> {
constructor(private readonly key: Uint8Array) {
super("xor-demo");
}
async process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T> {
if (!(input instanceof Uint8Array)) {
throw new TypeError("XorTransform requires Uint8Array");
}
this.emit("start", { engineId: "xor" }, options);
let counter = 0;
const before = await this.applyHooks(
"before-chunk",
{ chunk: input, counter },
options
);
this.assertNotAborted(options);
const out = before.chunk.map(
(b, i) => b ^ this.key[i % this.key.length]!
) as Uint8Array;
const after = await this.applyHooks(
"after-chunk",
{ chunk: out, counter },
options
);
counter += 1;
this.emit(
"chunk",
{ counter, bytes: after.chunk.byteLength },
options
);
this.emit("complete", { totalChunks: counter }, options);
return after.chunk as unknown as T;
}
}
const t = new XorTransform(new Uint8Array([0xaa]));
t.hook("before-chunk", (p) => ({ ...p, chunk: p.chunk }));
t.on("chunk", (p) => console.log(`chunk ${p.counter}: ${p.bytes} bytes`));
// Direct call
const sealed = await t.process(new Uint8Array([1, 2, 3]));
// WHATWG bridge
const source = new ReadableStream<Uint8Array>({ /* ... */ });
const transformed = source.pipeThrough(
t.toTransformStream<Uint8Array, Uint8Array>()
);
```
## API Reference
### `BaseTransform<THooks, TEvents>`
Abstract class. Type parameters:
- `THooks extends HookMap` — map of hook names to payload types. Default: `HookMap` (loose).
- `TEvents extends EventMap` — map of event names to payload types. Default: `EventMap` (loose).
Constructor: `(name: string)` — assigned to public readonly `name`.
#### `process<T>(input: T, options?: TransformOptions): Promise<T>`
Abstract. Subclasses implement the operation. Implementations SHOULD call `this.assertNotAborted(options)` at suitable yield points.
#### `hook<K>(name: K, fn: Hook<THooks[K]>): Unsubscribe`
Registers a payload-chaining hook under `name`. Returns an `Unsubscribe` function that removes this exact registration.
#### `removeHook<K>(name: K, fn: Hook<THooks[K]>): boolean`
Removes a previously registered hook. Returns `true` if the hook was found and removed; `false` if it wasn't registered.
#### `on<K>(name: K, fn: Listener<TEvents[K]>): Unsubscribe`
Registers a fire-and-forget event listener.
#### `off<K>(name: K, fn: Listener<TEvents[K]>): boolean`
Removes a registered listener. Returns `true` on success.
#### `protected applyHooks<K>(name, payload, options?): Promise<THooks[K]>`
Walks the hook chain registered under `name`. Each hook receives the prior hook's return value. Returns the final payload. Honors `options.signal` between hooks.
#### `protected emit<K>(name, payload, options?): void`
Notifies listeners synchronously. Listener errors are caught and forwarded to `options.logger.error` (if present). Never throws.
#### `protected assertNotAborted(options?): void`
Throws `StreamAbortedError` when `options.signal.aborted` is true. The thrown error's `cause` is `options.signal.reason`.
#### `toTransformStream<TIn, TOut>(options?): TransformStream<TIn, TOut>`
Default WHATWG bridge. Each input chunk is passed through `this.process(chunk, options)`. Errors from `process` flow into the readable side via `controller.error`. Subclasses MAY override for streaming semantics (e.g. AEAD frame-counter binding).
#### `dispose(): void`
Clears all hooks and listeners. Idempotent — calling twice is a no-op. Subclasses overriding `dispose` MUST call `super.dispose()`.
### `TransformOptionsSchema`
Strict Zod schema for `TransformOptions`. Rejects unknown keys, validates `signal` as an `AbortSignal`, `clock` as a function, and `logger` as an object with `debug`, `info`, `warn`, `error` methods.
## Implementation Notes
The class stores hooks and listeners in `Map<string, Function[]>` rather than per-name fields so subclass-declared hook/event names can be added without fields conflicting. Hooks fire **in registration order** — this is observable behavior pinned by `transform.test.ts::"hooks fire in registration order with payload chaining"` and is part of the spec contract.
`emit` is synchronous and swallows listener errors so a misbehaving observer never affects `process`. This matches the suaveplan convention — events are _passive observability_, never load-bearing flow control. Hooks, by contrast, are load-bearing: a hook return value flows through the chain, and a hook throw aborts `process`.
`toTransformStream` returns a fresh `TransformStream` per call but all streams share the same hook/listener state on the underlying `BaseTransform` instance — so registering a hook before constructing the stream affects every subsequent stream.
`dispose` is intentionally minimal — it does not cancel pending `process` calls. Cancellation is the caller's responsibility via `AbortSignal`. Subclasses with additional disposable resources (e.g. `CryptoTransform` zeroizing its key buffer) override `dispose` and call `super.dispose()` to chain cleanup.
The design intentionally avoids depending on `@suaveplan/events`, even though that package provides richer event/hook primitives. The streams package is a foundational layer; pulling in events would create an awkward dependency cycle for any consumer that wants to compose streams + events without the indirection. The minimal inline implementation here is sufficient for the spec scenarios and faster on the hot path.

View File

@@ -0,0 +1,38 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import z from "zod";
const callable = <T>() =>
z.custom<T>((v): v is T => typeof v === "function", {
message: "must be a function",
});
const loggerSchema = z.custom<LoggerLike>(
(v): v is LoggerLike => {
if (v === null || typeof v !== "object") return false;
const obj = v as Record<string, unknown>;
return (
typeof obj["debug"] === "function" &&
typeof obj["info"] === "function" &&
typeof obj["warn"] === "function" &&
typeof obj["error"] === "function"
);
},
{ message: "must be a LoggerLike with debug/info/warn/error methods" }
);
/**
* Strict Zod schema for {@link TransformOptions}.
*
* `strict()` rejects unknown keys at the boundary, ensuring callers that pass
* malformed options surface the error at parse time rather than silently
* dropping fields. We use `z.custom` for callables and the logger so the
* parsed values are passed through with referential identity preserved —
* `z.function()` in Zod v4 wraps the callable, breaking identity equality.
*/
export const TransformOptionsSchema = z
.object({
signal: z.instanceof(AbortSignal).optional(),
clock: callable<Clock>().optional(),
logger: loggerSchema.optional(),
})
.strict();

View File

@@ -0,0 +1,89 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import { BaseTransform } from "./transform.js";
import type {
Hook,
Listener,
TransformOptions,
Unsubscribe,
} from "./transform.types.js";
interface CryptoLikeHooks {
"before-chunk": { chunk: Uint8Array; counter: number };
"after-chunk": { chunk: Uint8Array; counter: number };
}
interface CryptoLikeEvents {
start: { engineId: string };
chunk: { counter: number; bytes: number };
complete: { totalChunks: number };
}
class TypedTransform extends BaseTransform<CryptoLikeHooks, CryptoLikeEvents> {
constructor() {
super("typed");
}
async process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T> {
this.assertNotAborted(options);
return input;
}
}
describe("BaseTransform spec conformance", () => {
it("hook payload type is inferred from event name literal", {
timeout: 5000,
}, () => {
const t = new TypedTransform();
const beforeFn: Hook<CryptoLikeHooks["before-chunk"]> = (p) => {
expectTypeOf(p).toEqualTypeOf<{
chunk: Uint8Array;
counter: number;
}>();
return p;
};
const unsub = t.hook("before-chunk", beforeFn);
expectTypeOf(unsub).toEqualTypeOf<Unsubscribe>();
unsub();
});
it("listener payload type is inferred from event name literal", {
timeout: 5000,
}, () => {
const t = new TypedTransform();
const startFn: Listener<CryptoLikeEvents["start"]> = (p) => {
expectTypeOf(p).toEqualTypeOf<{ engineId: string }>();
};
const unsub = t.on("start", startFn);
expectTypeOf(unsub).toEqualTypeOf<Unsubscribe>();
unsub();
});
it("removeHook and off return boolean", { timeout: 5000 }, () => {
const t = new TypedTransform();
const fn: Hook<CryptoLikeHooks["after-chunk"]> = (p) => p;
t.hook("after-chunk", fn);
const removed = t.removeHook("after-chunk", fn);
expectTypeOf(removed).toEqualTypeOf<boolean>();
const listener: Listener<CryptoLikeEvents["chunk"]> = () => {};
t.on("chunk", listener);
const off = t.off("chunk", listener);
expectTypeOf(off).toEqualTypeOf<boolean>();
});
it("process narrows the generic input to the output type", {
timeout: 5000,
}, async () => {
const t = new TypedTransform();
const out = await t.process<{ a: number }>({ a: 1 });
expectTypeOf(out).toEqualTypeOf<{ a: number }>();
});
it("toTransformStream narrows TIn / TOut", { timeout: 5000 }, () => {
const t = new TypedTransform();
const stream = t.toTransformStream<string, string>();
expectTypeOf(stream).toEqualTypeOf<TransformStream<string, string>>();
});
});

View File

@@ -0,0 +1,387 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { STREAM_CODES, StreamAbortedError } from "../../errors/stream-error.js";
import { BaseTransform } from "./transform.js";
import { TransformOptionsSchema } from "./transform.schemas.js";
import type { TransformOptions } from "./transform.types.js";
interface TestHooks {
"before-process": { input: unknown };
"after-process": { output: unknown };
}
interface TestEvents {
start: { name: string };
complete: { name: string; output: unknown };
}
class JsonTransform extends BaseTransform<TestHooks, TestEvents> {
constructor() {
super("json");
}
async process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T> {
this.emit("start", { name: this.name }, options);
const beforePayload = await this.applyHooks(
"before-process",
{ input },
options
);
this.assertNotAborted(options);
const out = JSON.stringify(beforePayload.input) as unknown as T;
const afterPayload = await this.applyHooks(
"after-process",
{ output: out },
options
);
this.emit(
"complete",
{ name: this.name, output: afterPayload.output },
options
);
return afterPayload.output as T;
}
}
class PassthroughTransform extends BaseTransform<
{ tap: { value: unknown } },
Record<string, never>
> {
constructor() {
super("passthrough");
}
async process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T> {
this.assertNotAborted(options);
const out = await this.applyHooks("tap", { value: input }, options);
return out.value as T;
}
}
describe("BaseTransform", () => {
it("subclass process returns transformed value", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
await expect(t.process({ a: 1 })).resolves.toBe('{"a":1}');
});
it("hooks fire in registration order with payload chaining", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const order: string[] = [];
t.hook("before-process", (p) => {
order.push("first");
return { input: { ...(p.input as object), seen: 1 } };
});
t.hook("before-process", (p) => {
order.push("second");
expect((p.input as { seen?: number }).seen).toBe(1);
return { input: { ...(p.input as object), seen: 2 } };
});
const result = await t.process({ a: 1 });
expect(order).toEqual(["first", "second"]);
expect(result).toBe('{"a":1,"seen":2}');
});
it("events fire without affecting return value", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const seen: string[] = [];
t.on("start", (p) => seen.push(`start:${p.name}`));
t.on("complete", (p) => seen.push(`complete:${p.output}`));
const out = await t.process({ x: 1 });
expect(out).toBe('{"x":1}');
expect(seen).toEqual(["start:json", `complete:${out}`]);
});
it("listener errors are swallowed and forwarded to logger", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const errors: unknown[] = [];
const logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: (_msg: string, ctx: unknown) => {
errors.push(ctx);
},
};
t.on("start", () => {
throw new Error("listener boom");
});
await expect(t.process({ a: 1 }, { logger })).resolves.toBe('{"a":1}');
expect(errors).toHaveLength(1);
expect((errors[0] as { error: Error }).error).toBeInstanceOf(Error);
});
it("removeHook returns true when hook is registered, false otherwise", {
timeout: 5000,
}, async () => {
const t = new PassthroughTransform();
const fn = (p: { value: unknown }) => p;
t.hook("tap", fn);
expect(t.removeHook("tap", fn)).toBe(true);
expect(t.removeHook("tap", fn)).toBe(false);
expect(t.removeHook("tap", (p: { value: unknown }) => p)).toBe(false);
});
it("off returns true when listener is registered, false otherwise", {
timeout: 5000,
}, () => {
const t = new JsonTransform();
const fn = (_p: { name: string }) => {};
t.on("start", fn);
expect(t.off("start", fn)).toBe(true);
expect(t.off("start", fn)).toBe(false);
expect(t.off("start", (_p: { name: string }) => {})).toBe(false);
});
it("hook unsubscribe handle removes the hook", {
timeout: 5000,
}, async () => {
const t = new PassthroughTransform();
const calls: number[] = [];
const unsub = t.hook("tap", (p) => {
calls.push(1);
return p;
});
await t.process(0);
unsub();
await t.process(0);
expect(calls).toEqual([1]);
});
it("on unsubscribe handle removes the listener", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const calls: string[] = [];
const unsub = t.on("start", (p) => calls.push(p.name));
await t.process(0);
unsub();
await t.process(0);
expect(calls).toEqual(["json"]);
});
it("toTransformStream pipes through process", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const source = new ReadableStream<{ x: number }>({
start(c) {
c.enqueue({ x: 1 });
c.enqueue({ x: 2 });
c.close();
},
});
const out: string[] = [];
const sink = new WritableStream<string>({
write(chunk) {
out.push(chunk);
},
});
await source
.pipeThrough(t.toTransformStream<{ x: number }, string>())
.pipeTo(sink);
expect(out).toEqual(['{"x":1}', '{"x":2}']);
});
it("toTransformStream propagates process errors via controller.error", {
timeout: 5000,
}, async () => {
class BoomTransform extends BaseTransform {
constructor() {
super("boom");
}
async process<T>(_input: T): Promise<T> {
throw new Error("boom");
}
}
const t = new BoomTransform();
const source = new ReadableStream<number>({
start(c) {
c.enqueue(1);
c.close();
},
});
const reader = source
.pipeThrough(t.toTransformStream<number, number>())
.getReader();
await expect(reader.read()).rejects.toThrowError("boom");
});
it("aborted signal rejects process with STREAM.ABORTED and propagates cause", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const ctrl = new AbortController();
const reason = new Error("user cancelled");
ctrl.abort(reason);
await expect(
t.process({ a: 1 }, { signal: ctrl.signal })
).rejects.toMatchObject({
code: STREAM_CODES.ABORTED,
cause: reason,
});
});
it("abort during applyHooks rejects mid-chain", {
timeout: 5000,
}, async () => {
const t = new PassthroughTransform();
const ctrl = new AbortController();
t.hook("tap", async (p) => {
ctrl.abort(new Error("during"));
return p;
});
t.hook("tap", (p) => {
throw new Error("should not run");
return p;
});
await expect(
t.process(0, { signal: ctrl.signal })
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("dispose is idempotent and clears hooks and listeners", {
timeout: 5000,
}, async () => {
const t = new JsonTransform();
const hookCalls: number[] = [];
const eventCalls: number[] = [];
t.hook("before-process", (p) => {
hookCalls.push(1);
return p;
});
t.on("start", () => eventCalls.push(1));
t.dispose();
t.dispose();
await t.process({ a: 1 });
expect(hookCalls).toEqual([]);
expect(eventCalls).toEqual([]);
});
});
describe("TransformOptionsSchema", () => {
it("accepts an empty object", { timeout: 5000 }, () => {
expect(TransformOptionsSchema.parse({})).toEqual({});
});
it("accepts signal, clock, and logger together", { timeout: 5000 }, () => {
const ctrl = new AbortController();
const opts = {
signal: ctrl.signal,
clock: () => 0,
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
};
const parsed = TransformOptionsSchema.parse(opts);
expect(parsed.signal).toBe(ctrl.signal);
expect(parsed.clock).toBe(opts.clock);
expect(parsed.logger).toBe(opts.logger);
});
it("rejects unknown keys at the boundary", { timeout: 5000 }, () => {
expect(() => TransformOptionsSchema.parse({ unknownKey: 1 })).toThrow();
});
it("rejects non-AbortSignal signal", { timeout: 5000 }, () => {
expect(() =>
TransformOptionsSchema.parse({ signal: { aborted: false } })
).toThrow();
});
it("rejects logger missing required methods", { timeout: 5000 }, () => {
expect(() =>
TransformOptionsSchema.parse({
logger: { debug: () => {} },
})
).toThrow();
});
it("rejects non-function clock", { timeout: 5000 }, () => {
expect(() =>
TransformOptionsSchema.parse({ clock: "not-a-function" })
).toThrow();
});
it("rejects null logger", { timeout: 5000 }, () => {
expect(() => TransformOptionsSchema.parse({ logger: null })).toThrow();
});
});
describe("BaseTransform — never-registered name paths", () => {
class NoopTransform extends BaseTransform<
{ foo: { v: number } },
{ bar: { v: number } }
> {
constructor() {
super("noop");
}
async process<T>(input: T): Promise<T> {
return input;
}
// Surface protected APIs for testing the empty-list branches.
callApply<K extends "foo">(
name: K,
payload: { v: number }
): Promise<{ v: number }> {
return this.applyHooks(name, payload as never);
}
callEmit<K extends "bar">(name: K, payload: { v: number }): void {
this.emit(name, payload as never);
}
get disposedFlag(): boolean {
return this.isDisposed;
}
}
it("applyHooks returns payload unchanged when no hooks registered", {
timeout: 5000,
}, async () => {
const t = new NoopTransform();
const out = await t.callApply("foo", { v: 7 });
expect(out).toEqual({ v: 7 });
});
it("emit is a no-op when no listeners registered", {
timeout: 5000,
}, () => {
const t = new NoopTransform();
// Should not throw; no listeners to invoke.
t.callEmit("bar", { v: 1 });
});
it("removeHook returns false when name was never registered", {
timeout: 5000,
}, () => {
const t = new NoopTransform();
expect(t.removeHook("foo", () => ({ v: 0 }))).toBe(false);
});
it("off returns false when name was never registered", {
timeout: 5000,
}, () => {
const t = new NoopTransform();
expect(t.off("bar", () => {})).toBe(false);
});
it("isDisposed flips after dispose()", { timeout: 5000 }, () => {
const t = new NoopTransform();
expect(t.disposedFlag).toBe(false);
t.dispose();
expect(t.disposedFlag).toBe(true);
});
});

View File

@@ -0,0 +1,207 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
import type {
EventMap,
Hook,
HookMap,
IBaseTransform,
Listener,
TransformOptions,
Unsubscribe,
} from "./transform.types.js";
/**
* Abstract base class for stream transforms.
*
* Subclasses implement {@link BaseTransform.process} to perform their work and
* MAY parameterize the class with `THooks` and `TEvents` maps to declare
* type-safe hook and event surfaces. The class provides:
*
* - `hook(name, fn)` / `removeHook(name, fn)` — payload-chaining hooks that
* fire in registration order. Each hook receives the previous hook's
* return value and can mutate, replace, redact, or veto data.
* - `on(name, fn)` / `off(name, fn)` — fire-and-forget event listeners.
* Listeners are called synchronously in registration order; thrown errors
* are caught and forwarded to `options.logger.error` if present, never
* propagated to the caller.
* - `applyHooks(name, payload, options?)` — protected helper subclasses call
* inside their `process` implementation to walk the hook chain.
* - `emit(name, payload, options?)` — protected helper subclasses call to
* notify listeners.
* - `assertNotAborted(options)` — protected helper that throws
* {@link StreamAbortedError} when `options.signal` has fired.
* - `toTransformStream()` — default WHATWG bridge that pipes chunks through
* `process(chunk)`. Subclasses MAY override for streaming semantics.
* - `dispose()` — clears all hooks and listeners idempotently.
*/
export abstract class BaseTransform<
THooks extends HookMap = HookMap,
TEvents extends EventMap = EventMap,
> implements IBaseTransform<THooks, TEvents>
{
public readonly name: string;
private readonly hooks = new Map<string, Hook<unknown>[]>();
private readonly listeners = new Map<string, Listener<unknown>[]>();
private disposed = false;
constructor(name: string) {
this.name = name;
}
/**
* Subclass-supplied operation. Implementations SHOULD call
* {@link BaseTransform.assertNotAborted} at suitable yield points to honor
* the AbortSignal contract from the streams capability spec.
*/
abstract process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T>;
/** Registers a hook under `name`. Returns an `Unsubscribe` for cleanup. */
hook<K extends keyof THooks & string>(
name: K,
fn: Hook<THooks[K]>
): Unsubscribe {
const list = (this.hooks.get(name) ?? []) as Hook<THooks[K]>[];
list.push(fn);
this.hooks.set(name, list as Hook<unknown>[]);
return () => {
this.removeHook(name, fn);
};
}
/** Removes a previously registered hook. Returns true if removed. */
removeHook<K extends keyof THooks & string>(
name: K,
fn: Hook<THooks[K]>
): boolean {
const list = this.hooks.get(name);
if (!list) return false;
const idx = list.indexOf(fn as Hook<unknown>);
if (idx === -1) return false;
list.splice(idx, 1);
return true;
}
/**
* Walks the hook chain registered under `name`. Each hook receives the
* payload returned by the previous one. Returns the final payload.
*/
protected async applyHooks<K extends keyof THooks & string>(
name: K,
payload: THooks[K],
options?: TransformOptions
): Promise<THooks[K]> {
const list = this.hooks.get(name);
if (!list || list.length === 0) return payload;
let current: THooks[K] = payload;
for (const hook of list) {
this.assertNotAborted(options);
current = (await hook(current)) as THooks[K];
}
return current;
}
/** Registers an event listener under `name`. */
on<K extends keyof TEvents & string>(
name: K,
fn: Listener<TEvents[K]>
): Unsubscribe {
const list = (this.listeners.get(name) ?? []) as Listener<TEvents[K]>[];
list.push(fn);
this.listeners.set(name, list as Listener<unknown>[]);
return () => {
this.off(name, fn);
};
}
/** Removes a previously registered listener. */
off<K extends keyof TEvents & string>(
name: K,
fn: Listener<TEvents[K]>
): boolean {
const list = this.listeners.get(name);
if (!list) return false;
const idx = list.indexOf(fn as Listener<unknown>);
if (idx === -1) return false;
list.splice(idx, 1);
return true;
}
/**
* Notifies all listeners registered under `name` synchronously, in
* registration order. Listener errors are swallowed (forwarded to the
* supplied logger if any) so listener failures never affect `process`.
*/
protected emit<K extends keyof TEvents & string>(
name: K,
payload: TEvents[K],
options?: TransformOptions
): void {
const list = this.listeners.get(name);
if (!list) return;
for (const listener of list) {
try {
(listener as Listener<TEvents[K]>)(payload);
} catch (err) {
options?.logger?.error?.(
`[BaseTransform:${this.name}] listener for "${name}" threw`,
{ error: err }
);
}
}
}
/**
* Returns a default WHATWG `TransformStream` that pipes each chunk through
* `this.process(chunk, options)` where `options` are captured at
* construction time. Each call returns a fresh stream sharing this
* transform's hook + listener state.
*/
toTransformStream<TIn = unknown, TOut = unknown>(
options?: TransformOptions
): TransformStream<TIn, TOut> {
return new TransformStream<TIn, TOut>({
transform: async (chunk, controller) => {
try {
const out = (await this.process(
chunk as unknown,
options
)) as TOut;
controller.enqueue(out);
} catch (err) {
controller.error(err);
}
},
});
}
/**
* Throws {@link StreamAbortedError} if `options.signal` has been aborted.
* Subclasses SHOULD call this at yield points inside their `process`
* implementation.
*/
protected assertNotAborted(options?: TransformOptions): void {
if (options?.signal?.aborted) {
throw new StreamAbortedError({
cause: options.signal.reason,
});
}
}
/**
* Releases all hooks and listeners. Idempotent — calling twice is a no-op.
* Subclasses overriding `dispose` MUST call `super.dispose()`.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.hooks.clear();
this.listeners.clear();
}
/** Returns whether `dispose()` has been called. Useful for guards. */
protected get isDisposed(): boolean {
return this.disposed;
}
}

View File

@@ -0,0 +1,83 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
/**
* Common runtime options accepted by every {@link BaseTransform.process} call.
*
* Subclasses MAY extend this shape with their own option keys; the base shape
* is enforced via {@link TransformOptionsSchema} from the sibling
* `transform.schemas.ts`.
*/
export interface TransformOptions {
/** Cancellation signal — `process` rejects with `StreamAbortedError` if fired. */
readonly signal?: AbortSignal;
/** Injectable clock for deterministic time reads. */
readonly clock?: Clock;
/** Logger for production telemetry; respects `@suaveplan/types::LoggerLike`. */
readonly logger?: LoggerLike;
}
/**
* A hook function. Hooks form a chain — the previous hook's return value is
* passed to the next. Use hooks to mutate, redact, replace, or veto data
* flowing through a transform without subclassing.
*/
export type Hook<TPayload> = (
payload: TPayload
) => TPayload | Promise<TPayload>;
/**
* An event listener. Listeners are fire-and-forget; their return value is
* ignored, and synchronous throws are caught and forwarded to `logger.error`
* (if a logger is wired) without propagating to the caller.
*/
export type Listener<TPayload> = (payload: TPayload) => void;
/** Removes a previously registered hook or listener. */
export type Unsubscribe = () => void;
/**
* Map of declared hook names to payload types. Subclasses parameterize
* {@link BaseTransform} with this map to get type-safe `hook()` and
* `applyHooks()` calls.
*/
export type HookMap = Readonly<Record<string, unknown>>;
/**
* Map of declared event names to payload types. Subclasses parameterize
* {@link BaseTransform} with this map to get type-safe `on()` and `emit()`
* calls.
*/
export type EventMap = Readonly<Record<string, unknown>>;
/**
* The minimal contract every transform exposes: a stable `name`, a `process`
* method, and a `dispose` method. Subclasses extend this via inheritance.
*/
export interface IBaseTransform<
THooks extends HookMap = HookMap,
TEvents extends EventMap = EventMap,
> {
readonly name: string;
process<T = unknown>(input: T, options?: TransformOptions): Promise<T>;
hook<K extends keyof THooks & string>(
name: K,
fn: Hook<THooks[K]>
): Unsubscribe;
removeHook<K extends keyof THooks & string>(
name: K,
fn: Hook<THooks[K]>
): boolean;
on<K extends keyof TEvents & string>(
name: K,
fn: Listener<TEvents[K]>
): Unsubscribe;
off<K extends keyof TEvents & string>(
name: K,
fn: Listener<TEvents[K]>
): boolean;
toTransformStream<TIn = unknown, TOut = unknown>(): TransformStream<
TIn,
TOut
>;
dispose(): void;
}

View File

@@ -0,0 +1,5 @@
/** Default high-water mark for most stream operations. */
export const DEFAULT_HWM = 1;
/** Default byte chunk size for fromBytes (64 KiB). */
export const DEFAULT_CHUNK_SIZE = 65536;

View File

@@ -0,0 +1,23 @@
export {
STREAM_CODES,
StreamAbortedError,
StreamBranchFailedError,
StreamClosedError,
StreamCryptoDecryptFailedError,
StreamCryptoInvalidFrameError,
StreamDuplexClosedError,
StreamError,
type StreamErrorCode,
StreamFileBrowserNotSupportedError,
StreamFileReadFailedError,
StreamFrameTooLargeError,
StreamNetworkResetError,
StreamPipeFailedError,
StreamReadFailedError,
StreamRouteUnknownKeyError,
StreamTransformFailedError,
StreamTruncatedFrameError,
StreamUnknownTransformTypeError,
StreamWriteFailedError,
StreamZipLengthMismatchError,
} from "./stream-error.js";

View File

@@ -0,0 +1,101 @@
# stream-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error hierarchy for the `@suaveplan/streams` package. Every runtime error emitted by stream operations is an instance of `StreamError` (which extends `SuaveplanError`) and carries a stable, dot-namespaced error code from the `STREAM.*` registry. This design means callers can narrow errors programmatically — `error.code === STREAM_CODES.ABORTED` — without string-matching unstructured messages, and ensures that OTel integrations, log pipelines, and dead-letter queues can route stream errors reliably.
The codes are registered once via `defineCodes("STREAM", [...])` and reused across every throw site. Codes are intentionally coarse-grained: they represent the semantic category of failure, not the specific call stack location, so they remain stable across refactors.
## Features
- Six concrete error subclasses covering all stream failure modes
- Stable `STREAM.*` error code registry via `defineCodes`
- Exported `StreamErrorCode` union type for exhaustive switch statements
- Each subclass propagates `cause` when it is an `Error` instance
- All classes extend `StreamError` which extends `SuaveplanError`, compatible with the project error wire format
## Usage: Basic Example
```ts
import { StreamAbortedError, STREAM_CODES } from "@suaveplan/streams/errors/stream-error";
function checkAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
}
try {
checkAborted(AbortSignal.abort());
} catch (err) {
if (err instanceof StreamAbortedError) {
console.log(err.code); // "STREAM.ABORTED"
}
}
```
## Usage: Advanced Example
```ts
import {
StreamTransformFailedError,
StreamClosedError,
StreamReadFailedError,
STREAM_CODES,
} from "@suaveplan/streams/errors/stream-error";
function handleStreamError(err: unknown): never {
if (!(err instanceof Error)) {
throw new StreamReadFailedError({ message: "Unknown read failure" });
}
switch ((err as { code?: string }).code) {
case STREAM_CODES.CLOSED:
throw new StreamClosedError({ message: "Cannot read from closed stream" });
case STREAM_CODES.TRANSFORM_FAILED:
throw new StreamTransformFailedError({ cause: err });
default:
throw new StreamReadFailedError({ cause: err });
}
}
```
## API Reference
### `STREAM_CODES`
`Record<string, string>` — the stable code map. Keys: `ABORTED`, `BACKPRESSURE_EXCEEDED`, `CHUNK_INVALID`, `PIPE_FAILED`, `TRANSFORM_FAILED`, `SOURCE_EXHAUSTED`, `READ_FAILED`, `WRITE_FAILED`, `CLOSED`. Values are dot-namespaced strings like `"STREAM.ABORTED"`.
### `StreamError`
Base class. Extends `SuaveplanError<StreamErrorCode>`. All concrete subclasses inherit from this.
### `StreamAbortedError`
Thrown when an `AbortSignal` fires mid-operation. Constructor: `{ cause?: unknown; message?: string }`.
### `StreamTransformFailedError`
Thrown when a user-supplied transform function throws. Constructor: `{ cause?: unknown; message?: string }`.
### `StreamClosedError`
Thrown when an operation is attempted on a closed stream. Constructor: `{ message?: string }`.
### `StreamReadFailedError`
Thrown on unrecoverable read failures. Constructor: `{ cause?: unknown; message?: string }`.
### `StreamWriteFailedError`
Thrown on unrecoverable write failures. Constructor: `{ cause?: unknown; message?: string }`.
### `StreamPipeFailedError`
Thrown when a pipe or pipeline composition fails. Constructor: `{ cause?: unknown; message?: string }`.
## Implementation Notes
`defineCodes` generates stable string codes by joining the namespace prefix and the key with a dot. The generated values are frozen at module-init time and never change, which means error codes survive across serialization round-trips (they appear verbatim in JSON error envelopes). Cause propagation is conditional: we only set `cause` when the original value `instanceof Error` to avoid accidentally serializing non-Error objects into the error chain.

View File

@@ -0,0 +1,323 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STREAM_CODES,
StreamAbortedError,
StreamBranchFailedError,
StreamClosedError,
StreamCryptoDecryptFailedError,
StreamCryptoInvalidFrameError,
StreamDuplexClosedError,
StreamError,
StreamFileBrowserNotSupportedError,
StreamFileReadFailedError,
StreamFrameTooLargeError,
StreamNetworkResetError,
StreamPipeFailedError,
StreamReadFailedError,
StreamRouteUnknownKeyError,
StreamTransformFailedError,
StreamTruncatedFrameError,
StreamUnknownTransformTypeError,
StreamWriteFailedError,
StreamZipLengthMismatchError,
} from "./stream-error.js";
describe("STREAM_CODES", () => {
it("has all expected codes", { timeout: 5000 }, () => {
expect(STREAM_CODES.ABORTED).toBe("STREAM.ABORTED");
expect(STREAM_CODES.TRANSFORM_FAILED).toBe("STREAM.TRANSFORM_FAILED");
expect(STREAM_CODES.CLOSED).toBe("STREAM.CLOSED");
expect(STREAM_CODES.READ_FAILED).toBe("STREAM.READ_FAILED");
expect(STREAM_CODES.WRITE_FAILED).toBe("STREAM.WRITE_FAILED");
expect(STREAM_CODES.PIPE_FAILED).toBe("STREAM.PIPE_FAILED");
expect(STREAM_CODES.BACKPRESSURE_EXCEEDED).toBe(
"STREAM.BACKPRESSURE_EXCEEDED"
);
expect(STREAM_CODES.CHUNK_INVALID).toBe("STREAM.CHUNK_INVALID");
expect(STREAM_CODES.SOURCE_EXHAUSTED).toBe("STREAM.SOURCE_EXHAUSTED");
});
});
describe("StreamAbortedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamAbortedError();
expect(err.code).toBe(STREAM_CODES.ABORTED);
expect(err instanceof StreamError).toBe(true);
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamAbortedError({ message: "custom abort" });
expect(err.message).toBe("custom abort");
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("signal reason");
const err = new StreamAbortedError({ cause });
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new StreamAbortedError({ cause: "string cause" });
expect(err.cause).toBeUndefined();
});
});
describe("StreamTransformFailedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamTransformFailedError();
expect(err.code).toBe(STREAM_CODES.TRANSFORM_FAILED);
});
it("uses default message", { timeout: 5000 }, () => {
const err = new StreamTransformFailedError();
expect(err.message).toBe("Stream transform failed");
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new TypeError("bad chunk");
const err = new StreamTransformFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamClosedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamClosedError();
expect(err.code).toBe(STREAM_CODES.CLOSED);
});
it("uses default message", { timeout: 5000 }, () => {
const err = new StreamClosedError();
expect(err.message).toBe("Stream is closed");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamClosedError({ message: "already done" });
expect(err.message).toBe("already done");
});
});
describe("StreamReadFailedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamReadFailedError();
expect(err.code).toBe(STREAM_CODES.READ_FAILED);
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("io error");
const err = new StreamReadFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamWriteFailedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamWriteFailedError();
expect(err.code).toBe(STREAM_CODES.WRITE_FAILED);
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("write error");
const err = new StreamWriteFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamPipeFailedError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new StreamPipeFailedError();
expect(err.code).toBe(STREAM_CODES.PIPE_FAILED);
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("pipe error");
const err = new StreamPipeFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamFrameTooLargeError", () => {
it("has correct code and includes values in message", {
timeout: 5000,
}, () => {
const err = new StreamFrameTooLargeError({
framePrefixValue: 9999,
maxFrameBytes: 100,
});
expect(err.code).toBe(STREAM_CODES.FRAME_TOO_LARGE);
expect(err.message).toContain("9999");
expect(err.message).toContain("100");
});
});
describe("StreamTruncatedFrameError", () => {
it("has correct code and includes values in message", {
timeout: 5000,
}, () => {
const err = new StreamTruncatedFrameError({
expectedBytes: 12,
receivedBytes: 5,
});
expect(err.code).toBe(STREAM_CODES.TRUNCATED_FRAME);
expect(err.message).toContain("12");
expect(err.message).toContain("5");
});
});
describe("StreamRouteUnknownKeyError", () => {
it("has correct code and includes key in message", {
timeout: 5000,
}, () => {
const err = new StreamRouteUnknownKeyError({ key: "badKey" });
expect(err.code).toBe(STREAM_CODES.ROUTE_UNKNOWN_KEY);
expect(err.message).toContain("badKey");
});
});
describe("StreamZipLengthMismatchError", () => {
it("has correct code and includes values in message", {
timeout: 5000,
}, () => {
const err = new StreamZipLengthMismatchError({
endedIndex: 1,
totalInputs: 3,
});
expect(err.code).toBe(STREAM_CODES.ZIP_LENGTH_MISMATCH);
expect(err.message).toContain("1");
expect(err.message).toContain("3");
});
});
describe("StreamBranchFailedError", () => {
it("has correct code with default message", { timeout: 5000 }, () => {
const err = new StreamBranchFailedError();
expect(err.code).toBe(STREAM_CODES.BRANCH_FAILED);
expect(err.message).toBe("Stream branch failed");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamBranchFailedError({ message: "branch boom" });
expect(err.message).toBe("branch boom");
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new StreamBranchFailedError({ cause });
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new StreamBranchFailedError({ cause: "string cause" });
expect(err.cause).toBeUndefined();
});
});
describe("StreamCryptoDecryptFailedError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamCryptoDecryptFailedError();
expect(err.code).toBe(STREAM_CODES.CRYPTO_DECRYPT_FAILED);
expect(err.message).toBe("AEAD decryption failed");
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("tag mismatch");
const err = new StreamCryptoDecryptFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamCryptoInvalidFrameError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamCryptoInvalidFrameError();
expect(err.code).toBe(STREAM_CODES.CRYPTO_INVALID_FRAME);
expect(err.message).toBe("AEAD frame structure is invalid");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamCryptoInvalidFrameError({
message: "bad header tag",
});
expect(err.message).toBe("bad header tag");
});
});
describe("StreamDuplexClosedError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamDuplexClosedError();
expect(err.code).toBe(STREAM_CODES.DUPLEX_CLOSED);
expect(err.message).toBe("Duplex stream is closed");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamDuplexClosedError({ message: "already closed" });
expect(err.message).toBe("already closed");
});
});
describe("StreamNetworkResetError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamNetworkResetError();
expect(err.code).toBe(STREAM_CODES.NETWORK_RESET);
expect(err.message).toBe("Network stream was reset");
});
it("accepts networkId context", { timeout: 5000 }, () => {
const err = new StreamNetworkResetError({ networkId: "conn-42" });
expect(err.code).toBe(STREAM_CODES.NETWORK_RESET);
expect(err.message).toBe("Network stream was reset");
});
it("accepts custom message without networkId", { timeout: 5000 }, () => {
const err = new StreamNetworkResetError({ message: "reset!" });
expect(err.message).toBe("reset!");
});
});
describe("StreamFileReadFailedError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamFileReadFailedError({});
expect(err.code).toBe(STREAM_CODES.FILE_READ_FAILED);
expect(err.message).toBe("File read failed");
});
it("accepts filePath and custom message", { timeout: 5000 }, () => {
const err = new StreamFileReadFailedError({
filePath: "/tmp/data.bin",
message: "could not open",
});
expect(err.message).toBe("could not open");
});
it("propagates Error cause without filePath", { timeout: 5000 }, () => {
const cause = new Error("ENOENT");
const err = new StreamFileReadFailedError({ cause });
expect(err.cause).toBe(cause);
});
});
describe("StreamFileBrowserNotSupportedError", () => {
it("has correct code and default message", { timeout: 5000 }, () => {
const err = new StreamFileBrowserNotSupportedError();
expect(err.code).toBe(STREAM_CODES.FILE_BROWSER_NOT_SUPPORTED);
expect(err.message).toContain("Node.js");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new StreamFileBrowserNotSupportedError({
message: "only Node",
});
expect(err.message).toBe("only Node");
});
});
describe("StreamUnknownTransformTypeError", () => {
it("has correct code and includes transformType in message", {
timeout: 5000,
}, () => {
const err = new StreamUnknownTransformTypeError({
transformType: "mystery",
});
expect(err.code).toBe(STREAM_CODES.UNKNOWN_TRANSFORM_TYPE);
expect(err.message).toContain("mystery");
});
});

View File

@@ -0,0 +1,251 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the streams package. */
export const STREAM_CODES = defineCodes("STREAM", [
"ABORTED",
"BACKPRESSURE_EXCEEDED",
"CHUNK_INVALID",
"PIPE_FAILED",
"TRANSFORM_FAILED",
"SOURCE_EXHAUSTED",
"READ_FAILED",
"WRITE_FAILED",
"CLOSED",
"FRAME_TOO_LARGE",
"TRUNCATED_FRAME",
"ROUTE_UNKNOWN_KEY",
"ZIP_LENGTH_MISMATCH",
"BRANCH_FAILED",
"CRYPTO_DECRYPT_FAILED",
"CRYPTO_INVALID_FRAME",
"DUPLEX_CLOSED",
"NETWORK_RESET",
"FILE_READ_FAILED",
"FILE_BROWSER_NOT_SUPPORTED",
"UNKNOWN_TRANSFORM_TYPE",
] as const);
export type StreamErrorCode = (typeof STREAM_CODES)[keyof typeof STREAM_CODES];
/** Base error class for all streams-package errors. */
export class StreamError extends SuaveplanError<StreamErrorCode> {}
interface ErrorOpts {
cause?: unknown;
message?: string;
}
function asErrorCause(c: unknown): Error | undefined {
return c instanceof Error ? c : undefined;
}
/** Thrown when a stream operation is cancelled via AbortSignal. */
export class StreamAbortedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.ABORTED,
opts.message ?? "Stream operation aborted",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when a transform function throws an error. */
export class StreamTransformFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.TRANSFORM_FAILED,
opts.message ?? "Stream transform failed",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when attempting to write to a closed stream. */
export class StreamClosedError extends StreamError {
constructor(opts: { message?: string } = {}) {
super(STREAM_CODES.CLOSED, opts.message ?? "Stream is closed");
}
}
/** Thrown when a stream read operation fails. */
export class StreamReadFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(STREAM_CODES.READ_FAILED, opts.message ?? "Stream read failed", {
cause: asErrorCause(opts.cause),
});
}
}
/** Thrown when a stream write operation fails. */
export class StreamWriteFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.WRITE_FAILED,
opts.message ?? "Stream write failed",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when a pipe operation fails. */
export class StreamPipeFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(STREAM_CODES.PIPE_FAILED, opts.message ?? "Stream pipe failed", {
cause: asErrorCause(opts.cause),
});
}
}
/** Thrown when a length-prefixed frame exceeds the configured maxFrameBytes. */
export class StreamFrameTooLargeError extends StreamError {
constructor(opts: { framePrefixValue: number; maxFrameBytes: number }) {
super(
STREAM_CODES.FRAME_TOO_LARGE,
`Length-prefixed frame ${opts.framePrefixValue} exceeds maxFrameBytes ${opts.maxFrameBytes}`,
{
context: {
framePrefixValue: opts.framePrefixValue,
maxFrameBytes: opts.maxFrameBytes,
},
}
);
}
}
/** Thrown when a length-prefixed decode terminates mid-frame. */
export class StreamTruncatedFrameError extends StreamError {
constructor(opts: { expectedBytes: number; receivedBytes: number }) {
super(
STREAM_CODES.TRUNCATED_FRAME,
`Length-prefixed frame truncated: expected ${opts.expectedBytes} bytes, received ${opts.receivedBytes}`,
{
context: {
expectedBytes: opts.expectedBytes,
receivedBytes: opts.receivedBytes,
},
}
);
}
}
/** Thrown when route() encounters a key not in its declared key set under onUnknownKey: 'error'. */
export class StreamRouteUnknownKeyError extends StreamError {
constructor(opts: { key: string }) {
super(
STREAM_CODES.ROUTE_UNKNOWN_KEY,
`route() received unknown key "${opts.key}" under onUnknownKey: 'error'`,
{ context: { key: opts.key } }
);
}
}
/** Thrown when zip() inputs end before others under onShortInput: 'error'. */
export class StreamZipLengthMismatchError extends StreamError {
constructor(opts: { endedIndex: number; totalInputs: number }) {
super(
STREAM_CODES.ZIP_LENGTH_MISMATCH,
`zip() input at index ${opts.endedIndex} ended before others (total inputs: ${opts.totalInputs})`,
{
context: {
endedIndex: opts.endedIndex,
totalInputs: opts.totalInputs,
},
}
);
}
}
/** Thrown when a safeBranch wrapped sub-pipeline errors. */
export class StreamBranchFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.BRANCH_FAILED,
opts.message ?? "Stream branch failed",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when AEAD decryption fails (reorder, truncate, tamper). */
export class StreamCryptoDecryptFailedError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.CRYPTO_DECRYPT_FAILED,
opts.message ?? "AEAD decryption failed",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when an AEAD frame fails structural validation. */
export class StreamCryptoInvalidFrameError extends StreamError {
constructor(opts: ErrorOpts = {}) {
super(
STREAM_CODES.CRYPTO_INVALID_FRAME,
opts.message ?? "AEAD frame structure is invalid",
{ cause: asErrorCause(opts.cause) }
);
}
}
/** Thrown when a duplex stream is used after close(). */
export class StreamDuplexClosedError extends StreamError {
constructor(opts: { message?: string } = {}) {
super(
STREAM_CODES.DUPLEX_CLOSED,
opts.message ?? "Duplex stream is closed"
);
}
}
/** Thrown when NetworkStreamImpl.reset() aborts the stream. */
export class StreamNetworkResetError extends StreamError {
constructor(opts: { message?: string; networkId?: string } = {}) {
super(
STREAM_CODES.NETWORK_RESET,
opts.message ?? "Network stream was reset",
opts.networkId
? { context: { networkId: opts.networkId } }
: undefined
);
}
}
/** Thrown when getDataFromFile fails to read the underlying file. */
export class StreamFileReadFailedError extends StreamError {
constructor(opts: ErrorOpts & { filePath?: string }) {
super(
STREAM_CODES.FILE_READ_FAILED,
opts.message ?? "File read failed",
{
cause: asErrorCause(opts.cause),
...(opts.filePath
? { context: { filePath: opts.filePath } }
: {}),
}
);
}
}
/** Thrown when getDataFromFile is invoked outside Node.js. */
export class StreamFileBrowserNotSupportedError extends StreamError {
constructor(opts: { message?: string } = {}) {
super(
STREAM_CODES.FILE_BROWSER_NOT_SUPPORTED,
opts.message ??
"getDataFromFile is only available in Node.js environment"
);
}
}
/** Thrown when TransformFactory.create() is asked for an unregistered type. */
export class StreamUnknownTransformTypeError extends StreamError {
constructor(opts: { transformType: string }) {
super(
STREAM_CODES.UNKNOWN_TRANSFORM_TYPE,
`Unknown transform type: "${opts.transformType}"`,
{ context: { transformType: opts.transformType } }
);
}
}

View File

@@ -0,0 +1,133 @@
# TransformFactory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TransformFactory` is the open registry that lets consumers construct `BaseTransform` instances by string type rather than direct class import. It exists for two reasons: first, to enable JSON / dataflow-driven pipeline assembly where the transform name is data and not source code (e.g. a saved DAW project that says `[{ type: "crypto", op: "encrypt" }, { type: "compress" }]`); second, to keep the `streams` package tree-shake-friendly — every built-in transform self-registers from its own barrel module, so a consumer that never imports `crypto/crypto.js` never pays for the AEAD engine code.
The registry is global per process (a single static `Map<string, TransformFactoryFn>` shared by the static and instance APIs). Built-in subclasses (the `MapTransform`, `CryptoTransform`, etc. that land in M2/M3) call `TransformFactory.register(name, fn)` at module load. Consumers who want their own discriminated types call the same method.
## Features
- Static API (`TransformFactory.create`, `register`, `unregister`, `has`, `types`) for direct usage
- Instance API (`new TransformFactory().create(...)` etc.) for DI patterns; delegates to the same shared registry
- Generic-narrowed `create<T>(type, ...args): T` so call sites get the precise subclass type without `instanceof` casting
- `StreamUnknownTransformTypeError` (`STREAM.UNKNOWN_TRANSFORM_TYPE`) thrown when a type is not registered, with the offending type recorded in `error.context.transformType`
- `register(type, fn)` replaces any prior entry under the same type (last write wins) — useful for tests and for plugin override scenarios
- `types()` snapshot returned in insertion order so callers can render deterministic UIs
## Usage: Basic Example
```ts
import { TransformFactory } from "@suaveplan/streams/factory/factory/factory";
import { BaseTransform } from "@suaveplan/streams/base/transform/transform";
class UpperTransform extends BaseTransform {
constructor() {
super("upper");
}
async process<T>(input: T): Promise<T> {
return (typeof input === "string"
? input.toUpperCase()
: input) as unknown as T;
}
}
TransformFactory.register("upper", () => new UpperTransform());
const t = TransformFactory.create("upper");
const out = await t.process("hello"); // "HELLO"
```
## Usage: Advanced Example
```ts
import { TransformFactory } from "@suaveplan/streams/factory/factory/factory";
import { StreamManager } from "@suaveplan/streams/manager/manager/manager";
import {
STREAM_CODES,
StreamUnknownTransformTypeError,
} from "@suaveplan/streams/errors/stream-error";
// Saved pipeline definition coming off the wire:
type SavedStage = { type: string; args?: unknown[] };
function build(stages: readonly SavedStage[]): StreamManager {
const transforms = stages.map((stage) => {
try {
return TransformFactory.create(stage.type, ...(stage.args ?? []));
} catch (err) {
if (err instanceof StreamUnknownTransformTypeError) {
console.warn(
`Skipping unknown transform: ${err.context?.transformType}`
);
throw err;
}
throw err;
}
});
return new StreamManager().pipe(...transforms);
}
const m = build([
{ type: "decode-text" },
{ type: "upper" },
{ type: "encode-text" },
]);
// Generic narrowing at the call site
const crypto = TransformFactory.create<CryptoTransform>("crypto", {
engine,
key,
operation: "encrypt",
});
crypto.hook("crypto:before-chunk", (p) => p);
```
## API Reference
### `TransformFactory`
#### `static create<T>(type: string, ...args: unknown[]): T`
Looks up the factory function for `type` and invokes it with `args`. Returns a fresh `BaseTransform` (or narrower subclass `T`).
**Throws:** `StreamUnknownTransformTypeError` (`STREAM.UNKNOWN_TRANSFORM_TYPE`) if `type` is not registered. The error carries `context.transformType` for diagnostics.
#### `static register(type: string, factory: TransformFactoryFn): void`
Adds (or replaces) a factory under `type`. The factory receives the variadic `args` from `create` and must return a `BaseTransform` instance.
#### `static unregister(type: string): boolean`
Removes a registration. Returns `true` on success, `false` if `type` was not present.
#### `static has(type: string): boolean`
Returns whether `type` is currently registered.
#### `static types(): readonly string[]`
Snapshot of registered type names in insertion order.
#### Instance API
`new TransformFactory()` exposes the same five methods as instance methods that delegate to the static registry. Use the instance form when threading the factory through DI containers; use the static form for direct calls.
### `TransformFactoryFn`
```ts
type TransformFactoryFn = (...args: unknown[]) => BaseTransform;
```
## Implementation Notes
The registry is a single static `Map` shared between the static and instance APIs. This is intentional: a process-wide factory means the saved-pipeline pattern works across module boundaries without consumers having to thread a registry instance everywhere. If multiple isolated registries become useful (e.g. per-tenant transform whitelists in a multi-tenant server), a future change can introduce an explicit `TransformRegistry` class — but until that requirement surfaces, a single shared map is the simplest correct design.
`create<T>` accepts a generic type parameter that narrows the return statically without changing the runtime: it's a bare `as T` cast inside `create`, equivalent to `instanceof` checking but at the call site. Callers that want runtime safety should still `instanceof` the result; consumers that trust their registration are free to skip the check.
Built-in subclasses self-register at module-import time via a side-effecting line in their `index.ts` (e.g. `TransformFactory.register("map", () => new MapTransform(...))`). This means importing the `streams` root barrel registers all built-ins; importing a single subclass via subpath registers only that one. The registration call-out is idempotent under last-write-wins semantics, so re-importing is safe.
The factory does **not** validate that the returned instance is actually a `BaseTransform`. It trusts the registered function. If a consumer registers a faulty factory the cast at the call site will surface the type error; this matches the open-registry pattern in the rest of Suaveplan (`@suaveplan/events` `EventBus`, `@suaveplan/feature-flags` `FlagRegistry`, etc.).

View File

@@ -0,0 +1,52 @@
import {
afterEach,
describe,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import { BaseTransform } from "../../base/transform/transform.js";
import { TransformFactory } from "./factory.js";
class UpperTransform extends BaseTransform {
public readonly tag = "upper" as const;
constructor() {
super("upper");
}
async process<T>(input: T): Promise<T> {
return input;
}
}
afterEach(() => {
for (const t of TransformFactory.types()) {
TransformFactory.unregister(t);
}
});
describe("TransformFactory spec conformance", () => {
it("create() narrows return type by literal type argument", {
timeout: 5000,
}, () => {
TransformFactory.register("upper", () => new UpperTransform());
const t = TransformFactory.create<UpperTransform>("upper");
expectTypeOf(t).toEqualTypeOf<UpperTransform>();
expectTypeOf(t.tag).toEqualTypeOf<"upper">();
});
it("create() default generic resolves to BaseTransform", {
timeout: 5000,
}, () => {
TransformFactory.register("upper", () => new UpperTransform());
const t = TransformFactory.create("upper");
expectTypeOf(t).toEqualTypeOf<BaseTransform>();
});
it("register accepts variadic factory args", { timeout: 5000 }, () => {
const fn: (...args: unknown[]) => BaseTransform = () =>
new UpperTransform();
TransformFactory.register("upper", fn);
expectTypeOf(TransformFactory.types()).toEqualTypeOf<
readonly string[]
>();
});
});

View File

@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { BaseTransform } from "../../base/transform/transform.js";
import {
STREAM_CODES,
StreamUnknownTransformTypeError,
} from "../../errors/stream-error.js";
import { TransformFactory } from "./factory.js";
class UpperTransform extends BaseTransform {
constructor(public readonly suffix = "") {
super("upper");
}
async process<T>(input: T): Promise<T> {
if (typeof input !== "string") return input;
return `${input.toUpperCase()}${this.suffix}` as unknown as T;
}
}
class CounterTransform extends BaseTransform {
public count = 0;
constructor(public readonly start: number) {
super("counter");
}
async process<T>(input: T): Promise<T> {
this.count = this.start + 1;
return input;
}
}
afterEach(() => {
// Each test cleans up its own registrations.
for (const t of TransformFactory.types()) {
TransformFactory.unregister(t);
}
});
describe("TransformFactory", () => {
it("unknown type throws STREAM.UNKNOWN_TRANSFORM_TYPE", {
timeout: 5000,
}, () => {
expect(() => TransformFactory.create("nonexistent")).toThrow(
StreamUnknownTransformTypeError
);
try {
TransformFactory.create("nonexistent");
} catch (err) {
expect(err).toMatchObject({
code: STREAM_CODES.UNKNOWN_TRANSFORM_TYPE,
context: { transformType: "nonexistent" },
});
expect((err as Error).message).toContain("nonexistent");
}
});
it("custom registration is honoured", { timeout: 5000 }, () => {
TransformFactory.register("upper", () => new UpperTransform());
const t = TransformFactory.create("upper");
expect(t).toBeInstanceOf(UpperTransform);
});
it("create() forwards args to the factory function", {
timeout: 5000,
}, () => {
TransformFactory.register(
"counter",
(start) => new CounterTransform(start as number)
);
const t = TransformFactory.create<CounterTransform>("counter", 41);
expect(t.start).toBe(41);
});
it("register replaces a prior entry under the same type", {
timeout: 5000,
}, () => {
TransformFactory.register("upper", () => new UpperTransform("-v1"));
TransformFactory.register("upper", () => new UpperTransform("-v2"));
const t = TransformFactory.create<UpperTransform>("upper");
expect(t.suffix).toBe("-v2");
});
it("unregister returns true on success and false when missing", {
timeout: 5000,
}, () => {
TransformFactory.register("upper", () => new UpperTransform());
expect(TransformFactory.unregister("upper")).toBe(true);
expect(TransformFactory.unregister("upper")).toBe(false);
});
it("has() reflects registry state", { timeout: 5000 }, () => {
expect(TransformFactory.has("upper")).toBe(false);
TransformFactory.register("upper", () => new UpperTransform());
expect(TransformFactory.has("upper")).toBe(true);
TransformFactory.unregister("upper");
expect(TransformFactory.has("upper")).toBe(false);
});
it("types() returns registrations in insertion order", {
timeout: 5000,
}, () => {
TransformFactory.register("upper", () => new UpperTransform());
TransformFactory.register("counter", () => new CounterTransform(0));
expect(TransformFactory.types()).toEqual(["upper", "counter"]);
});
it("instance API delegates to the same shared registry", {
timeout: 5000,
}, () => {
const f = new TransformFactory();
f.register("upper", () => new UpperTransform());
expect(f.has("upper")).toBe(true);
expect(TransformFactory.has("upper")).toBe(true);
expect(f.create("upper")).toBeInstanceOf(UpperTransform);
expect(f.types()).toEqual(["upper"]);
expect(f.unregister("upper")).toBe(true);
expect(f.has("upper")).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
import type { BaseTransform } from "../../base/transform/transform.js";
import { StreamUnknownTransformTypeError } from "../../errors/stream-error.js";
import type { ITransformFactory, TransformFactoryFn } from "./factory.types.js";
/**
* Open transform registry. Built-in transform subclasses (`CryptoTransform`,
* `MapTransform`, …) self-register at module load by calling
* `TransformFactory.register(name, factoryFn)` from their barrel module so
* tree-shaking can drop unused entries.
*
* Both static and instance APIs are provided so DI patterns and direct
* lookups work identically; the instance method delegates to the static
* lookup against a shared registry.
*/
export class TransformFactory implements ITransformFactory {
private static readonly registry = new Map<string, TransformFactoryFn>();
/** Registers a factory function under `type`. Replaces any prior entry. */
static register(type: string, factory: TransformFactoryFn): void {
TransformFactory.registry.set(type, factory);
}
/** Removes a registration. Returns `true` if the type was present. */
static unregister(type: string): boolean {
return TransformFactory.registry.delete(type);
}
/** Returns `true` when `type` is currently registered. */
static has(type: string): boolean {
return TransformFactory.registry.has(type);
}
/** Returns a snapshot of currently-registered type names, in insertion order. */
static types(): readonly string[] {
return Array.from(TransformFactory.registry.keys());
}
/**
* Looks up the factory for `type` and invokes it with `args`. Throws
* `StreamUnknownTransformTypeError` (`STREAM.UNKNOWN_TRANSFORM_TYPE`) if
* the type is not registered. Generic `T` lets callers narrow the return
* type at the call site without an `instanceof` cast — ergonomically
* mirrors the OO factory pattern from `../suaveplan` main while keeping
* the registry open.
*/
static create<T extends BaseTransform = BaseTransform>(
type: string,
...args: unknown[]
): T {
const factory = TransformFactory.registry.get(type);
if (!factory) {
throw new StreamUnknownTransformTypeError({ transformType: type });
}
return factory(...args) as T;
}
register(type: string, factory: TransformFactoryFn): void {
TransformFactory.register(type, factory);
}
unregister(type: string): boolean {
return TransformFactory.unregister(type);
}
has(type: string): boolean {
return TransformFactory.has(type);
}
types(): readonly string[] {
return TransformFactory.types();
}
create<T extends BaseTransform = BaseTransform>(
type: string,
...args: unknown[]
): T {
return TransformFactory.create<T>(type, ...args);
}
}

View File

@@ -0,0 +1,19 @@
import type { BaseTransform } from "../../base/transform/transform.js";
/**
* Factory function registered against a transform type. Receives the variadic
* arguments passed to `TransformFactory.create()` and returns a fresh
* `BaseTransform` instance.
*/
export type TransformFactoryFn = (...args: unknown[]) => BaseTransform;
export interface ITransformFactory {
create<T extends BaseTransform = BaseTransform>(
type: string,
...args: unknown[]
): T;
register(type: string, factory: TransformFactoryFn): void;
unregister(type: string): boolean;
has(type: string): boolean;
types(): readonly string[];
}

View File

@@ -0,0 +1,5 @@
export { TransformFactory } from "./factory.js";
export type {
ITransformFactory,
TransformFactoryFn,
} from "./factory.types.js";

View File

@@ -0,0 +1,82 @@
// Errors
// Plugin substrate
export { BaseTransform } from "./base/transform/transform.js";
export * from "./errors/stream-error.js";
// Factory
export { TransformFactory } from "./factory/factory/factory.js";
// Instrumentation
export * from "./instrument/transform/transform.js";
export * from "./instrument/transform/transform.types.js";
// Manager
export { StreamManager } from "./manager/manager/manager.js";
// Node adapters
export * from "./node/node-adapters.js";
// Pipeline
export * from "./pipeline/builder/builder.js";
export * from "./pipeline/builder/builder.types.js";
export * from "./pipeline/concat/concat.js";
export * from "./pipeline/fan-out/fan-out.js";
export * from "./pipeline/merge/merge.js";
export * from "./pipeline/merge-ordered/merge-ordered.js";
export * from "./pipeline/partition/partition.js";
export * from "./pipeline/route/route.js";
export * from "./pipeline/safe-branch/safe-branch.js";
export * from "./pipeline/zip/zip.js";
// Primitives
export {
BaseDuplexStream,
createDuplexStream,
} from "./primitives/duplex/duplex.js";
export {
createNetworkStream,
NetworkStreamImpl,
} from "./primitives/network/network.js";
// Readable factories
export * from "./readable/from-array/from-array.js";
export * from "./readable/from-bytes/from-bytes.js";
export * from "./readable/from-generator/from-generator.js";
// Sinks
export * from "./sinks/for-each/for-each.js";
export * from "./sinks/to-array/to-array.js";
export * from "./sinks/to-async-iterable/to-async-iterable.js";
export * from "./sinks/to-bytes/to-bytes.js";
// Transforms
export * from "./transforms/batch/batch.js";
export * from "./transforms/chunk-bytes/chunk-bytes.js";
export * from "./transforms/coalesce-bytes/coalesce-bytes.js";
export * from "./transforms/compress/compress.js";
export * from "./transforms/crypto/crypto.js";
export * from "./transforms/dedup/dedup.js";
export * from "./transforms/filter/filter.js";
export * from "./transforms/flat-map/flat-map.js";
export * from "./transforms/length-prefix-decode/length-prefix-decode.js";
export * from "./transforms/length-prefix-encode/length-prefix-encode.js";
export * from "./transforms/limit/limit.js";
export * from "./transforms/map/map.js";
export * from "./transforms/sliding-bytes/sliding-bytes.js";
export * from "./transforms/split-join/split-join.js";
export * from "./transforms/tap/tap.js";
export * from "./transforms/text-codec/text-codec.js";
export * from "./transforms/throttle/throttle.js";
export * from "./transforms/window/window.js";
// Types
export type {
InstrumentOptions,
ReadableOptions,
StreamClock,
TransformOptions,
} from "./types/common.js";
export {
InstrumentOptionsSchema,
ReadableOptionsSchema,
TransformOptionsSchema,
} from "./types/common.js";
// Utils
export * from "./utils/buffer/buffer-to-uint8/buffer-to-uint8.js";
export * from "./utils/buffer/get-data-from-file/get-data-from-file.js";
export * from "./utils/buffer/uint8-slice/uint8-slice.js";
export * from "./utils/stream/create-data-stream/create-data-stream.js";
export * from "./utils/stream/process-data-stream/process-data-stream.js";
export * from "./utils/stream/round-trip/round-trip.js";
export * from "./utils/stream/validate-integrity/validate-integrity.js";

View File

@@ -0,0 +1,2 @@
export { instrumentTransform } from "./transform.js";
export type { InstrumentTransformOptions } from "./transform.types.js";

View File

@@ -0,0 +1,74 @@
# instrument-transform
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instrumentTransform` wraps any `TransformStream<TIn, TOut>` with OpenTelemetry instrumentation: a span per chunk (`stream.transform.<name>`), an optional chunk counter (`stream.chunks_total`), and an optional processing duration histogram (`stream.transform_duration_ms`). Callers inject an OTel `Tracer` and `Meter` instance; when these are not provided, the global no-op tracer is used, so the function is always safe to call regardless of whether OTel is configured.
This is the standard way to add observability to any pipeline stage without modifying the stage's own implementation.
## Features
- Per-chunk OTel span with configurable stage name and attributes
- `stream.chunks_total` counter (attribute: `stream.stage`)
- `stream.transform_duration_ms` histogram (attribute: `stream.stage`)
- No-op when tracer/meter are not provided
- Works with any `TransformStream` without modifying it
## Usage: Basic Example
```ts
import { instrumentTransform } from "@suaveplan/streams/instrument/instrument-transform/instrument-transform";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const result = await toArray(
fromArray([1, 2, 3]).pipeThrough(
instrumentTransform(mapStream((n) => n * 2), "double")
)
);
console.log(result); // [2, 4, 6]
```
## Usage: Advanced Example
```ts
import { instrumentTransform } from "@suaveplan/streams/instrument/instrument-transform/instrument-transform";
import { trace, metrics } from "@opentelemetry/api";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const tracer = trace.getTracer("my-service");
const meter = metrics.getMeter("my-service");
const transform = instrumentTransform(
mapStream((n: number) => n * 2),
"double",
{ tracer, meter, attributes: { "service.name": "my-service" } }
);
const result = await toArray(fromArray([1, 2, 3]).pipeThrough(transform));
// Spans and metrics recorded for each chunk
```
## API Reference
### `instrumentTransform<TIn, TOut>(transform, name, options?): TransformStream<TIn, TOut>`
**Parameters**:
- `transform` — the transform stream to instrument.
- `name` — stage name; used in span names (`stream.transform.<name>`) and metric attributes.
- `options.tracer` — OTel `Tracer` instance (default: global no-op).
- `options.meter` — OTel `Meter` instance (default: none; counters/histograms omitted).
- `options.attributes` — additional span/metric attributes.
**Returns**: A new `TransformStream<TIn, TOut>` with OTel instrumentation wrapping the original.
## Implementation Notes
The instrumented transform is implemented by creating separate `ReadableStream` and `WritableStream` wrappers. The writable side writes each chunk to the original writable, measuring duration and recording metrics. The readable side pumps from the original readable into a new controller. This approach avoids modifying the transform's internal implementation while still providing full observability.

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { mapStream } from "../../transforms/map/map.js";
import { instrumentTransform } from "./transform.js";
describe("instrumentTransform", () => {
it("passes chunks through without modification", {
timeout: 30000,
}, async () => {
const result = await toArray(
fromArray([1, 2, 3]).pipeThrough(
instrumentTransform(
mapStream((n: number) => n * 2),
"double"
)
)
);
expect(result).toEqual([2, 4, 6]);
});
it("works with no-op tracer/meter (no options)", {
timeout: 30000,
}, async () => {
const result = await toArray(
fromArray(["a", "b"]).pipeThrough(
instrumentTransform(
mapStream((s: string) => s.toUpperCase()),
"upper"
)
)
);
expect(result).toEqual(["A", "B"]);
});
it("records chunk counts when meter is provided", {
timeout: 30000,
}, async () => {
const addFn = vi.fn();
const meter = {
createCounter: () => ({ add: addFn }),
createHistogram: () => ({ record: vi.fn() }),
};
const result = await toArray(
fromArray([1, 2, 3]).pipeThrough(
instrumentTransform(
mapStream((n: number) => n),
"identity",
{
meter: meter as unknown as import("@opentelemetry/api").Meter,
}
)
)
);
expect(result).toEqual([1, 2, 3]);
expect(addFn).toHaveBeenCalledTimes(3);
});
it("handles empty stream", { timeout: 30000 }, async () => {
const result = await toArray(
fromArray<number>([]).pipeThrough(
instrumentTransform(
mapStream((n: number) => n),
"empty"
)
)
);
expect(result).toEqual([]);
});
it("applies custom attributes to metrics", { timeout: 30000 }, async () => {
const addFn = vi.fn();
const meter = {
createCounter: () => ({ add: addFn }),
createHistogram: () => ({ record: vi.fn() }),
};
await toArray(
fromArray([1]).pipeThrough(
instrumentTransform(
mapStream((n: number) => n),
"stage",
{
meter: meter as unknown as import("@opentelemetry/api").Meter,
attributes: { "custom.attr": "value" },
}
)
)
);
expect(addFn).toHaveBeenCalledWith(
1,
expect.objectContaining({ "custom.attr": "value" })
);
});
it("writable close handler completes stream", {
timeout: 30000,
}, async () => {
const seen: number[] = [];
// Use pipeTo to trigger the close handler on the writable side
const instrumented = instrumentTransform(
mapStream((n: number) => n * 3),
"triple"
);
const source = fromArray([1, 2]);
const result: number[] = [];
await source.pipeThrough(instrumented).pipeTo(
new WritableStream({
write(c) {
result.push(c);
},
})
);
expect(result).toEqual([3, 6]);
void seen;
});
it("writable abort handler propagates abort", {
timeout: 30000,
}, async () => {
const instrumented = instrumentTransform(
mapStream((n: number) => n),
"passthrough"
);
const writer = instrumented.writable.getWriter();
await writer.abort(new Error("test abort"));
// readable side should also be errored
const reader = instrumented.readable.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(Error);
});
it("write error path records error span status", {
timeout: 30000,
}, async () => {
const statusFn = vi.fn();
const tracer = {
startSpan: () => ({
setStatus: statusFn,
end: vi.fn(),
setAttribute: vi.fn(),
}),
};
// Manually build a TransformStream whose writable.write() rejects
const rejectingWritable = new WritableStream<number>({
write() {
return Promise.reject(new Error("write error"));
},
});
const passThroughReadable = new ReadableStream<number>({
start(controller) {
controller.close();
},
});
const erroringTransform: TransformStream<number, number> = {
readable: passThroughReadable,
writable: rejectingWritable,
};
const instrumented = instrumentTransform(
erroringTransform,
"error-stage",
{
tracer: tracer as unknown as import("@opentelemetry/api").Tracer,
}
);
const writer = instrumented.writable.getWriter();
await expect(writer.write(1)).rejects.toBeInstanceOf(Error);
expect(statusFn).toHaveBeenCalledWith(
expect.objectContaining({ code: expect.anything() })
);
});
});

View File

@@ -0,0 +1,105 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import type { InstrumentTransformOptions } from "./transform.types.js";
const TRACER_NAME = "@suaveplan/streams";
/**
* Wraps a TransformStream with OTel span + chunk counter instrumentation.
* Each chunk passing through creates a span and increments a counter.
*/
export function instrumentTransform<TIn, TOut>(
transform: TransformStream<TIn, TOut>,
name: string,
options: InstrumentTransformOptions = {}
): TransformStream<TIn, TOut> {
const { tracer: userTracer, meter, attributes = {} } = options;
const tracer = userTracer ?? trace.getTracer(TRACER_NAME);
const chunkCounter = meter?.createCounter("stream.chunks_total", {
description: "Number of chunks processed by stream stage",
});
const durationHistogram = meter?.createHistogram(
"stream.transform_duration_ms",
{
description:
"Duration in milliseconds of stream transform per chunk",
unit: "ms",
}
);
const readable = transform.readable;
const writable = transform.writable;
const instrumentedReadable = new ReadableStream<TOut>({
start(controller) {
const reader = readable.getReader();
function pump(): Promise<void> {
return reader
.read()
.then(({ value, done }) => {
if (done) {
controller.close();
return;
}
controller.enqueue(value);
return pump();
})
.catch((err) => {
controller.error(err);
});
}
pump();
},
});
const instrumentedWritable = new WritableStream<TIn>({
write(chunk) {
const spanAttrs = {
"stream.stage": name,
...attributes,
};
const span = tracer.startSpan(`stream.transform.${name}`, {
attributes: spanAttrs,
});
const startMs = Date.now();
return new Promise<void>((resolve, reject) => {
const writer = writable.getWriter();
writer
.write(chunk)
.then(() => {
writer.releaseLock();
const durationMs = Date.now() - startMs;
chunkCounter?.add(1, spanAttrs);
durationHistogram?.record(durationMs, spanAttrs);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
resolve();
})
.catch((err) => {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(err),
});
span.end();
writer.releaseLock();
reject(err);
});
});
},
close() {
const writer = writable.getWriter();
return writer.close().finally(() => writer.releaseLock());
},
abort(reason) {
const writer = writable.getWriter();
return writer.abort(reason).finally(() => writer.releaseLock());
},
});
return {
readable: instrumentedReadable,
writable: instrumentedWritable,
};
}

View File

@@ -0,0 +1,9 @@
import type { Meter, Tracer } from "@opentelemetry/api";
/** Options for instrumenting a transform stream. */
export interface InstrumentTransformOptions {
tracer?: Tracer;
meter?: Meter;
/** Additional attributes to attach to spans/metrics. */
attributes?: Record<string, string>;
}

View File

@@ -0,0 +1,2 @@
export { StreamManager } from "./manager.js";
export type { ExecuteOptions, IStreamManager } from "./manager.types.js";

View File

@@ -0,0 +1,137 @@
# StreamManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StreamManager` is the high-level pipeline orchestrator for `@suaveplan/streams`. It wraps a list of `BaseTransform` instances and threads an input value through their `process` methods sequentially, propagating `ExecuteOptions` (including `AbortSignal`) end-to-end. It is the canonical entry point when callers want a one-shot "transform this value through these N stages" call without managing WHATWG `ReadableStream` plumbing themselves.
The manager is intentionally minimal: it does not own its own event surface, queue, or scheduler. It composes `BaseTransform` instances exactly as constructed, so all observability (hooks, events, OTel spans) lives where it should — on the individual transforms — not bolted on at the orchestrator layer. This keeps `StreamManager` cheap to allocate, predictable, and trivially serializable as a pipeline definition.
## Features
- Chainable `pipe(...transforms)` / `clear()` / `getTransforms()` API
- `execute<T>(input, options?)` runs the pipeline and returns the final value
- Errors thrown by individual transforms are wrapped in `StreamTransformFailedError` so callers see a stable code; existing `StreamError` subclasses (e.g. `StreamAbortedError`) propagate unchanged so abort and source-specific failures keep their semantic identity
- `AbortSignal` is checked between every stage, not just at start
- Empty pipeline returns input unchanged with referential equality
- `pipeline` getter exposes the live list (`readonly BaseTransform[]`) for callers that want to introspect the pipeline shape
## Usage: Basic Example
```ts
import { StreamManager } from "@suaveplan/streams/manager/manager/manager";
import { BaseTransform } from "@suaveplan/streams/base/transform/transform";
class UpperTransform extends BaseTransform {
constructor() {
super("upper");
}
async process<T>(input: T): Promise<T> {
return (typeof input === "string"
? input.toUpperCase()
: input) as unknown as T;
}
}
class ExclaimTransform extends BaseTransform {
constructor() {
super("exclaim");
}
async process<T>(input: T): Promise<T> {
return `${String(input)}!` as unknown as T;
}
}
const out = await new StreamManager()
.pipe(new UpperTransform(), new ExclaimTransform())
.execute("hello");
// "HELLO!"
```
## Usage: Advanced Example
```ts
import { StreamManager } from "@suaveplan/streams/manager/manager/manager";
import { BaseTransform } from "@suaveplan/streams/base/transform/transform";
import { STREAM_CODES } from "@suaveplan/streams/errors/stream-error";
const ctrl = new AbortController();
const m = new StreamManager().pipe(
new SlowTransform(),
new EncryptTransform(key),
new RedactTransform()
);
setTimeout(() => ctrl.abort(new Error("user cancelled")), 50);
try {
const out = await m.execute(payload, {
signal: ctrl.signal,
clock: () => Date.now(),
logger,
});
} catch (err) {
if ((err as { code?: string }).code === STREAM_CODES.ABORTED) {
// User cancelled mid-pipeline; cleanup
} else if ((err as { code?: string }).code === STREAM_CODES.TRANSFORM_FAILED) {
// A transform threw a non-StreamError; cause is on err.cause
}
}
// Re-use the manager for a different input — pipeline persists across calls
const second = await m.execute(otherPayload);
// Or swap the pipeline entirely
m.clear().pipe(new ProbeTransform()).execute("ping");
```
## API Reference
### `StreamManager`
#### `pipe(...transforms: BaseTransform[]): StreamManager`
Replaces the current pipeline with `transforms` in order. Returns `this` for chaining. Calling `pipe` more than once swaps the entire pipeline; use `clear()` followed by `pipe()` if you prefer to be explicit, but both produce the same result.
#### `execute<T>(input: T, options?: ExecuteOptions): Promise<T>`
Threads `input` through every transform's `process(value, options)` in order. Returns the final value. Errors:
- `StreamAbortedError` (`STREAM.ABORTED`) — `options.signal` was already aborted before execution began, or fired between two stages. The signal is also forwarded to each transform's `process` call so subclasses honoring `assertNotAborted` reject mid-stage.
- `StreamTransformFailedError` (`STREAM.TRANSFORM_FAILED`) — a transform threw a non-`StreamError`. The original throw is preserved on `error.cause`; the failing transform's `name` appears in the message.
- Any `StreamError` subclass thrown by a transform (e.g. `StreamReadFailedError`) propagates verbatim — no double-wrap.
#### `clear(): StreamManager`
Empties the pipeline. Returns `this` for chaining.
#### `getTransforms(): readonly BaseTransform[]`
Returns the current transform list, in order. Useful for testing and introspection.
#### `pipeline: readonly BaseTransform[]`
Read-only getter equivalent to `getTransforms()`. The two coexist because `pipeline` reads cleaner inline (`m.pipeline.length`) while `getTransforms()` is preferred from explicit interface code paths.
### `ExecuteOptions`
```ts
interface ExecuteOptions {
signal?: AbortSignal;
clock?: Clock;
logger?: LoggerLike;
}
```
This shape is identical to `TransformOptions` so the same options object can be threaded straight through every stage without restructuring.
## Implementation Notes
The manager checks `options.signal` three times per stage: once before the loop, once at the top of every iteration, and once after the final stage. The redundancy is intentional — it ensures abort latency is bounded by one transform's runtime, not by the entire pipeline length.
`StreamError` subclasses are caught and re-thrown unwrapped so consumers that already match on specific codes (e.g. `STREAM.ABORTED`, `STREAM.READ_FAILED`) keep their semantic match. Only generic `Error` instances become `StreamTransformFailedError` — that distinction means `try { await m.execute(...) } catch (e) { if (e.code === STREAM.READ_FAILED) ... }` continues to work even if the failing transform is buried mid-pipeline.
The pipeline list is a plain array; `pipe(...transforms)` replaces it via assignment, not mutation, so consumers holding a prior reference (via `getTransforms()` or `pipeline`) still see the snapshot they captured. This is convenient for diagnostics that want to log "pipeline at error time" without racing against `clear()`.
`StreamManager` does not currently own a benchmark suite of its own — its overhead is dominated by the transforms it wraps, so benchmarks live alongside the heavy-lift transforms (`CryptoTransform`, byte-framing) rather than here.

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BaseTransform } from "../../base/transform/transform.js";
import type { TransformOptions } from "../../base/transform/transform.types.js";
import {
STREAM_CODES,
StreamAbortedError,
StreamReadFailedError,
StreamTransformFailedError,
} from "../../errors/stream-error.js";
import { StreamManager } from "./manager.js";
class AppendTransform extends BaseTransform {
constructor(private readonly suffix: string) {
super(`append:${suffix}`);
}
async process<T = unknown>(
input: T,
options?: TransformOptions
): Promise<T> {
this.assertNotAborted(options);
return `${String(input)}${this.suffix}` as unknown as T;
}
}
class BoomTransform extends BaseTransform {
constructor() {
super("boom");
}
async process<T>(_input: T): Promise<T> {
throw new Error("kaboom");
}
}
class StreamErrorTransform extends BaseTransform {
constructor() {
super("stream-err");
}
async process<T>(_input: T): Promise<T> {
throw new StreamReadFailedError({ message: "specific" });
}
}
class CountingTransform extends BaseTransform {
public calls = 0;
constructor() {
super("counter");
}
async process<T>(input: T): Promise<T> {
this.calls += 1;
return input;
}
}
describe("StreamManager", () => {
it("pipe chains transforms in order", { timeout: 5000 }, async () => {
const m = new StreamManager().pipe(
new AppendTransform("-a"),
new AppendTransform("-b"),
new AppendTransform("-c")
);
await expect(m.execute("x")).resolves.toBe("x-a-b-c");
});
it("empty pipeline returns input unchanged", {
timeout: 5000,
}, async () => {
const m = new StreamManager();
const input = { ref: 1 };
const out = await m.execute(input);
expect(out).toBe(input);
});
it("error in middle transform aborts downstream and wraps in STREAM.TRANSFORM_FAILED", {
timeout: 5000,
}, async () => {
const c = new CountingTransform();
const m = new StreamManager().pipe(
new AppendTransform("-a"),
new BoomTransform(),
c
);
await expect(m.execute("x")).rejects.toMatchObject({
code: STREAM_CODES.TRANSFORM_FAILED,
});
await expect(m.execute("x")).rejects.toBeInstanceOf(
StreamTransformFailedError
);
expect(c.calls).toBe(0);
});
it("wrapped error preserves the original throw via cause", {
timeout: 5000,
}, async () => {
const m = new StreamManager().pipe(new BoomTransform());
await expect(m.execute("x")).rejects.toMatchObject({
code: STREAM_CODES.TRANSFORM_FAILED,
cause: expect.objectContaining({ message: "kaboom" }),
});
});
it("existing StreamError propagates unchanged (no double wrap)", {
timeout: 5000,
}, async () => {
const m = new StreamManager().pipe(new StreamErrorTransform());
const err = await m
.execute("x")
.then(() => null)
.catch((e) => e);
expect(err).toBeInstanceOf(StreamReadFailedError);
expect((err as { code: string }).code).toBe(STREAM_CODES.READ_FAILED);
});
it("clear() empties pipeline", { timeout: 5000 }, async () => {
const m = new StreamManager().pipe(new AppendTransform("-a"));
expect(m.getTransforms()).toHaveLength(1);
m.clear();
expect(m.getTransforms()).toHaveLength(0);
await expect(m.execute("x")).resolves.toBe("x");
});
it("pipe returns the manager for chaining", { timeout: 5000 }, () => {
const m = new StreamManager();
expect(m.pipe(new AppendTransform("-a"))).toBe(m);
expect(m.clear()).toBe(m);
});
it("pipeline getter exposes the live transform list", {
timeout: 5000,
}, () => {
const t = new AppendTransform("-a");
const m = new StreamManager().pipe(t);
expect(m.pipeline).toEqual([t]);
});
it("execute rejects with STREAM.ABORTED when signal already fired", {
timeout: 5000,
}, async () => {
const m = new StreamManager().pipe(new AppendTransform("-a"));
const ctrl = new AbortController();
const reason = new Error("user cancelled");
ctrl.abort(reason);
await expect(
m.execute("x", { signal: ctrl.signal })
).rejects.toMatchObject({
code: STREAM_CODES.ABORTED,
cause: reason,
});
});
it("execute aborts between stages when signal fires mid-pipeline", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
class AbortMidway extends BaseTransform {
constructor() {
super("abort-midway");
}
async process<T>(input: T): Promise<T> {
ctrl.abort(new Error("mid"));
return input;
}
}
const c = new CountingTransform();
const m = new StreamManager().pipe(new AbortMidway(), c);
await expect(
m.execute("x", { signal: ctrl.signal })
).rejects.toBeInstanceOf(StreamAbortedError);
expect(c.calls).toBe(0);
});
it("execute on empty pipeline still honors abort", {
timeout: 5000,
}, async () => {
const m = new StreamManager();
const ctrl = new AbortController();
ctrl.abort(new Error("nope"));
await expect(
m.execute("x", { signal: ctrl.signal })
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("execute threads options through to transforms", {
timeout: 5000,
}, async () => {
class ObservingTransform extends BaseTransform {
public seen: TransformOptions | undefined;
constructor() {
super("observer");
}
async process<T>(input: T, options?: TransformOptions): Promise<T> {
this.seen = options;
return input;
}
}
const obs = new ObservingTransform();
const opts: TransformOptions = { clock: () => 42 };
await new StreamManager().pipe(obs).execute("x", opts);
expect(obs.seen).toBe(opts);
});
});

View File

@@ -0,0 +1,80 @@
import type { BaseTransform } from "../../base/transform/transform.js";
import {
StreamAbortedError,
StreamError,
StreamTransformFailedError,
} from "../../errors/stream-error.js";
import type { ExecuteOptions, IStreamManager } from "./manager.types.js";
/**
* Chainable pipeline orchestrator. Threads an input value through every
* registered {@link BaseTransform.process} in order, propagating any
* `ExecuteOptions` (including `AbortSignal`) verbatim. Errors thrown by an
* individual transform are wrapped in {@link StreamTransformFailedError} so
* callers can match on a stable code without unwrapping arbitrary cause
* chains; an already-`StreamError` propagates unchanged so abort and
* source-specific failures keep their semantic identity.
*/
export class StreamManager implements IStreamManager {
private transforms: BaseTransform[] = [];
/** Live view of the current pipeline. */
get pipeline(): readonly BaseTransform[] {
return this.transforms;
}
/** Replaces the current pipeline with the supplied transforms in order. */
pipe(...transforms: BaseTransform[]): StreamManager {
this.transforms = transforms;
return this;
}
/**
* Executes the pipeline. The `input` is fed to the first transform's
* `process` and each subsequent transform receives the prior's output.
*
* - Honors `options.signal` between every stage and rejects with
* {@link StreamAbortedError} when fired.
* - Wraps non-`StreamError` throws in
* {@link StreamTransformFailedError} so consumers see a stable code.
* - Returns `input` unchanged for an empty pipeline (referential equality).
*/
async execute<T = unknown>(input: T, options?: ExecuteOptions): Promise<T> {
this.assertNotAborted(options);
if (this.transforms.length === 0) {
return input;
}
let value: unknown = input;
for (const transform of this.transforms) {
this.assertNotAborted(options);
try {
value = await transform.process(value, options);
} catch (err) {
if (err instanceof StreamError) throw err;
throw new StreamTransformFailedError({
cause: err,
message: `Transform "${transform.name}" failed`,
});
}
}
this.assertNotAborted(options);
return value as T;
}
/** Empties the pipeline. Returns `this` for chaining. */
clear(): StreamManager {
this.transforms = [];
return this;
}
/** Returns the transforms currently registered, in order. */
getTransforms(): readonly BaseTransform[] {
return this.transforms;
}
private assertNotAborted(options?: ExecuteOptions): void {
if (options?.signal?.aborted) {
throw new StreamAbortedError({ cause: options.signal.reason });
}
}
}

View File

@@ -0,0 +1,21 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import type { BaseTransform } from "../../base/transform/transform.js";
/**
* Runtime options accepted by {@link StreamManager.execute}. Mirrors the
* shape of `TransformOptions` so the same options object can be threaded
* straight through to every transform's `process` call.
*/
export interface ExecuteOptions {
readonly signal?: AbortSignal;
readonly clock?: Clock;
readonly logger?: LoggerLike;
}
export interface IStreamManager {
readonly pipeline: readonly BaseTransform[];
pipe(...transforms: BaseTransform[]): IStreamManager;
execute<T = unknown>(input: T, options?: ExecuteOptions): Promise<T>;
clear(): IStreamManager;
getTransforms(): readonly BaseTransform[];
}

View File

@@ -0,0 +1,61 @@
# node-adapters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides bidirectional adapters between Node.js streams (`node:stream` `Readable` / `Writable`) and WHATWG streams (`ReadableStream<Uint8Array>` / `WritableStream<Uint8Array>`). These adapters are essential for interoperating with the Node.js ecosystem — reading files via `fs.createReadStream`, writing to HTTP response streams, or integrating with third-party Node.js stream-based libraries — while keeping the rest of the pipeline in standard WHATWG stream primitives.
All adapters handle the three critical lifecycle events: data flow, end-of-stream, and error propagation. Node.js `Buffer` instances are converted to `Uint8Array` views without copying.
## Features
- `fromNodeReadable`: Node.js `Readable` → `ReadableStream<Uint8Array>` (zero-copy Buffer views)
- `toNodeReadable`: `ReadableStream<Uint8Array>` → Node.js `Readable`
- `fromNodeWritable`: Node.js `Writable` → `WritableStream<Uint8Array>`
- Errors wrapped as `StreamPipeFailedError` on both sides
- Stream cancellation propagates to the underlying Node.js stream via `destroy()`
## Usage: Basic Example
```ts
import { fromNodeReadable } from "@suaveplan/streams/node/node-adapters";
import { createReadStream } from "node:fs";
import { toBytes } from "@suaveplan/streams/sinks/to-bytes/to-bytes";
const nodeStream = createReadStream("/path/to/file.bin");
const whatwgStream = fromNodeReadable(nodeStream);
const bytes = await toBytes(whatwgStream);
console.log(bytes.byteLength);
```
## Usage: Advanced Example
```ts
import { fromNodeReadable, fromNodeWritable } from "@suaveplan/streams/node/node-adapters";
import { createReadStream, createWriteStream } from "node:fs";
import { compressStream } from "@suaveplan/streams/transforms/compress/compress";
const readStream = fromNodeReadable(createReadStream("input.txt"));
const writeStream = fromNodeWritable(createWriteStream("output.gz"));
await readStream.pipeThrough(compressStream("gzip")).pipeTo(writeStream);
```
## API Reference
### `fromNodeReadable(readable: Readable): ReadableStream<Uint8Array>`
Wraps a Node.js `Readable` in a WHATWG `ReadableStream`. `Buffer` chunks are converted to zero-copy `Uint8Array` views.
### `toNodeReadable(stream: ReadableStream<Uint8Array>): Readable`
Creates a Node.js `Readable` that pulls from a WHATWG stream. Cancellation propagates via `destroy()`.
### `fromNodeWritable(writable: Writable): WritableStream<Uint8Array>`
Wraps a Node.js `Writable` in a WHATWG `WritableStream`. Write callbacks resolve/reject based on the Node.js callback.
## Implementation Notes
`Buffer` to `Uint8Array` conversion uses `new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)` which creates a view into the same underlying `ArrayBuffer` without copying. Error events from the Node.js stream are caught and re-thrown as `StreamPipeFailedError` with the original error as the cause. The `cancel` method calls `readable.destroy()` which immediately terminates the Node.js stream and triggers its `destroy` callback.

View File

@@ -0,0 +1,252 @@
import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "../readable/from-array/from-array.js";
import { toArray } from "../sinks/to-array/to-array.js";
import { toBytes } from "../sinks/to-bytes/to-bytes.js";
import {
fromNodeReadable,
fromNodeWritable,
toNodeReadable,
} from "./node-adapters.js";
describe("fromNodeReadable", () => {
it("reads all chunks from a Node readable", { timeout: 5000 }, async () => {
const nodeStream = Readable.from([
Buffer.from("hello"),
Buffer.from(" world"),
]);
const whatwg = fromNodeReadable(nodeStream);
const bytes = await toBytes(whatwg);
expect(new TextDecoder().decode(bytes)).toBe("hello world");
});
it("handles errors from Node readable", { timeout: 5000 }, async () => {
const nodeStream = new Readable({
read() {
this.emit("error", new Error("node stream error"));
},
});
const whatwg = fromNodeReadable(nodeStream);
const reader = whatwg.getReader();
await expect(reader.read()).rejects.toThrow();
});
it("handles string chunks", { timeout: 5000 }, async () => {
// Provide a string chunk directly via custom readable
let sent = false;
const nodeStream = new Readable({
encoding: "utf8",
read() {
if (!sent) {
sent = true;
this.push("hello", "utf8");
this.push(null);
}
},
});
const whatwg = fromNodeReadable(nodeStream);
const bytes = await toBytes(whatwg);
expect(new TextDecoder().decode(bytes)).toBe("hello");
});
it("cancel calls readable.destroy", { timeout: 5000 }, async () => {
let destroyed = false;
const nodeStream = new Readable({
read() {
/* never produces data */
},
destroy(_err: Error | null, cb: (err?: Error | null) => void) {
destroyed = true;
cb();
},
});
const whatwg = fromNodeReadable(nodeStream);
const reader = whatwg.getReader();
await reader.cancel();
expect(destroyed).toBe(true);
});
it("handles raw Uint8Array chunk (not Buffer)", {
timeout: 5000,
}, async () => {
// Emit a plain Uint8Array (not a Buffer) to exercise the : chunk else branch.
// We emit the chunk directly via the 'data' event to bypass Node's Buffer wrapping.
const nodeStream = new Readable({ read() {} });
const whatwg = fromNodeReadable(nodeStream);
const bytesPromise = toBytes(whatwg);
const raw = new Uint8Array([42, 43]);
// Emit directly so the chunk arrives as Uint8Array (not Buffer)
nodeStream.emit("data", raw);
nodeStream.emit("end");
const result = await bytesPromise;
expect(Array.from(result)).toEqual([42, 43]);
});
});
describe("toNodeReadable", () => {
it("converts WHATWG stream to Node readable", {
timeout: 5000,
}, async () => {
const whatwg = fromArray([
new Uint8Array([1, 2, 3]),
new Uint8Array([4, 5]),
]);
const node = toNodeReadable(whatwg);
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
node.on("data", (chunk: Buffer) => chunks.push(chunk));
node.on("end", resolve);
node.on("error", reject);
});
const total = Buffer.concat(chunks);
expect(Array.from(total)).toEqual([1, 2, 3, 4, 5]);
});
it("destroy cancels the underlying WHATWG reader", {
timeout: 5000,
}, async () => {
let cancelled = false;
const whatwg = new ReadableStream<Uint8Array>({
pull() {
return new Promise(() => {});
},
cancel() {
cancelled = true;
},
});
const node = toNodeReadable(whatwg);
// Read one empty pull cycle then destroy
node.destroy();
await new Promise((r) => setTimeout(r, 30));
expect(cancelled).toBe(true);
});
it("destroy with error passes error to cancel", {
timeout: 5000,
}, async () => {
let cancelledWithReason: unknown;
const whatwg = new ReadableStream<Uint8Array>({
pull() {
return new Promise(() => {});
},
cancel(reason) {
cancelledWithReason = reason;
},
});
const node = toNodeReadable(whatwg);
node.on("error", () => {}); // suppress unhandled error event
const destroyErr = new Error("destroy-error");
node.destroy(destroyErr);
await new Promise((r) => setTimeout(r, 30));
expect(cancelledWithReason).toBe(destroyErr);
});
});
describe("fromNodeWritable", () => {
it("pipes chunks to Node writable", { timeout: 5000 }, async () => {
const received: Uint8Array[] = [];
const node = new Writable({
write(chunk: Buffer, _enc, cb) {
received.push(new Uint8Array(chunk));
cb();
},
});
const whatwg = fromNodeWritable(node);
const writer = whatwg.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();
expect(received).toHaveLength(1);
expect(Array.from(received[0])).toEqual([1, 2, 3]);
});
it("abort calls writable.destroy", { timeout: 5000 }, async () => {
let destroyed = false;
const node = new Writable({
write(_chunk, _enc, cb) {
cb();
},
destroy(_err, cb) {
destroyed = true;
cb();
},
});
const whatwg = fromNodeWritable(node);
const writer = whatwg.getWriter();
await writer.abort(new Error("abort"));
expect(destroyed).toBe(true);
});
it("abort with non-Error reason wraps it in Error", {
timeout: 5000,
}, async () => {
// Exercises: reason instanceof Error ? reason : new Error(String(reason))
let destroyedWithError: Error | null = null;
const node = new Writable({
write(_chunk, _enc, cb) {
cb();
},
destroy(err, cb) {
destroyedWithError = err;
cb();
},
});
const whatwg = fromNodeWritable(node);
const writer = whatwg.getWriter();
await writer.abort("string reason");
expect(destroyedWithError).toBeInstanceOf(Error);
expect((destroyedWithError as Error).message).toContain(
"string reason"
);
});
it("write rejects when Node writable callback has error", {
timeout: 5000,
}, async () => {
// Exercises the if (err) reject(...) branch in the write callback
const writeErr = new Error("write-error");
const node = new Writable({
write(_chunk, _enc, cb) {
cb(writeErr);
},
});
node.on("error", () => {}); // suppress unhandled error event
const whatwg = fromNodeWritable(node);
const writer = whatwg.getWriter();
await expect(writer.write(new Uint8Array([1]))).rejects.toBeInstanceOf(
Error
);
});
it("close rejects when writable.end emits an error", {
timeout: 5000,
}, async () => {
// Spy on the writable's end() call and immediately emit an error
const endErr = new Error("end-error");
let resolveEnd!: () => void;
const node = new Writable({
write(_chunk, _enc, cb) {
cb();
},
});
// Monkey-patch .end() to call the callback with an error immediately
const origEnd = node.end.bind(node);
node.end = ((cb?: ((err?: Error | null) => void) | undefined) => {
if (typeof cb === "function") {
cb(endErr);
}
void resolveEnd;
return origEnd();
}) as typeof node.end;
node.on("error", () => {}); // suppress unhandled error
const whatwg = fromNodeWritable(node);
const writer = whatwg.getWriter();
await expect(writer.close()).rejects.toBeInstanceOf(Error);
});
});
describe("toArray with streams", () => {
it("collects from fromArray helper", { timeout: 5000 }, async () => {
const result = await toArray(fromArray([10, 20, 30]));
expect(result).toEqual([10, 20, 30]);
});
});

View File

@@ -0,0 +1,101 @@
import { Readable, type Writable } from "node:stream";
import { StreamPipeFailedError } from "../errors/stream-error.js";
/**
* Converts a Node.js Readable stream to a WHATWG ReadableStream<Uint8Array>.
*/
export function fromNodeReadable(
readable: Readable
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
readable.on("data", (chunk: Buffer | Uint8Array | string) => {
const bytes =
typeof chunk === "string"
? new TextEncoder().encode(chunk)
: chunk instanceof Buffer
? new Uint8Array(
chunk.buffer,
chunk.byteOffset,
chunk.byteLength
)
: chunk;
controller.enqueue(bytes);
});
readable.on("end", () => {
controller.close();
});
readable.on("error", (err) => {
controller.error(new StreamPipeFailedError({ cause: err }));
});
},
cancel() {
readable.destroy();
},
});
}
/**
* Converts a WHATWG ReadableStream<Uint8Array> to a Node.js Readable stream.
*/
export function toNodeReadable(stream: ReadableStream<Uint8Array>): Readable {
const reader = stream.getReader();
let nodeReadable: Readable;
const readable = new Readable({
read() {
reader.read().then(
({ value, done }) => {
if (done) {
nodeReadable.push(null);
} else {
nodeReadable.push(Buffer.from(value));
}
},
/* istanbul ignore next -- WHATWG controller.error() creates unhandled-rejection noise in Node.js/Vitest */
(err: unknown) => {
nodeReadable.destroy(
err instanceof Error ? err : new Error(String(err))
);
}
);
},
destroy(err, callback) {
reader.cancel(err ?? undefined).finally(() => callback(err));
},
});
nodeReadable = readable;
return readable;
}
/**
* Converts a Node.js Writable stream to a WHATWG WritableStream<Uint8Array>.
*/
export function fromNodeWritable(
writable: Writable
): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise((resolve, reject) => {
writable.write(chunk, (err) => {
if (err) reject(new StreamPipeFailedError({ cause: err }));
else resolve();
});
});
},
close() {
return new Promise((resolve, reject) => {
writable.end((err?: Error | null) => {
if (err) reject(new StreamPipeFailedError({ cause: err }));
else resolve();
});
});
},
abort(reason) {
writable.destroy(
reason instanceof Error ? reason : new Error(String(reason))
);
return Promise.resolve();
},
});
}

View File

@@ -0,0 +1,90 @@
# pipeline-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PipelineBuilder` is a fluent, type-safe wrapper for composing WHATWG stream pipelines. Starting from a `ReadableStream<T>`, each call to `.pipe()` returns a new `PipelineBuilder<TNext>` typed to the transform's output, making the full pipeline statically typed from source to sink. Terminal methods (`collect`, `drain`, `forEach`, `drainToWritable`) consume the pipeline and return a Promise.
The builder is a convenience layer over `ReadableStream.pipeThrough` — it does not buffer, copy, or re-implement backpressure. Every `pipe()` call delegates directly to the WHATWG `pipeThrough` method.
## Features
- Fluent `.pipe(transform)` and `.pipe(fn)` chaining with full TypeScript inference
- Terminal: `collect()`, `drain()`, `forEach()`, `drainToWritable()`
- `toReadable()` escape hatch for raw stream access
- Optional global `AbortSignal` in `PipelineOptions` applied to all terminals
- Per-terminal signal override
## Usage: Basic Example
```ts
import { pipeline } from "@suaveplan/streams/pipeline/pipeline-builder/pipeline-builder";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { filterStream } from "@suaveplan/streams/transforms/filter-stream/filter-stream";
const result = await pipeline(fromArray([1, 2, 3, 4, 5]))
.pipe(filterStream((n) => n % 2 === 0))
.pipe(mapStream((n) => n * 10))
.collect();
console.log(result); // [20, 40]
```
## Usage: Advanced Example
```ts
import { pipeline } from "@suaveplan/streams/pipeline/pipeline-builder/pipeline-builder";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
const ac = new AbortController();
const result = await pipeline(fromArray(["hello", "world"]), { signal: ac.signal })
.pipe((s) => s.toUpperCase()) // inline function
.pipe((s) => `[${s}]`) // another inline function
.collect();
console.log(result); // ["[HELLO]", "[WORLD]"]
// Drain to writable
const received: string[] = [];
const writable = new WritableStream<string>({ write(c) { received.push(c); } });
await pipeline(fromArray(["a", "b"])).drainToWritable(writable);
console.log(received); // ["a", "b"]
```
## API Reference
### `pipeline<T>(source: ReadableStream<T>, options?: PipelineOptions): PipelineBuilder<T>`
Creates a new builder from `source`.
### `PipelineBuilder<T>.pipe<TNext>(transform): PipelineBuilder<TNext>`
Applies a `TransformStream<T, TNext>` or a mapping function `(chunk: T) => TNext | Promise<TNext>`.
### `PipelineBuilder<T>.collect(signal?): Promise<T[]>`
Materialises all output chunks into an array.
### `PipelineBuilder<T>.drain(signal?): Promise<void>`
Discards all output chunks.
### `PipelineBuilder<T>.forEach(fn, signal?): Promise<void>`
Calls `fn` for each output chunk.
### `PipelineBuilder<T>.drainToWritable(writable, signal?): Promise<void>`
Pipes all output to a `WritableStream<T>`.
### `PipelineBuilder<T>.toReadable(): ReadableStream<T>`
Returns the underlying `ReadableStream` for raw access.
## Implementation Notes
The inline function overload of `pipe` wraps the function in a `new TransformStream` with a single `transform` callback. This is identical in behaviour to `mapStream` but allows callers to write terse one-liners. Type inference works because the overload signatures have distinct type parameters and TypeScript's overload resolution picks the correct one based on the argument type.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "../../readable/from-array/from-array.js";
import { filterStream } from "../../transforms/filter/filter.js";
import { mapStream } from "../../transforms/map/map.js";
import { PipelineBuilder, pipeline } from "./builder.js";
describe("PipelineBuilder", () => {
it("chains transforms and collects", { timeout: 5000 }, async () => {
const result = await pipeline(fromArray([1, 2, 3, 4, 5]))
.pipe(filterStream((n) => n % 2 === 0))
.pipe(mapStream((n) => n * 10))
.collect();
expect(result).toEqual([20, 40]);
});
it("pipe with inline function", { timeout: 5000 }, async () => {
const result = await pipeline(fromArray(["a", "b", "c"]))
.pipe((s) => s.toUpperCase())
.collect();
expect(result).toEqual(["A", "B", "C"]);
});
it("pipe with async inline function", { timeout: 5000 }, async () => {
const result = await pipeline(fromArray([1, 2]))
.pipe(async (n) => n * 2)
.collect();
expect(result).toEqual([2, 4]);
});
it("drain discards all chunks", { timeout: 5000 }, async () => {
await expect(
pipeline(fromArray([1, 2, 3])).drain()
).resolves.toBeUndefined();
});
it("forEach calls fn for each chunk", { timeout: 5000 }, async () => {
const seen: number[] = [];
await pipeline(fromArray([10, 20, 30])).forEach((n) => {
seen.push(n);
});
expect(seen).toEqual([10, 20, 30]);
});
it("drainToWritable pipes to writable", { timeout: 5000 }, async () => {
const received: number[] = [];
const writable = new WritableStream<number>({
write(chunk) {
received.push(chunk);
},
});
await pipeline(fromArray([1, 2, 3])).drainToWritable(writable);
expect(received).toEqual([1, 2, 3]);
});
it("toReadable returns underlying stream", { timeout: 5000 }, () => {
const source = fromArray([1, 2]);
const builder = pipeline(source);
expect(builder.toReadable()).toBeInstanceOf(ReadableStream);
});
it("handles empty input", { timeout: 5000 }, async () => {
const result = await pipeline(fromArray<number>([]))
.pipe(mapStream((n) => n * 2))
.collect();
expect(result).toEqual([]);
});
it("accepts explicit options object", { timeout: 5000 }, async () => {
const result = await pipeline(fromArray([1, 2]), {}).collect();
expect(result).toEqual([1, 2]);
});
it("PipelineBuilder constructor default options branch", {
timeout: 5000,
}, async () => {
// Call the constructor directly without options to exercise the = {} default branch
const builder = new PipelineBuilder(fromArray([7, 8, 9]));
const result = await builder.collect();
expect(result).toEqual([7, 8, 9]);
});
});

View File

@@ -0,0 +1,82 @@
import { drain, forEach, toWritable } from "../../sinks/for-each/for-each.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import type { PipelineOptions } from "./builder.types.js";
/**
* Type-safe fluent pipeline builder.
* Usage: pipeline(source).pipe(transform).pipe(map(fn)).collect()
*/
export class PipelineBuilder<T> {
protected readonly stream: ReadableStream<T>;
protected readonly options: PipelineOptions;
constructor(stream: ReadableStream<T>, options: PipelineOptions = {}) {
this.stream = stream;
this.options = options;
}
/** Apply a TransformStream and return a new PipelineBuilder for the next type. */
pipe<TNext>(transform: TransformStream<T, TNext>): PipelineBuilder<TNext>;
/** Apply a mapping function via an inline transform. */
pipe<TNext>(
fn: (chunk: T) => TNext | Promise<TNext>
): PipelineBuilder<TNext>;
pipe<TNext>(
arg: TransformStream<T, TNext> | ((chunk: T) => TNext | Promise<TNext>)
): PipelineBuilder<TNext> {
let transform: TransformStream<T, TNext>;
if (typeof arg === "function") {
const fn = arg;
transform = new TransformStream<T, TNext>({
async transform(chunk, controller) {
controller.enqueue(await fn(chunk));
},
});
} else {
transform = arg;
}
const piped = this.stream.pipeThrough(transform);
return new PipelineBuilder<TNext>(piped, this.options);
}
/** Collect all output chunks into an array. */
collect(signal?: AbortSignal): Promise<T[]> {
return toArray(this.stream, signal ?? this.options.signal);
}
/** Drain all output chunks (discard results). */
drain(signal?: AbortSignal): Promise<void> {
return drain(this.stream, signal ?? this.options.signal);
}
/** Call `fn` for each output chunk. */
forEach(
fn: (chunk: T) => void | Promise<void>,
signal?: AbortSignal
): Promise<void> {
return forEach(this.stream, fn, signal ?? this.options.signal);
}
/** Pipe output into a WritableStream. */
drainToWritable(
writable: WritableStream<T>,
signal?: AbortSignal
): Promise<void> {
return toWritable(this.stream, writable, signal ?? this.options.signal);
}
/** Expose the underlying ReadableStream for low-level access. */
toReadable(): ReadableStream<T> {
return this.stream;
}
}
/**
* Creates a type-safe PipelineBuilder from a ReadableStream source.
*/
export function pipeline<T>(
source: ReadableStream<T>,
options: PipelineOptions = {}
): PipelineBuilder<T> {
return new PipelineBuilder<T>(source, options);
}

View File

@@ -0,0 +1,4 @@
/** Options for the pipeline builder. */
export interface PipelineOptions {
signal?: AbortSignal;
}

View File

@@ -0,0 +1,2 @@
export { PipelineBuilder, pipeline } from "./builder.js";
export type { PipelineOptions } from "./builder.types.js";

View File

@@ -0,0 +1,113 @@
# concat
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`concat` joins multiple `ReadableStream<T>` inputs end-to-end into a single
`ReadableStream<T>`, draining each source to completion before pulling from the
next. This is the sequential counterpart to `merge`: where `merge` races readers
and emits chunks in arrival order, `concat` preserves the input ordering
deterministically. Use it when downstream consumers depend on the relative
order of upstream chunks — for example, replaying a header stream followed by a
body stream, or stitching paginated results into one contiguous flow.
## Features
- Deterministic, sequential ordering across stream boundaries.
- Lazy: the next source is only acquired (its reader pulled) once the previous
one has reported `done`.
- Backpressure-aware: pulls from the current source only when the output is
being read.
- Error propagation: any input error halts the concatenation immediately and is
surfaced as a `StreamPipeFailedError` (`STREAM.PIPE_FAILED`) with the original
error attached as `cause`. Unread later sources are never touched.
- Cancellation: cancelling the output cancels the currently-reading source.
Sources that have not yet been acquired are left untouched (the caller still
owns them).
- Fast paths for empty input (`[]` returns an immediately-closed stream) and
single input (returned verbatim, identity).
## Usage: Basic Example
```ts
import { concat } from "@suaveplan/streams/pipeline/concat/concat";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const head = fromArray([1, 2, 3]);
const tail = fromArray([4, 5, 6]);
const all = await toArray(concat([head, tail]));
// → [1, 2, 3, 4, 5, 6]
```
## Usage: Advanced Example
Concatenating paginated fetch results, surfacing transport errors:
```ts
import { concat } from "@suaveplan/streams/pipeline/concat/concat";
import { StreamPipeFailedError } from "@suaveplan/streams/errors/stream-error";
function pageStream(pageNumber: number): ReadableStream<Uint8Array> {
return new ReadableStream({
async pull(controller) {
const res = await fetch(`/api/items?page=${pageNumber}`);
if (!res.ok) throw new Error(`page ${pageNumber} failed`);
const buf = new Uint8Array(await res.arrayBuffer());
controller.enqueue(buf);
controller.close();
},
});
}
const stitched = concat([pageStream(1), pageStream(2), pageStream(3)]);
try {
const reader = stitched.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
process(value);
}
} catch (err) {
if (err instanceof StreamPipeFailedError) {
// err.cause holds the original transport / page error
report(err);
}
throw err;
}
```
## API Reference
### `concat<T>(streams: ReadableStream<T>[]): ReadableStream<T>`
- **Parameters**
- `streams` — an ordered array of `ReadableStream<T>` to drain in sequence.
- **Returns** — a new `ReadableStream<T>` that emits every chunk from
`streams[0]`, then every chunk from `streams[1]`, and so on, closing once the
final source closes. For `streams.length === 0`, returns an
immediately-closed stream. For `streams.length === 1`, returns
`streams[0]` unchanged (identity).
- **Throws (downstream)** — `StreamPipeFailedError` with stable code
`STREAM.PIPE_FAILED` whenever any input source errors. The original error is
attached as `cause`.
## Implementation Notes
- The implementation owns at most one reader at a time. After a source reports
`done`, its reader is `releaseLock`-ed before the next source's reader is
acquired. This avoids holding multiple locks open and keeps memory bounded.
- Sources are acquired lazily inside `pull`. Streams that the consumer never
reaches (e.g., because of an early error or cancellation) are never read,
which is intentional — concat must not eagerly drain inputs.
- Cancellation only cancels the currently-acquired source. Un-acquired sources
are still owned by the caller and may be reused or cancelled independently.
- Errors are wrapped, never re-thrown raw. This guarantees that consumers can
reliably pattern-match on `StreamPipeFailedError` / `STREAM.PIPE_FAILED` for
any concat-mediated failure, regardless of the underlying cause.
- The single-source identity fast path is observable via reference equality —
consumers can detect it with `concat([s]) === s`.

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamPipeFailedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { concat } from "./concat.js";
describe("concat", () => {
it("preserves order across stream boundaries", {
timeout: 5000,
}, async () => {
const a = fromArray([1, 2, 3]);
const b = fromArray([4, 5, 6]);
const result = await toArray(concat([a, b]));
expect(result).toEqual([1, 2, 3, 4, 5, 6]);
});
it("produces empty output for empty input array", {
timeout: 5000,
}, async () => {
const result = await toArray(concat<number>([]));
expect(result).toEqual([]);
});
it("passes through a single stream verbatim", { timeout: 5000 }, () => {
const source = fromArray([1, 2, 3]);
const out = concat([source]);
expect(out).toBe(source);
});
it("propagates errors as StreamPipeFailedError and stops reading subsequent streams", {
timeout: 5000,
}, async () => {
let bGetReaderCount = 0;
let cGetReaderCount = 0;
const a = fromArray([1, 2]);
const rawB = new ReadableStream<number>({
pull(controller) {
controller.error(new Error("middle stream boom"));
},
});
const rawC = fromArray([99]);
// Wrap b and c so we can detect when concat acquires their readers.
const b = new Proxy(rawB, {
get(target, prop, recv) {
if (prop === "getReader") {
return (...args: unknown[]) => {
bGetReaderCount++;
return (
target.getReader as unknown as (
...a: unknown[]
) => ReadableStreamDefaultReader<number>
).apply(target, args);
};
}
return Reflect.get(target, prop, recv);
},
});
const c = new Proxy(rawC, {
get(target, prop, recv) {
if (prop === "getReader") {
return (...args: unknown[]) => {
cGetReaderCount++;
return (
target.getReader as unknown as (
...a: unknown[]
) => ReadableStreamDefaultReader<number>
).apply(target, args);
};
}
return Reflect.get(target, prop, recv);
},
});
const out = concat([a, b, c]);
const reader = out.getReader();
const collected: number[] = [];
let caught: unknown;
try {
while (true) {
const r = await reader.read();
if (r.done) break;
collected.push(r.value);
}
} catch (err) {
caught = err;
}
expect(collected).toEqual([1, 2]);
expect(bGetReaderCount).toBe(1);
expect(cGetReaderCount).toBe(0);
expect(caught).toBeInstanceOf(StreamPipeFailedError);
expect((caught as StreamPipeFailedError).code).toBe(
"STREAM.PIPE_FAILED"
);
expect(((caught as StreamPipeFailedError).cause as Error).message).toBe(
"middle stream boom"
);
});
it("cancels the currently-reading stream on output cancel", {
timeout: 5000,
}, async () => {
let aCancelled = false;
let bGetReaderCount = 0;
const a = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
},
pull() {
return new Promise(() => {
/* hang */
});
},
cancel() {
aCancelled = true;
},
});
const rawB = new ReadableStream<number>({
pull(controller) {
controller.enqueue(2);
controller.close();
},
});
const b = new Proxy(rawB, {
get(target, prop, recv) {
if (prop === "getReader") {
return (...args: unknown[]) => {
bGetReaderCount++;
return (
target.getReader as unknown as (
...a: unknown[]
) => ReadableStreamDefaultReader<number>
).apply(target, args);
};
}
return Reflect.get(target, prop, recv);
},
});
const out = concat([a, b]);
const reader = out.getReader();
const first = await reader.read();
expect(first.value).toBe(1);
await reader.cancel("user-cancel");
await new Promise((r) => setTimeout(r, 20));
expect(aCancelled).toBe(true);
expect(bGetReaderCount).toBe(0);
});
it("only pulls from current stream while output is being pulled (backpressure)", {
timeout: 5000,
}, async () => {
let bGetReaderCount = 0;
const a = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
controller.enqueue(2);
controller.close();
},
});
const rawB = new ReadableStream<number>({
start(controller) {
controller.enqueue(100);
controller.close();
},
});
const b = new Proxy(rawB, {
get(target, prop, recv) {
if (prop === "getReader") {
return (...args: unknown[]) => {
bGetReaderCount++;
return (
target.getReader as unknown as (
...a: unknown[]
) => ReadableStreamDefaultReader<number>
).apply(target, args);
};
}
return Reflect.get(target, prop, recv);
},
});
const out = concat([a, b]);
const reader = out.getReader();
const r1 = await reader.read();
expect(r1.value).toBe(1);
// After only one read, concat must not have acquired b yet — a still
// has a chunk to give.
expect(bGetReaderCount).toBe(0);
const r2 = await reader.read();
expect(r2.value).toBe(2);
const r3 = await reader.read();
expect(r3.value).toBe(100);
// Concat acquired b only after a was exhausted.
expect(bGetReaderCount).toBe(1);
const r4 = await reader.read();
expect(r4.done).toBe(true);
});
it("handles multiple empty streams interleaved with data", {
timeout: 5000,
}, async () => {
const out = concat<number>([
fromArray<number>([]),
fromArray([1, 2]),
fromArray<number>([]),
fromArray([3]),
fromArray<number>([]),
]);
const result = await toArray(out);
expect(result).toEqual([1, 2, 3]);
});
it("cancelled flag prevents enqueue after in-flight read resolves concurrently", {
timeout: 5000,
}, async () => {
// Exercises line 70: `if (cancelled)` check — cancel() fires while
// reader.read() is in-flight; when it resolves the value is abandoned.
let resolvePull!: () => void;
const slow = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
},
pull() {
return new Promise<void>((r) => {
resolvePull = r;
});
},
cancel() {
// Release the pending pull so reader.read() can resolve
resolvePull?.();
},
});
const out = concat([slow, fromArray([99])]);
const reader = out.getReader();
// Consume the first chunk so the stream advances to the hanging pull
const first = await reader.read();
expect(first.value).toBe(1);
// Cancel while the second pull is in-flight; cancel() releases resolvePull
await reader.cancel("test-cancel");
// After cancel, any subsequent read must return done
const next = await reader.read();
expect(next.done).toBe(true);
});
});

View File

@@ -0,0 +1,97 @@
import { StreamPipeFailedError } from "../../errors/stream-error.js";
/**
* Concatenates multiple ReadableStreams into one, sequentially.
*
* Each input stream is drained to completion before the next stream is pulled
* from. Output ordering is therefore deterministic and identical to the input
* order — unlike {@link merge}, which is race-based.
*
* Errors from any input stream halt the concatenation immediately and are
* propagated wrapped in a {@link StreamPipeFailedError}; subsequent streams
* are never read. Output cancellation cancels the currently-reading source
* and releases any unread sources without pulling from them.
*/
export function concat<T>(streams: ReadableStream<T>[]): ReadableStream<T> {
if (streams.length === 0) {
return new ReadableStream({
start(c) {
c.close();
},
});
}
if (streams.length === 1) {
return streams[0]!;
}
const sources = [...streams];
let index = 0;
let currentReader: ReadableStreamDefaultReader<T> | undefined;
let cancelled = false;
function acquireReader(): ReadableStreamDefaultReader<T> | undefined {
if (currentReader) return currentReader;
if (index >= sources.length) return undefined;
currentReader = sources[index]!.getReader();
return currentReader;
}
return new ReadableStream<T>({
async pull(controller) {
while (!cancelled) {
const reader = acquireReader();
if (!reader) {
controller.close();
return;
}
let result: ReadableStreamReadResult<T>;
try {
result = await reader.read();
} catch (cause) {
controller.error(
new StreamPipeFailedError({
cause,
message: "concat: input stream errored",
})
);
// Release the failed reader; do not touch unread sources.
try {
reader.releaseLock();
} catch {
// ignore — reader may already be released
}
currentReader = undefined;
return;
}
if (result.done) {
try {
reader.releaseLock();
} catch {
// ignore
}
currentReader = undefined;
index++;
// Loop to acquire next reader (or close if exhausted).
continue;
}
controller.enqueue(result.value);
return;
}
},
async cancel(reason) {
cancelled = true;
const reader = currentReader;
currentReader = undefined;
try {
await reader?.cancel(reason);
} catch {
// best-effort cancellation
}
// Subsequent un-acquired sources are intentionally NOT pulled or
// cancelled — concat consumes them lazily, so unread sources were
// never attached and remain owned by the caller.
},
});
}

View File

@@ -0,0 +1,11 @@
/**
* Type definitions for the `concat` pipeline op.
*
* `concat` takes an ordered list of `ReadableStream<T>` inputs and produces a
* single `ReadableStream<T>` that drains each input to completion before
* pulling from the next. The output type matches the element type of the
* input streams.
*/
/** Input bundle for {@link concat}: an ordered array of streams of the same element type. */
export type ConcatInputs<T> = ReadableStream<T>[];

View File

@@ -0,0 +1,2 @@
export { concat } from "./concat.js";
export type { ConcatInputs } from "./concat.types.js";

View File

@@ -0,0 +1,68 @@
# fan-out
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fanOut` splits a single `ReadableStream<T>` into N independent readable streams that each receive every chunk in the same order. It is built on top of the WHATWG `ReadableStream.tee()` method, applied recursively to produce N branches. All branches are synchronised: the source stream is only pulled when the slowest consumer is ready, so backpressure from any branch applies to all.
Fan-out is the foundation for broadcast patterns: send the same data to a logger, a metrics collector, and a storage writer simultaneously, without running the source pipeline multiple times.
## Features
- N-way broadcast of a single stream
- Built on native `tee()` — no custom buffering
- Backpressure from slowest consumer propagates to source
- Throws `RangeError` for `n < 1`
- `n = 1` returns the original stream unchanged
## Usage: Basic Example
```ts
import { fanOut } from "@suaveplan/streams/pipeline/fan-out/fan-out";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const [a, b] = fanOut(fromArray([1, 2, 3]), 2);
const [ra, rb] = await Promise.all([toArray(a), toArray(b)]);
console.log(ra); // [1, 2, 3]
console.log(rb); // [1, 2, 3]
```
## Usage: Advanced Example
```ts
import { fanOut } from "@suaveplan/streams/pipeline/fan-out/fan-out";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const [forStorage, forLogging, forMetrics] = fanOut(
fromArray([{ id: 1, value: 42 }, { id: 2, value: 99 }]),
3
);
const [stored, logged, counted] = await Promise.all([
toArray(forStorage),
toArray(forLogging.pipeThrough(mapStream((e) => `[LOG] id=${e.id}`))),
toArray(forMetrics.pipeThrough(mapStream((e) => e.value))),
]);
```
## API Reference
### `fanOut<T>(stream: ReadableStream<T>, n: number): ReadableStream<T>[]`
**Parameters**:
- `stream` — the source stream to fan out.
- `n` — number of output branches; must be `>= 1`.
**Returns**: Array of `n` `ReadableStream<T>` instances.
**Throws** (immediate): `RangeError` if `n < 1`.
## Implementation Notes
`tee()` is called `n - 1` times, each time on the last branch, building a chain: `[a1, (tee b1 into [a2, (tee b2 into [a3, ...])])]`. Each `tee` call creates internal queues that buffer chunks until both branches have consumed them. For large `n`, memory usage grows proportionally to `n × backlog_size`. If consumers process at very different rates, the internal queues can grow unboundedly — combine with `limitStream` or external flow control if this is a concern.

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { fanOut } from "./fan-out.js";
describe("fanOut", () => {
it("returns 2 independent streams with same chunks", {
timeout: 5000,
}, async () => {
const [a, b] = fanOut(fromArray([1, 2, 3]), 2);
const [ra, rb] = await Promise.all([toArray(a), toArray(b)]);
expect(ra).toEqual([1, 2, 3]);
expect(rb).toEqual([1, 2, 3]);
});
it("returns N streams", { timeout: 5000 }, async () => {
const streams = fanOut(fromArray([1, 2]), 4);
expect(streams).toHaveLength(4);
const results = await Promise.all(streams.map((s) => toArray(s)));
for (const r of results) {
expect(r).toEqual([1, 2]);
}
});
it("n=1 returns original stream", { timeout: 5000 }, () => {
const source = fromArray([1]);
const [s] = fanOut(source, 1);
expect(s).toBe(source);
});
it("throws RangeError for n < 1", { timeout: 5000 }, () => {
expect(() => fanOut(fromArray([1]), 0)).toThrow(RangeError);
});
it("handles empty source", { timeout: 5000 }, async () => {
const [a, b] = fanOut(fromArray<number>([]), 2);
const [ra, rb] = await Promise.all([toArray(a), toArray(b)]);
expect(ra).toEqual([]);
expect(rb).toEqual([]);
});
});

View File

@@ -0,0 +1,23 @@
/**
* Fans out a ReadableStream to N independent readable streams using tee.
* All N streams receive every chunk in the same order.
* Backpressure is applied to the slowest consumer.
*/
export function fanOut<T>(
stream: ReadableStream<T>,
n: number
): ReadableStream<T>[] {
if (n < 1) throw new RangeError("fanOut: n must be >= 1");
if (n === 1) return [stream];
const streams: ReadableStream<T>[] = [];
let current: ReadableStream<T> = stream;
for (let i = 0; i < n - 1; i++) {
const [a, b] = current.tee();
streams.push(a);
current = b;
}
streams.push(current);
return streams;
}

View File

@@ -0,0 +1 @@
export { fanOut } from "./fan-out.js";

View File

@@ -0,0 +1,2 @@
export { mergeOrdered } from "./merge-ordered.js";
export type { MergeOrderedCompare } from "./merge-ordered.types.js";

View File

@@ -0,0 +1,119 @@
# mergeOrdered
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`mergeOrdered` performs a k-way merge of pre-sorted `ReadableStream`s
into a single ordered output stream. It is the streaming analogue of
the merge step in merge-sort: when every input is sorted under the
same comparator, the output is sorted under that comparator. This is
the right tool whenever a pipeline needs to consume multiple already-
ordered sources — sharded query results, time-keyed event logs,
externally-sorted partitions — without re-sorting the union in memory.
Unlike the unordered `merge`, which races inputs and emits in arrival
order, `mergeOrdered` emits the global minimum at every step using a
binary min-heap of `(value, sourceIdx)` entries. Each pull dequeues
the heap root, enqueues it on the output, and replaces the slot with
the next chunk from the same source. Throughput is dominated by the
`O(log k)` heap operations where `k` is the number of inputs.
## Features
- K-way merge over `ReadableStream<T>` arrays of any cardinality
- Caller-supplied comparator (ascending, descending, or domain-specific)
- Single-stream and empty-array fast paths
- Streams of differing lengths handled without truncation
- `cancel` propagates to every input stream
- Errors on any input cancel the remaining inputs and surface on the
merged stream
- Backpressure-respecting — only pulls one chunk per source per pull cycle
## Usage: Basic Example
```ts
import { mergeOrdered } from "@suaveplan/streams/pipeline/merge-ordered/merge-ordered";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const s1 = fromArray([1, 4, 7]);
const s2 = fromArray([2, 5, 8]);
const s3 = fromArray([3, 6, 9]);
const merged = mergeOrdered([s1, s2, s3], (a, b) => a - b);
const out = await toArray(merged);
// out === [1, 2, 3, 4, 5, 6, 7, 8, 9]
```
## Usage: Advanced Example
Merge sharded events keyed by timestamp, in descending order, and
forward the result into a downstream consumer that may cancel mid-flight:
```ts
import { mergeOrdered } from "@suaveplan/streams/pipeline/merge-ordered/merge-ordered";
interface Event {
ts: number;
payload: unknown;
}
declare const shardA: ReadableStream<Event>;
declare const shardB: ReadableStream<Event>;
declare const shardC: ReadableStream<Event>;
const merged = mergeOrdered<Event>(
[shardA, shardB, shardC],
(a, b) => b.ts - a.ts, // newest-first
);
const reader = merged.getReader();
try {
const first = await reader.read();
if (!first.done) {
// process newest event
}
} finally {
// Cancellation propagates to every shard.
await reader.cancel("consumer-done");
}
```
## API Reference
### `mergeOrdered<T>(streams, compare): ReadableStream<T>`
- `streams: ReadableStream<T>[]` — input streams, each pre-sorted
under `compare`. May be empty (returns an immediately-closed stream)
or singleton (returns a passthrough merge with the same heap path).
- `compare: (a: T, b: T) => number` — comparator. Negative when `a`
precedes `b`, zero when equal, positive otherwise. Identical contract
to `Array.prototype.sort`.
- **Returns** a `ReadableStream<T>` that emits values from the union of
all inputs in `compare` order, then closes.
- **Throws / errors** — if any input reader rejects, the remaining
inputs are cancelled and the rejection is forwarded as a stream error.
### `MergeOrderedCompare<T>`
Type alias for the comparator argument; exported for downstream typing
of comparator-producing factories.
## Implementation Notes
- The heap stores at most `k` entries (one per active source) so peak
memory is `O(k)`, independent of the total chunk count.
- Heap operations (`siftUp` / `siftDown`) are iterative and avoid
recursion to keep call stacks shallow on large `k`.
- The first `pull` primes every reader in parallel via `Promise.all`;
subsequent pulls only read from the source whose value was just
emitted, which preserves backpressure on the other inputs.
- `cancel` on the output uses `Promise.allSettled` to ensure every
input is cancelled even if one rejects.
- Output sortedness is conditional on input sortedness — passing
unsorted inputs is a caller bug, not a runtime check, because
validating sortedness would defeat the streaming guarantees.
- The single-stream path still goes through the heap for behavioural
parity with k≥2 (cancellation, error propagation).

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { mergeOrdered } from "./merge-ordered.js";
function fromArray<T>(items: T[]): ReadableStream<T> {
let i = 0;
return new ReadableStream<T>({
pull(controller) {
if (i < items.length) {
controller.enqueue(items[i]!);
i++;
} else {
controller.close();
}
},
});
}
async function toArray<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader();
const out: T[] = [];
for (;;) {
const r = await reader.read();
if (r.done) return out;
out.push(r.value);
}
}
const ascending = (a: number, b: number): number => a - b;
const descending = (a: number, b: number): number => b - a;
describe("mergeOrdered", () => {
it("merges three sorted streams under ascending compare", {
timeout: 5000,
}, async () => {
const s1 = fromArray([1, 4, 7]);
const s2 = fromArray([2, 5, 8]);
const s3 = fromArray([3, 6, 9]);
const merged = mergeOrdered([s1, s2, s3], ascending);
expect(await toArray(merged)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("produces sorted output for any sorted inputs (property test)", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.array(fc.integer({ min: -100, max: 100 }), {
maxLength: 20,
}),
{ minLength: 1, maxLength: 5 }
),
async (lists) => {
const sorted = lists.map((l) => [...l].sort(ascending));
const streams = sorted.map((l) => fromArray(l));
const merged = mergeOrdered(streams, ascending);
const out = await toArray(merged);
const expected = sorted.flat().sort(ascending);
expect(out).toEqual(expected);
for (let i = 1; i < out.length; i++) {
expect(out[i - 1]! <= out[i]!).toBe(true);
}
}
),
{ numRuns: 30 }
);
});
it("single-stream input passes sorted output through", {
timeout: 5000,
}, async () => {
const merged = mergeOrdered([fromArray([1, 2, 3, 4, 5])], ascending);
expect(await toArray(merged)).toEqual([1, 2, 3, 4, 5]);
});
it("empty input array produces an empty stream", {
timeout: 5000,
}, async () => {
const merged = mergeOrdered<number>([], ascending);
expect(await toArray(merged)).toEqual([]);
});
it("merges streams of different lengths correctly", {
timeout: 5000,
}, async () => {
const s1 = fromArray([1, 10, 100]);
const s2 = fromArray([2]);
const s3 = fromArray([3, 4, 5, 6, 7, 8, 9]);
const merged = mergeOrdered([s1, s2, s3], ascending);
expect(await toArray(merged)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100,
]);
});
it("descending compare produces descending merged output", {
timeout: 5000,
}, async () => {
const s1 = fromArray([9, 6, 3]);
const s2 = fromArray([8, 5, 2]);
const s3 = fromArray([7, 4, 1]);
const merged = mergeOrdered([s1, s2, s3], descending);
expect(await toArray(merged)).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1]);
});
it("source.cancel propagates to every input stream", {
timeout: 5000,
}, async () => {
const cancelled: boolean[] = [false, false, false];
const make = (idx: number): ReadableStream<number> =>
new ReadableStream<number>({
pull(controller) {
controller.enqueue(idx);
},
cancel() {
cancelled[idx] = true;
},
});
const merged = mergeOrdered([make(0), make(1), make(2)], ascending);
const reader = merged.getReader();
await reader.read();
await reader.cancel("stop");
await new Promise((r) => setTimeout(r, 10));
expect(cancelled).toEqual([true, true, true]);
});
it("source error during pull propagates to merged stream and cancels remaining inputs", {
timeout: 5000,
}, async () => {
// Exercises lines 132-133: catch(err) calls Promise.allSettled on all
// readers then controller.error(err).
const boom = new Error("source-boom");
let normalCancelled = false;
const erroring = new ReadableStream<number>({
pull(controller) {
controller.error(boom);
},
});
const normal = new ReadableStream<number>({
pull(controller) {
controller.enqueue(99);
},
cancel() {
normalCancelled = true;
},
});
const merged = mergeOrdered([erroring, normal], ascending);
let caught: unknown;
try {
await toArray(merged);
} catch (err) {
caught = err;
}
expect(caught).toBe(boom);
await new Promise((r) => setTimeout(r, 10));
expect(normalCancelled).toBe(true);
});
});

View File

@@ -0,0 +1,140 @@
import type { MergeOrderedCompare } from "./merge-ordered.types.js";
interface HeapEntry<T> {
value: T;
sourceIdx: number;
}
/**
* Sift the entry at `idx` upwards until heap-order is restored.
*/
function siftUp<T>(
heap: HeapEntry<T>[],
idx: number,
compare: MergeOrderedCompare<T>
): void {
let i = idx;
while (i > 0) {
const parent = (i - 1) >>> 1;
if (compare(heap[i]!.value, heap[parent]!.value) < 0) {
const tmp = heap[i]!;
heap[i] = heap[parent]!;
heap[parent] = tmp;
i = parent;
} else {
return;
}
}
}
/**
* Sift the entry at `idx` downwards until heap-order is restored.
*/
function siftDown<T>(
heap: HeapEntry<T>[],
idx: number,
compare: MergeOrderedCompare<T>
): void {
const n = heap.length;
let i = idx;
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let smallest = i;
if (left < n && compare(heap[left]!.value, heap[smallest]!.value) < 0) {
smallest = left;
}
if (
right < n &&
compare(heap[right]!.value, heap[smallest]!.value) < 0
) {
smallest = right;
}
if (smallest === i) return;
const tmp = heap[i]!;
heap[i] = heap[smallest]!;
heap[smallest] = tmp;
i = smallest;
}
}
/**
* K-way merge of pre-sorted ReadableStreams.
*
* Maintains a min-heap of `(value, sourceIdx)` entries — one per active
* source — and emits the smallest value per `compare` on each pull, then
* pulls the next chunk from that source. The output stream is sorted iff
* every input stream is sorted under the same comparator.
*
* Source.cancel propagates to every input. Errors on any source surface
* on the merged stream after cancelling the remaining inputs.
*/
export function mergeOrdered<T>(
streams: ReadableStream<T>[],
compare: MergeOrderedCompare<T>
): ReadableStream<T> {
if (streams.length === 0) {
return new ReadableStream<T>({
start(c) {
c.close();
},
});
}
const readers = streams.map((s) => s.getReader());
const heap: HeapEntry<T>[] = [];
let initialised = false;
async function primeFrom(idx: number): Promise<void> {
const reader = readers[idx]!;
const result = await reader.read();
if (!result.done) {
heap.push({ value: result.value, sourceIdx: idx });
siftUp(heap, heap.length - 1, compare);
}
}
async function initialise(): Promise<void> {
// Prime in parallel — order independent because we sift on insert.
await Promise.all(readers.map((_, idx) => primeFrom(idx)));
initialised = true;
}
return new ReadableStream<T>({
async pull(controller) {
try {
if (!initialised) {
await initialise();
}
if (heap.length === 0) {
controller.close();
return;
}
const top = heap[0]!;
const { value, sourceIdx } = top;
controller.enqueue(value);
// Pull the next chunk from the source we just emitted from.
const reader = readers[sourceIdx]!;
const next = await reader.read();
if (next.done) {
// Remove root: replace with last and sift down.
const last = heap.pop();
if (heap.length > 0 && last !== undefined) {
heap[0] = last;
siftDown(heap, 0, compare);
}
} else {
heap[0] = { value: next.value, sourceIdx };
siftDown(heap, 0, compare);
}
} catch (err) {
await Promise.allSettled(readers.map((r) => r.cancel(err)));
controller.error(err);
}
},
async cancel(reason) {
await Promise.allSettled(readers.map((r) => r.cancel(reason)));
},
});
}

View File

@@ -0,0 +1,7 @@
/**
* Comparator function used to order values across input streams.
* Returns a negative number when `a` precedes `b`, zero when equal,
* and a positive number when `a` follows `b` — identical contract to
* `Array.prototype.sort`.
*/
export type MergeOrderedCompare<T> = (a: T, b: T) => number;

View File

@@ -0,0 +1 @@
export { merge } from "./merge.js";

View File

@@ -0,0 +1,66 @@
# merge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`merge` combines multiple `ReadableStream<T>` instances into a single `ReadableStream<T>` that emits chunks in arrival order. It is a fan-in operation: the merged stream races all input readers simultaneously and yields whichever chunk becomes available first, interleaving outputs from all sources as they produce data. The merged stream closes when every input stream has closed.
This is useful for consuming multiple independent data sources concurrently — for example, merging results from several parallel API calls, aggregating events from multiple sensors, or combining log streams from different servers.
## Features
- Race-based interleaving — faster streams yield chunks sooner
- Merged stream closes when all inputs close
- Error from any input stream errors the merged stream
- Handles empty input (returns immediately-closed stream)
- Single-stream input returns the stream unchanged
## Usage: Basic Example
```ts
import { merge } from "@suaveplan/streams/pipeline/merge/merge";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const [a, b] = [fromArray([1, 3, 5]), fromArray([2, 4, 6])];
const merged = merge([a, b]);
const result = await toArray(merged);
// result contains 1,2,3,4,5,6 in some interleaved order
console.log(result.length); // 6
```
## Usage: Advanced Example
```ts
import { merge } from "@suaveplan/streams/pipeline/merge/merge";
import { fromAsyncGenerator } from "@suaveplan/streams/readable/from-generator/from-generator";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
async function* slow() { yield "slow-1"; await new Promise(r => setTimeout(r, 10)); yield "slow-2"; }
async function* fast() { yield "fast-1"; yield "fast-2"; }
const result = await toArray(merge([
fromAsyncGenerator(slow),
fromAsyncGenerator(fast),
]));
console.log(result); // fast items likely come before slow items
```
## API Reference
### `merge<T>(streams: ReadableStream<T>[]): ReadableStream<T>`
**Parameters**:
- `streams` — array of readable streams to merge.
**Returns**: `ReadableStream<T>` that emits all chunks from all inputs.
**Returns immediately-closed stream** if `streams` is empty.
**Returns `streams[0]` unchanged** if `streams.length === 1`.
## Implementation Notes
Each input reader has an in-flight `read()` promise maintained in a `Map`. On each `pull`, `Promise.race` is called over all active in-flight promises to get the next available chunk. When a reader is done, it is removed from the active set. When all readers are done, the controller is closed. Errors from any reader propagate immediately to the merged stream's error channel, and all other readers are cancelled via `Promise.allSettled` in the `cancel` callback.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { merge } from "./merge.js";
describe("merge", () => {
it("merges two streams", { timeout: 30000 }, async () => {
const a = fromArray([1, 2, 3]);
const b = fromArray([4, 5, 6]);
const result = await toArray(merge([a, b]));
expect(result).toHaveLength(6);
expect(result.sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5, 6]);
});
it("returns immediately-closed stream for empty input", {
timeout: 5000,
}, async () => {
const result = await toArray(merge([]));
expect(result).toEqual([]);
});
it("returns original stream for single input", {
timeout: 5000,
}, () => {
const source = fromArray([1, 2]);
const merged = merge([source]);
expect(merged).toBe(source);
});
it("handles one empty stream and one non-empty", {
timeout: 5000,
}, async () => {
const a = fromArray<number>([]);
const b = fromArray([1, 2, 3]);
const result = await toArray(merge([a, b]));
expect(result).toEqual([1, 2, 3]);
});
it("merges 3 streams", { timeout: 30000 }, async () => {
const streams = [
fromArray([1, 2]),
fromArray([3, 4]),
fromArray([5, 6]),
];
const result = await toArray(merge(streams));
expect(result).toHaveLength(6);
expect(result.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
});
it("propagates error from one stream", { timeout: 30000 }, async () => {
const a = fromArray([1, 2]);
const b = new ReadableStream<number>({
start(controller) {
controller.error(new Error("stream b error"));
},
});
await expect(toArray(merge([a, b]))).rejects.toThrow("stream b error");
});
it("handles two empty streams", { timeout: 5000 }, async () => {
const result = await toArray(
merge([fromArray<number>([]), fromArray<number>([])])
);
expect(result).toEqual([]);
});
it("cancel cancels all readers", { timeout: 5000 }, async () => {
let cancelCount = 0;
const makeStream = () =>
new ReadableStream<number>({
pull() {
return new Promise(() => {});
},
cancel() {
cancelCount++;
},
});
const merged = merge([makeStream(), makeStream()]);
const reader = merged.getReader();
await reader.cancel();
await new Promise((r) => setTimeout(r, 20));
expect(cancelCount).toBe(2);
});
});

View File

@@ -0,0 +1,77 @@
/**
* Merges multiple ReadableStreams into one.
* Chunks are emitted in arrival order (race-based). The merged stream closes
* when all input streams have closed.
*/
export function merge<T>(streams: ReadableStream<T>[]): ReadableStream<T> {
if (streams.length === 0) {
return new ReadableStream({
start(c) {
c.close();
},
});
}
if (streams.length === 1) {
return streams[0]!;
}
const readers = streams.map((s) => s.getReader());
let closedCount = 0;
// Pull promises: one per reader
const pulling = new Map<
ReadableStreamDefaultReader<T>,
Promise<{
reader: ReadableStreamDefaultReader<T>;
result: ReadableStreamReadResult<T>;
}>
>();
function pullFrom(reader: ReadableStreamDefaultReader<T>): Promise<{
reader: ReadableStreamDefaultReader<T>;
result: ReadableStreamReadResult<T>;
}> {
const p = reader.read().then((result) => ({ reader, result }));
pulling.set(reader, p);
return p;
}
// Initialise pulls
for (const reader of readers) {
pullFrom(reader);
}
return new ReadableStream<T>({
async pull(controller) {
while (pulling.size > 0) {
let reader: ReadableStreamDefaultReader<T>;
let result: ReadableStreamReadResult<T>;
try {
({ reader, result } = await Promise.race([
...pulling.values(),
]));
} catch (err) {
controller.error(err);
return;
}
pulling.delete(reader);
if (result.done) {
closedCount++;
if (closedCount === streams.length) {
controller.close();
return;
}
// Continue loop to race remaining readers
} else {
controller.enqueue(result.value);
pullFrom(reader);
return;
}
}
},
async cancel() {
await Promise.allSettled(readers.map((r) => r.cancel()));
},
});
}

View File

@@ -0,0 +1,2 @@
export { partition } from "./partition.js";
export type { PartitionPredicate, PartitionResult } from "./partition.types.js";

View File

@@ -0,0 +1,105 @@
# `partition`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`partition` splits a single `ReadableStream<T>` into two output streams using a
boolean predicate. Unlike `tee` or `fanOut`, every input chunk is routed to
_exactly one_ output — the truthy stream or the falsy stream — so the union of
the two outputs equals the source with per-side ordering preserved. This is
the right tool when you want to fork a stream by classification (errors vs
successes, even vs odd, hot vs cold) without paying the buffering cost of a
full duplication.
## Features
- Single-pass split via one underlying reader on the source
- Synchronous or asynchronous predicate (`(chunk: T) => boolean | Promise<boolean>`)
- Backpressure flows from either output back to the source — pulls on each
side drive the reader on demand
- Predicate exceptions error _both_ output streams and cancel the source
- Per-side `cancel()` cooperates: the source is cancelled only when both
sides have been cancelled
- One-slot per-side buffer absorbs the off-routing case (a pull on truthy
yielding a falsy chunk and vice versa)
- Zero workspace runtime dependencies — uses only WHATWG Streams primitives
## Usage: Basic Example
```ts
import { partition } from "@suaveplan/streams/pipeline/partition/partition";
declare const numbers: ReadableStream<number>;
const { truthy: evens, falsy: odds } = partition(
numbers,
(n) => n % 2 === 0
);
```
## Usage: Advanced Example
```ts
import { partition } from "@suaveplan/streams/pipeline/partition/partition";
interface Event {
level: "info" | "warn" | "error";
payload: unknown;
}
declare const events: ReadableStream<Event>;
const { truthy: errors, falsy: rest } = partition(events, async (e) => {
// async predicates are awaited in routing order
await Promise.resolve();
return e.level === "error";
});
// Each side is an independent ReadableStream — pipe through unrelated
// pipelines without worrying about duplication or fan-out cost.
const errorsToSink = errors.pipeTo(errorSink);
const restToSink = rest.pipeTo(analyticsSink);
await Promise.all([errorsToSink, restToSink]);
```
## API Reference
### `partition<T>(source, predicate): { truthy, falsy }`
- **`source: ReadableStream<T>`** — the input stream. `partition` acquires a
single default reader and drives it from the output pulls.
- **`predicate: (chunk: T) => boolean | Promise<boolean>`** — routes each
chunk: `true` enqueues to `truthy`, `false` enqueues to `falsy`. Predicate
exceptions are caught, both outputs are errored with the original cause,
and the source reader is cancelled.
- **Returns `{ truthy: ReadableStream<T>; falsy: ReadableStream<T> }`** — two
independent readable streams. Both close when the source is exhausted.
Cancelling one side does _not_ cancel the source; cancelling both does.
### Throws
- Synchronous: none — invalid arguments propagate as TypeErrors from the
underlying `ReadableStream` machinery on first use.
- Asynchronous: predicate exceptions surface as stream errors on both
outputs; their `.cause` is the original thrown value.
## Implementation Notes
The implementation owns a single `ReadableStreamDefaultReader` on the source
and exposes two consumer streams whose `pull()` callbacks both drain from
that reader. Each `pull` loops until it produces a chunk _for its own side_;
chunks for the other side are stashed in a one-slot buffer that the other
side claims on its next pull. Because pulls only fire under backpressure
(when the consumer side has space), the source is read at most as fast as
the _currently-pulling_ consumer demands — backpressure on either side is
preserved end-to-end.
Cancellation is cooperative. A `cancel()` on a single output marks that side
closed; the source reader is only cancelled once both sides are closed. This
mirrors `tee` semantics and lets the live side continue draining the source.
Predicate exceptions are routed once: the first throw errors both outputs
and cancels the reader, after which `pullFor` short-circuits via the
`errored` flag.

View File

@@ -0,0 +1,291 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { partition } from "./partition.js";
function fromArray<T>(items: readonly T[]): ReadableStream<T> {
let i = 0;
return new ReadableStream<T>({
pull(controller) {
if (i >= items.length) {
controller.close();
return;
}
controller.enqueue(items[i++] as T);
},
});
}
async function drain<T>(rs: ReadableStream<T>): Promise<T[]> {
const out: T[] = [];
const reader = rs.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
out.push(value);
}
return out;
}
describe("partition", () => {
it("routes each chunk to exactly one side preserving per-side order", {
timeout: 5000,
}, async () => {
const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const { truthy, falsy } = partition(
fromArray(input),
(n: number) => n % 2 === 0
);
const [evens, odds] = await Promise.all([drain(truthy), drain(falsy)]);
expect(evens).toEqual([2, 4, 6, 8, 10]);
expect(odds).toEqual([1, 3, 5, 7, 9]);
expect([...evens, ...odds].sort((a, b) => a - b)).toEqual(input);
});
it("preserves union equality and per-side ordering for non-numeric chunks", {
timeout: 5000,
}, async () => {
const input = ["a", "bb", "ccc", "dd", "e", "ffff"];
const { truthy, falsy } = partition(
fromArray(input),
(s: string) => s.length >= 2
);
const [longs, shorts] = await Promise.all([
drain(truthy),
drain(falsy),
]);
expect(longs).toEqual(["bb", "ccc", "dd", "ffff"]);
expect(shorts).toEqual(["a", "e"]);
});
it("supports async predicates", { timeout: 5000 }, async () => {
const { truthy, falsy } = partition(
fromArray([1, 2, 3, 4]),
async (n: number) => {
await Promise.resolve();
return n > 2;
}
);
const [hi, lo] = await Promise.all([drain(truthy), drain(falsy)]);
expect(hi).toEqual([3, 4]);
expect(lo).toEqual([1, 2]);
});
it("errors both output streams when the predicate throws", {
timeout: 5000,
}, async () => {
const boom = new Error("predicate failed");
const { truthy, falsy } = partition(
fromArray([1, 2, 3]),
(n: number) => {
if (n === 2) throw boom;
return n % 2 === 0;
}
);
await expect(drain(truthy)).rejects.toBe(boom);
await expect(drain(falsy)).rejects.toBe(boom);
});
it("propagates cancellation to the source when both sides cancel", {
timeout: 5000,
}, async () => {
let cancelReason: unknown;
const source = new ReadableStream<number>({
pull(controller) {
controller.enqueue(1);
},
cancel(reason) {
cancelReason = reason;
},
});
const { truthy, falsy } = partition(source, (n: number) => n % 2 === 0);
await truthy.cancel("t-cancel");
await falsy.cancel("f-cancel");
expect(cancelReason).toBeDefined();
});
it("cancelSource only cancels reader when both sides have cancelled", {
timeout: 5000,
}, async () => {
// Exercises line 93: cancelSource() checks truthyClosed && falsyClosed
// before reader.cancel(). Cancelling one side leaves the reader open;
// cancelling the second side triggers the actual reader.cancel().
let cancelCount = 0;
const source = new ReadableStream<number>({
pull(controller) {
controller.enqueue(1);
},
cancel() {
cancelCount++;
},
});
const { truthy, falsy } = partition(source, (n: number) => n % 2 === 0);
// Cancel only the truthy side — source reader must stay open
await truthy.cancel("truthy-done");
expect(cancelCount).toBe(0);
// Cancel the falsy side — now both are closed, source is cancelled
await falsy.cancel("falsy-done");
expect(cancelCount).toBe(1);
});
it("drains falsyQueue into falsyController when source ends while truthy is consumed first (line 93)", {
timeout: 5000,
}, async () => {
// Exercises partition.ts line 93: the falsy drain loop fires when the
// source finishes and falsyQueue still has buffered items. We consume
// ONLY the truthy side until done; the falsy chunks accumulate in
// falsyQueue. When source ends, line 93 flushes them into falsyController.
const input = [1, 2, 3, 4, 5, 6]; // evens → truthy, odds → falsy
const { truthy, falsy } = partition(
fromArray(input),
(n: number) => n % 2 === 0
);
// Drain truthy to completion first — this drives the source loop and
// causes odd chunks to queue up in falsyQueue. When the source closes
// (done=true), partition drains falsyQueue into falsyController (line 93).
const truthyResult = await drain(truthy);
// Now drain falsy — it should have received all the odd values via the
// line-93 flush that happened when the source closed.
const falsyResult = await drain(falsy);
expect(truthyResult).toEqual([2, 4, 6]);
expect(falsyResult).toEqual([1, 3, 5]);
});
it("applies backpressure to source when one consumer is slow", {
timeout: 5000,
}, async () => {
let pulled = 0;
const source = new ReadableStream<number>(
{
pull(controller) {
pulled += 1;
controller.enqueue(pulled);
},
},
new CountQueuingStrategy({ highWaterMark: 1 })
);
const { truthy, falsy } = partition(source, (n: number) => n % 2 === 0);
const reader = truthy.getReader();
const a = await reader.read();
const b = await reader.read();
expect(a.value).toBe(2);
expect(b.value).toBe(4);
reader.releaseLock();
// Bounded source pull count proves backpressure: we did not drain the
// source's universe, only enough to satisfy the truthy reads (plus at
// most one extra off-side chunk parked in the falsy buffer).
expect(pulled).toBeLessThan(20);
expect(pulled).toBeGreaterThanOrEqual(4);
await truthy.cancel();
await falsy.cancel();
});
it("cancelling truthy then draining falsy until source-done exercises the truthyClosed=true branch in closeBoth", {
timeout: 5000,
}, async () => {
const { truthy, falsy } = partition(
fromArray([1, 2, 3, 4]),
(n: number) => n % 2 === 0
);
await truthy.cancel();
const odds = await drain(falsy);
expect(odds).toEqual([1, 3]);
});
it("cancelling falsy then draining truthy until source-done exercises the falsyClosed=true branch in closeBoth", {
timeout: 5000,
}, async () => {
const { truthy, falsy } = partition(
fromArray([1, 2, 3, 4]),
(n: number) => n % 2 === 0
);
await falsy.cancel();
const evens = await drain(truthy);
expect(evens).toEqual([2, 4]);
});
it("cancelling truthy then a predicate error exercises the truthyClosed=true branch in errorBoth", {
timeout: 5000,
}, async () => {
const { truthy, falsy } = partition(
fromArray([1, 2, 3, 4]),
(n: number) => {
if (n === 3) throw new Error("predicate-failure");
return n % 2 === 0;
}
);
await truthy.cancel();
const reader = falsy.getReader();
let caught: unknown;
try {
while (true) {
const { done } = await reader.read();
if (done) break;
}
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
});
it("cancelling falsy then a predicate error exercises the falsyClosed=true branch in errorBoth", {
timeout: 5000,
}, async () => {
const { truthy, falsy } = partition(
fromArray([1, 2, 3, 4]),
(n: number) => {
if (n === 3) throw new Error("predicate-failure");
return n % 2 === 0;
}
);
await falsy.cancel();
const reader = truthy.getReader();
let caught: unknown;
try {
while (true) {
const { done } = await reader.read();
if (done) break;
}
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
});
it("recovers from a source whose reader.read() rejects so the `pumping.catch` swallow handler fires", {
timeout: 5000,
}, async () => {
// Build a source that errors its controller mid-stream so a
// subsequent reader.read() rejects. The first read resolves with a
// value, but the second read rejects because the source was
// errored. That rejection propagates through `pullFor` and into
// the `next` promise inside `enqueuePull`, where `.catch(() => {})`
// swallows it on the shielded `pumping` chain.
let sourceController!: ReadableStreamDefaultController<number>;
const source = new ReadableStream<number>({
start(c) {
sourceController = c;
c.enqueue(1);
},
});
const { truthy, falsy } = partition(source, (n) => n % 2 === 0);
// Error the source — second read will reject.
sourceController.error(new Error("source-failed"));
// Drain both sides; each should propagate the error.
const drainSafely = async (rs: ReadableStream<number>) => {
try {
await drain(rs);
} catch {
/* expected — exercise the error path */
}
};
await Promise.all([drainSafely(truthy), drainSafely(falsy)]);
// The test passes if we reach here without an unhandled rejection;
// the .catch on `pumping` swallowed the rejection on the shielded chain.
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,175 @@
import type { PartitionPredicate, PartitionResult } from "./partition.types.js";
/**
* Splits a single `ReadableStream` into two output streams based on a
* predicate. Each chunk is routed to exactly one of `truthy` or `falsy`
* (no duplication, unlike `tee`/`fanOut`).
*
* Implementation drives a single reader on the source from whichever output
* stream pulls. Pull calls are serialized via a chained promise so concurrent
* consumers don't race for `reader.read()` and lose chunks. Each side has an
* unbounded FIFO queue for off-side routing.
*/
export function partition<T>(
source: ReadableStream<T>,
predicate: PartitionPredicate<T>
): PartitionResult<T> {
const reader = source.getReader();
let truthyController: ReadableStreamDefaultController<T> | undefined;
let falsyController: ReadableStreamDefaultController<T> | undefined;
const truthyQueue: T[] = [];
const falsyQueue: T[] = [];
let truthyClosed = false;
let falsyClosed = false;
let sourceDone = false;
let errored = false;
let pumping: Promise<void> = Promise.resolve();
const closeBoth = (): void => {
if (!truthyClosed) {
try {
(
truthyController as ReadableStreamDefaultController<T>
).close();
} catch {
/* already closed */
}
truthyClosed = true;
}
if (!falsyClosed) {
try {
(falsyController as ReadableStreamDefaultController<T>).close();
} catch {
/* already closed */
}
falsyClosed = true;
}
};
const errorBoth = (err: unknown): void => {
errored = true;
if (!truthyClosed) {
(truthyController as ReadableStreamDefaultController<T>).error(err);
truthyClosed = true;
}
if (!falsyClosed) {
(falsyController as ReadableStreamDefaultController<T>).error(err);
falsyClosed = true;
}
};
/**
* Pull from the source until the requested side has at least one chunk
* to emit (or the source is exhausted). Off-side chunks are appended to
* the other side's queue so its next pull claims them.
*
* Calls are serialized via the `pumping` chain so two concurrent consumer
* pulls cannot race against `reader.read()`.
*/
const pullFor = async (side: "truthy" | "falsy"): Promise<void> => {
if (errored) return;
const myQueue = side === "truthy" ? truthyQueue : falsyQueue;
const myController =
side === "truthy" ? truthyController : falsyController;
if (myQueue.length > 0) {
myController?.enqueue(myQueue.shift() as T);
return;
}
while (!sourceDone && myQueue.length === 0) {
const { value, done } = await reader.read();
if (done) {
sourceDone = true;
// Drain remaining queues into their controllers before
// closing so off-side chunks accumulated during the final
// pull aren't lost.
while (truthyQueue.length > 0 && !truthyClosed) {
(
truthyController as ReadableStreamDefaultController<T>
).enqueue(truthyQueue.shift() as T);
}
while (falsyQueue.length > 0 && !falsyClosed) {
(
falsyController as ReadableStreamDefaultController<T>
).enqueue(falsyQueue.shift() as T);
}
closeBoth();
return;
}
let route: boolean;
try {
route = await predicate(value);
} catch (err) {
errorBoth(err);
try {
await reader.cancel(err);
} catch {
/* ignore */
}
return;
}
if (route) {
truthyQueue.push(value);
} else {
falsyQueue.push(value);
}
}
if (myQueue.length > 0) {
myController?.enqueue(myQueue.shift() as T);
}
};
const enqueuePull = (side: "truthy" | "falsy"): Promise<void> => {
const next = pumping.then(() => pullFor(side));
pumping = next.catch(() => {
/* swallow — handled inside pullFor via errorBoth */
});
return next;
};
const cancelSource = async (reason?: unknown): Promise<void> => {
if (truthyClosed && falsyClosed) {
try {
await reader.cancel(reason);
} catch {
/* ignore */
}
}
};
const truthy = new ReadableStream<T>({
start(controller) {
truthyController = controller;
},
pull() {
return enqueuePull("truthy");
},
async cancel(reason) {
truthyClosed = true;
await cancelSource(reason);
},
});
const falsy = new ReadableStream<T>({
start(controller) {
falsyController = controller;
},
pull() {
return enqueuePull("falsy");
},
async cancel(reason) {
falsyClosed = true;
await cancelSource(reason);
},
});
return { truthy, falsy };
}

View File

@@ -0,0 +1,14 @@
/**
* Predicate function used by {@link partition} to route chunks to either the
* truthy or falsy output. May return synchronously or asynchronously.
*/
export type PartitionPredicate<T> = (chunk: T) => boolean | Promise<boolean>;
/**
* Result of {@link partition}: two `ReadableStream`s draining a single source.
* Every input chunk lands in exactly one of the two streams (no duplication).
*/
export interface PartitionResult<T> {
readonly truthy: ReadableStream<T>;
readonly falsy: ReadableStream<T>;
}

View File

@@ -0,0 +1,2 @@
export { route, routeBy } from "./route.js";
export type { RouteOptions } from "./route.types.js";

View File

@@ -0,0 +1,115 @@
# route / routeBy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`route` and `routeBy` split a single `ReadableStream<T>` into N independent
output branches, but unlike `fanOut` each chunk is delivered to **exactly one**
branch — the one selected by a caller-supplied `keyFn`. They are the
single-pass partitioning primitive of the streams pipeline: a chunk is read
from the source once and dispatched, never duplicated.
This is the idiomatic way to model demultiplexing: a tagged event stream that
should be processed differently per kind (writes → DB, audits → log,
notifications → fan-out), without paying the buffering/backpressure-coupling
cost of `fanOut`. The two ops differ only in container: `route` returns a
`Record<K, ReadableStream<T>>` for `string` keys, `routeBy` returns a
`Map<K, ReadableStream<T>>` for arbitrary key types (numbers, symbols,
objects).
## Features
- Single-pass: each source chunk is dispatched to exactly one branch.
- Per-key ordering is preserved (FIFO within each branch).
- Configurable unknown-key policy: `'drop'` (silent, default) or `'error'`
(every branch errors with `StreamRouteUnknownKeyError`).
- Source errors propagate to every branch.
- `routeBy` accepts non-string keys (number, symbol, object identity).
- Zero external dependencies; pure WHATWG Streams.
## Usage: Basic Example
```ts
import { route } from "@suaveplan/streams/pipeline/route/route";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const events = fromArray([
{ kind: "a" as const, n: 1 },
{ kind: "b" as const, n: 2 },
{ kind: "a" as const, n: 3 },
]);
const branches = route(events, (e) => e.kind, ["a", "b"] as const);
const [as, bs] = await Promise.all([
toArray(branches.a),
toArray(branches.b),
]);
// as -> [{ kind: "a", n: 1 }, { kind: "a", n: 3 }]
// bs -> [{ kind: "b", n: 2 }]
```
## Usage: Advanced Example
```ts
import { routeBy } from "@suaveplan/streams/pipeline/route/route";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
// Symbol-keyed routing with strict unknown-key handling.
const PRIORITY_HIGH = Symbol("high");
const PRIORITY_LOW = Symbol("low");
const m = routeBy<{ pri: symbol; payload: string }, symbol>(
fromArray([
{ pri: PRIORITY_HIGH, payload: "urgent" },
{ pri: PRIORITY_LOW, payload: "later" },
]),
(e) => e.pri,
[PRIORITY_HIGH, PRIORITY_LOW],
{ onUnknownKey: "error" },
);
const high = m.get(PRIORITY_HIGH)!;
const low = m.get(PRIORITY_LOW)!;
const [hi, lo] = await Promise.all([toArray(high), toArray(low)]);
```
## API Reference
### `route<T, K extends string>(source, keyFn, keys, options?)`
- `source: ReadableStream<T>` — input stream to dispatch.
- `keyFn: (chunk: T) => K` — extracts the routing key from each chunk.
- `keys: readonly K[]` — declared key set; one branch is created per key.
- `options.onUnknownKey?: 'drop' | 'error'` — default `'drop'`.
**Returns**: `Record<K, ReadableStream<T>>` — one branch per declared key.
**Throws** (per branch, asynchronously): `StreamRouteUnknownKeyError`
(`STREAM.ROUTE_UNKNOWN_KEY`) when a chunk's key is not in `keys` and
`onUnknownKey === 'error'`.
### `routeBy<T, K>(source, keyFn, keys, options?)`
Same contract as `route`, but returns `Map<K, ReadableStream<T>>` so the key
type is unconstrained (numbers, symbols, objects).
## Implementation Notes
The implementation creates one `ReadableStream<T>` per declared key whose
controller is captured during `start()`. A single reader loop pulls from the
source on the next microtask after construction and dispatches each chunk to
the controller selected by `keyFn`. On unknown key:
- `'drop'` — the chunk is consumed and discarded.
- `'error'` — every branch's controller is errored with
`StreamRouteUnknownKeyError`, the source is cancelled, and the dispatch loop
terminates.
Branches use the default WHATWG queuing strategy (CountQueuingStrategy with
`highWaterMark: 1`); if a downstream consumer falls far behind, the branch's
internal queue grows. Compose with `limit` or `throttle` if an unbounded
producer must be guarded.

View File

@@ -0,0 +1,189 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamRouteUnknownKeyError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { route, routeBy } from "./route.js";
describe("route", () => {
it("routes each chunk to exactly one keyed branch (single-pass, no duplication)", {
timeout: 5000,
}, async () => {
const source = fromArray<{ kind: "a" | "b"; n: number }>([
{ kind: "a", n: 1 },
{ kind: "b", n: 2 },
{ kind: "a", n: 3 },
{ kind: "b", n: 4 },
]);
const branches = route(source, (c) => c.kind, ["a", "b"] as const);
const [as, bs] = await Promise.all([
toArray(branches.a),
toArray(branches.b),
]);
expect(as).toEqual([
{ kind: "a", n: 1 },
{ kind: "a", n: 3 },
]);
expect(bs).toEqual([
{ kind: "b", n: 2 },
{ kind: "b", n: 4 },
]);
});
it("two-key route partitions a stream by keyFn = c => c.kind", {
timeout: 5000,
}, async () => {
type Evt = { kind: "even" | "odd"; v: number };
const events: Evt[] = [];
for (let v = 1; v <= 6; v++) {
events.push({ kind: v % 2 === 0 ? "even" : "odd", v });
}
const branches = route(fromArray(events), (e) => e.kind, [
"even",
"odd",
] as const);
const [evens, odds] = await Promise.all([
toArray(branches.even),
toArray(branches.odd),
]);
expect(evens.map((e) => e.v)).toEqual([2, 4, 6]);
expect(odds.map((e) => e.v)).toEqual([1, 3, 5]);
});
it("unknown key with 'drop' silently discards while other keys still receive their chunks", {
timeout: 5000,
}, async () => {
const branches = route(
fromArray<{ kind: "a" | "x"; n: number }>([
{ kind: "a", n: 1 },
{ kind: "x", n: 99 },
{ kind: "a", n: 2 },
{ kind: "x", n: 100 },
]),
(c) => c.kind,
["a"] as const,
{ onUnknownKey: "drop" }
);
const result = await toArray(branches.a);
expect(result).toEqual([
{ kind: "a", n: 1 },
{ kind: "a", n: 2 },
]);
});
it("unknown key with 'error' errors all output branches with STREAM.ROUTE_UNKNOWN_KEY", {
timeout: 5000,
}, async () => {
const branches = route(
fromArray<{ kind: "a" | "b" | "x" }>([
{ kind: "a" },
{ kind: "x" },
]),
(c) => c.kind,
["a", "b"] as const,
{ onUnknownKey: "error" }
);
const aErr = await toArray(branches.a).catch((e) => e);
const bErr = await toArray(branches.b).catch((e) => e);
expect(aErr).toBeInstanceOf(StreamRouteUnknownKeyError);
expect(bErr).toBeInstanceOf(StreamRouteUnknownKeyError);
expect((aErr as StreamRouteUnknownKeyError).code).toBe(
"STREAM.ROUTE_UNKNOWN_KEY"
);
});
it("routeBy supports number keys", { timeout: 5000 }, async () => {
const m = routeBy(fromArray<number>([1, 2, 3, 4, 5, 6]), (n) => n % 3, [
0, 1, 2,
] as const);
const r0 = m.get(0);
const r1 = m.get(1);
const r2 = m.get(2);
if (!r0 || !r1 || !r2) throw new Error("missing branch");
const [v0, v1, v2] = await Promise.all([
toArray(r0),
toArray(r1),
toArray(r2),
]);
expect(v0).toEqual([3, 6]);
expect(v1).toEqual([1, 4]);
expect(v2).toEqual([2, 5]);
});
it("routeBy supports symbol keys", { timeout: 5000 }, async () => {
const SA = Symbol("a");
const SB = Symbol("b");
const m = routeBy<{ kind: symbol; v: number }, symbol>(
fromArray([
{ kind: SA, v: 1 },
{ kind: SB, v: 2 },
{ kind: SA, v: 3 },
]),
(c) => c.kind,
[SA, SB]
);
const ra = m.get(SA);
const rb = m.get(SB);
if (!ra || !rb) throw new Error("missing branch");
const [va, vb] = await Promise.all([toArray(ra), toArray(rb)]);
expect(va.map((c) => c.v)).toEqual([1, 3]);
expect(vb.map((c) => c.v)).toEqual([2]);
});
it("source error propagates to all output branches", {
timeout: 5000,
}, async () => {
const source = new ReadableStream<{ kind: "a" | "b" }>({
start(controller) {
controller.enqueue({ kind: "a" });
controller.error(new Error("boom"));
},
});
const branches = route(source, (c) => c.kind, ["a", "b"] as const);
const aErr = await toArray(branches.a).catch((e) => e);
const bErr = await toArray(branches.b).catch((e) => e);
expect(aErr).toBeInstanceOf(Error);
expect((aErr as Error).message).toBe("boom");
expect(bErr).toBeInstanceOf(Error);
expect((bErr as Error).message).toBe("boom");
});
it("cancelling one branch removes its controller so writes become no-ops", {
timeout: 5000,
}, async () => {
// Exercises line 41: cancel() on a branch's ReadableStream calls
// controllers.delete(k), so subsequent enqueues to that key are skipped.
const source = fromArray<{ kind: "a" | "b"; n: number }>([
{ kind: "a", n: 1 },
{ kind: "b", n: 2 },
{ kind: "a", n: 3 },
]);
const branches = route(source, (c) => c.kind, ["a", "b"] as const);
// Cancel branch "a" immediately — controller for "a" is deleted
const aReader = branches.a.getReader();
await aReader.cancel("no-longer-needed");
// Branch "b" must still receive its chunk cleanly
const bResult = await toArray(branches.b);
expect(bResult).toEqual([{ kind: "b", n: 2 }]);
});
it("per-key ordering is preserved", { timeout: 5000 }, async () => {
const items: { kind: "x" | "y"; idx: number }[] = [];
for (let i = 0; i < 50; i++) {
items.push({ kind: i % 2 === 0 ? "x" : "y", idx: i });
}
const branches = route(fromArray(items), (c) => c.kind, [
"x",
"y",
] as const);
const [xs, ys] = await Promise.all([
toArray(branches.x),
toArray(branches.y),
]);
expect(xs.map((c) => c.idx)).toEqual(
items.filter((c) => c.kind === "x").map((c) => c.idx)
);
expect(ys.map((c) => c.idx)).toEqual(
items.filter((c) => c.kind === "y").map((c) => c.idx)
);
});
});

View File

@@ -0,0 +1,138 @@
import { StreamRouteUnknownKeyError } from "../../errors/stream-error.js";
import type { RouteOptions } from "./route.types.js";
/**
* Internal: build a `Map<K, ReadableStream<T>>` 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<T, K>(
source: ReadableStream<T>,
keyFn: (chunk: T) => K,
keys: readonly K[],
options: RouteOptions = {}
): Map<K, ReadableStream<T>> {
const onUnknownKey = options.onUnknownKey ?? "drop";
const controllers = new Map<K, ReadableStreamDefaultController<T>>();
const knownKeys = new Set<K>(keys);
const result = new Map<K, ReadableStream<T>>();
// 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<void>[] = [];
for (const k of keys) {
let resolveReady!: () => void;
const ready = new Promise<void>((r) => {
resolveReady = r;
});
controllerReady.push(ready);
const branch = new ReadableStream<T>({
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<void> => {
// 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<T, K extends string>(
source: ReadableStream<T>,
keyFn: (chunk: T) => K,
keys: readonly K[],
options?: RouteOptions
): Record<K, ReadableStream<T>> {
const m = buildRoutedMap<T, K>(source, keyFn, keys, options);
const out = {} as Record<K, ReadableStream<T>>;
for (const k of keys) {
out[k] = m.get(k) as ReadableStream<T>;
}
return out;
}
/**
* Like `route`, but returns a `Map<K, ReadableStream<T>>` — supporting key
* types beyond `string` (numbers, symbols, objects).
*/
export function routeBy<T, K>(
source: ReadableStream<T>,
keyFn: (chunk: T) => K,
keys: readonly K[],
options?: RouteOptions
): Map<K, ReadableStream<T>> {
return buildRoutedMap<T, K>(source, keyFn, keys, options);
}

View File

@@ -0,0 +1,14 @@
/**
* Options governing how `route`/`routeBy` handle a chunk whose key is not
* present in the declared key set.
*/
export interface RouteOptions {
/**
* Behaviour for chunks whose `keyFn` result is not a member of `keys`.
*
* - `'drop'` (default) — silently consume the chunk; no branch emits it.
* - `'error'` — every output branch errors with `StreamRouteUnknownKeyError`
* (`STREAM.ROUTE_UNKNOWN_KEY`) and the source is cancelled.
*/
onUnknownKey?: "drop" | "error";
}

View File

@@ -0,0 +1,2 @@
export { safeBranch } from "./safe-branch.js";
export type { SafeBranchOptions } from "./safe-branch.types.js";

View File

@@ -0,0 +1,92 @@
# safeBranch
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`safeBranch` is a pipeline operator that wraps a sub-pipeline factory in a fault boundary. It runs the supplied `factory(input)` to construct an inner `ReadableStream`, then forwards every chunk it produces to the wrapper's output stream. If the inner pipeline throws — synchronously while the factory is being invoked, or asynchronously while its stream is being read — the wrapper's output stream closes cleanly (no further chunks emitted) and the error is forwarded to `options.onError` as a `StreamBranchFailedError` whose `cause` is the original error.
The outer pipeline never observes the inner error. This is the difference between a normal `pipeThrough` (which propagates errors and tears down the pipeline) and `safeBranch` (which contains them). Use it for opportunistic side branches — analytics, mirroring, telemetry — where a failure must not bring down the primary data path.
## Features
- Isolates errors from inner sub-pipelines so the outer pipeline keeps running
- Forwards wrapped errors to `options.onError` as `StreamBranchFailedError`
- Preserves the original error via the `cause` chain
- Output stream closes cleanly after a branch error — no half-emitted state
- Silent mode when `onError` is omitted
- Synchronous factory throws are caught and reported the same way
## Usage: Basic Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { safeBranch } from "@suaveplan/streams/pipeline/safe-branch/safe-branch";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const wrapped = safeBranch<number, number>(
(input) =>
input.pipeThrough(
new TransformStream<number, number>({
transform(chunk, c) {
c.enqueue(chunk * 2);
},
})
)
);
const out = wrapped(fromArray([1, 2, 3]));
const result = await toArray(out); // [2, 4, 6]
```
## Usage: Advanced Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { safeBranch } from "@suaveplan/streams/pipeline/safe-branch/safe-branch";
import { StreamBranchFailedError } from "@suaveplan/streams/errors/stream-error";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
let lastError: StreamBranchFailedError | undefined;
const analyticsBranch = safeBranch<number, number>(
(input) =>
input.pipeThrough(
new TransformStream<number, number>({
transform(chunk, c) {
if (chunk < 0) throw new Error("negative not allowed");
c.enqueue(chunk);
},
})
),
{
onError: (err) => {
lastError = err as StreamBranchFailedError;
},
}
);
const collected = await toArray(analyticsBranch(fromArray([1, -2, 3])));
// collected captures whatever was emitted before the error;
// the outer pipeline does not throw.
console.log(lastError?.code); // "STREAM.BRANCH_FAILED"
console.log(lastError?.cause); // Error: negative not allowed
```
## API Reference
### `safeBranch<TIn, TOut>(factory, options?)`
- `factory: (input: ReadableStream<TIn>) => ReadableStream<TOut>` — constructs the inner sub-pipeline. May throw synchronously; failures are isolated.
- `options.onError?: (err: unknown) => void` — invoked with a `StreamBranchFailedError` when the inner pipeline errors. The original error is exposed via `err.cause`.
- **Returns** `(input: ReadableStream<TIn>) => ReadableStream<TOut>` — a wrapper function suitable for use anywhere a stream-shape transform is accepted.
- **Throws** Never. All errors are converted to `StreamBranchFailedError` and either delivered to `onError` or silently swallowed.
## Implementation Notes
- The wrapper acquires a single reader against the inner stream and pulls chunk-by-chunk inside the outer stream's `pull` callback. This preserves backpressure end-to-end: the outer consumer's pull cadence drives the inner sub-pipeline's read cadence.
- On error, the wrapper sets a `closed` flag, closes the outer controller, and calls `onError`. Subsequent pulls return `done` without re-invoking the inner reader.
- Cancellation of the wrapper propagates to the inner reader via `reader.cancel(reason)`. Cancellation errors from the inner stream are swallowed — they are part of the isolated branch by definition.
- The error code is `STREAM.BRANCH_FAILED` (registered in `errors/stream-error.ts`). Inspect `err.code` to detect a branch failure programmatically; inspect `err.cause` to recover the original error.
- `safeBranch` does not retry or restart the inner pipeline. After failure, the wrapper is terminal. To retry, pair `safeBranch` with `@suaveplan/retry` upstream of the factory.

View File

@@ -0,0 +1,233 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { StreamBranchFailedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { safeBranch } from "./safe-branch.js";
/**
* Builds a ReadableStream that emits `value` once then errors with `error`
* on the next pull.
*/
function failingStream<T>(value: T, error: Error): ReadableStream<T> {
let stage = 0;
return new ReadableStream<T>({
pull(controller) {
if (stage === 0) {
controller.enqueue(value);
stage = 1;
return;
}
controller.error(error);
},
});
}
describe("safeBranch", () => {
it("branch error invokes onError without propagating to outer pipeline", {
timeout: 5000,
}, async () => {
const onError = vi.fn();
const factory = (_input: ReadableStream<number>) =>
failingStream<number>(42, new Error("inner boom"));
const wrapped = safeBranch(factory, { onError });
const out = wrapped(fromArray([1, 2, 3]));
// Outer must not throw — collecting succeeds.
const collected = await toArray(out);
expect(collected).toEqual([42]);
expect(onError).toHaveBeenCalledTimes(1);
});
it("output closes cleanly after branch error (no chunks emitted post-error)", {
timeout: 5000,
}, async () => {
const factory = (_input: ReadableStream<number>) =>
failingStream<number>(7, new Error("explode"));
const wrapped = safeBranch(factory);
const out = wrapped(fromArray([1]));
const reader = out.getReader();
const first = await reader.read();
expect(first).toEqual({ done: false, value: 7 });
const second = await reader.read();
expect(second).toEqual({ done: true, value: undefined });
const third = await reader.read();
expect(third).toEqual({ done: true, value: undefined });
});
it("branch with no error passes through chunks unchanged", {
timeout: 5000,
}, async () => {
const onError = vi.fn();
const factory = (input: ReadableStream<number>) =>
input.pipeThrough(
new TransformStream<number, number>({
transform(chunk, controller) {
controller.enqueue(chunk * 2);
},
})
);
const wrapped = safeBranch(factory, { onError });
const out = wrapped(fromArray([1, 2, 3]));
const collected = await toArray(out);
expect(collected).toEqual([2, 4, 6]);
expect(onError).not.toHaveBeenCalled();
});
it("onError receives a StreamBranchFailedError with original error on cause", {
timeout: 5000,
}, async () => {
const original = new Error("root cause");
let captured: unknown;
const onError = (err: unknown) => {
captured = err;
};
const factory = (_input: ReadableStream<number>) =>
failingStream<number>(1, original);
const wrapped = safeBranch(factory, { onError });
await toArray(wrapped(fromArray([1])));
expect(captured).toBeInstanceOf(StreamBranchFailedError);
expect((captured as StreamBranchFailedError).cause).toBe(original);
});
it("works without an onError (silent failure mode)", {
timeout: 5000,
}, async () => {
const factory = (_input: ReadableStream<number>) =>
failingStream<number>(99, new Error("silent"));
const wrapped = safeBranch(factory);
const out = wrapped(fromArray([1]));
// No throw — failure is swallowed.
const collected = await toArray(out);
expect(collected).toEqual([99]);
});
it("wrapping multiple safeBranch instances chain independently", {
timeout: 5000,
}, async () => {
const errA = vi.fn();
const errB = vi.fn();
const branchA = safeBranch<number, number>(
(_input) => failingStream<number>(10, new Error("A fail")),
{ onError: errA }
);
const branchB = safeBranch<number, number>(
(input) =>
input.pipeThrough(
new TransformStream<number, number>({
transform(chunk, controller) {
controller.enqueue(chunk + 100);
},
})
),
{ onError: errB }
);
const a = await toArray(branchA(fromArray([1, 2])));
const b = await toArray(branchB(fromArray([1, 2])));
expect(a).toEqual([10]);
expect(b).toEqual([101, 102]);
expect(errA).toHaveBeenCalledTimes(1);
expect(errB).not.toHaveBeenCalled();
});
it("factory throwing synchronously is isolated and reported", {
timeout: 5000,
}, async () => {
const onError = vi.fn();
const wrapped = safeBranch<number, number>(
() => {
throw new Error("factory failed");
},
{ onError }
);
const out = wrapped(fromArray([1, 2]));
const collected = await toArray(out);
expect(collected).toEqual([]);
expect(onError).toHaveBeenCalledTimes(1);
const arg = onError.mock.calls[0]?.[0];
expect(arg).toBeInstanceOf(StreamBranchFailedError);
});
it("cancel on outer stream propagates to inner reader (cancel path)", {
timeout: 5000,
}, async () => {
let innerCancelled = false;
const factory = (_input: ReadableStream<number>) =>
new ReadableStream<number>({
pull(controller) {
controller.enqueue(42);
},
cancel() {
innerCancelled = true;
},
});
const wrapped = safeBranch(factory);
const out = wrapped(fromArray([1, 2, 3]));
const reader = out.getReader();
await reader.read();
await reader.cancel("user-cancel");
await new Promise((r) => setTimeout(r, 20));
expect(innerCancelled).toBe(true);
});
it("factory throw without onError swallows error and emits empty stream", {
timeout: 5000,
}, async () => {
const factory = (
_input: ReadableStream<number>
): ReadableStream<number> => {
throw new Error("factory-failure");
};
const wrapped = safeBranch(factory);
const out = wrapped(fromArray([1]));
const reader = out.getReader();
const result = await reader.read();
expect(result.done).toBe(true);
});
it("factory throw with onError reports a StreamBranchFailedError", {
timeout: 5000,
}, async () => {
const onError = vi.fn();
const factory = (
_input: ReadableStream<number>
): ReadableStream<number> => {
throw new Error("factory-failure");
};
const wrapped = safeBranch(factory, { onError });
const out = wrapped(fromArray([1]));
const reader = out.getReader();
const result = await reader.read();
expect(result.done).toBe(true);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(
StreamBranchFailedError
);
});
it("pull returns done immediately when closed=true after inner error", {
timeout: 5000,
}, async () => {
// After the inner stream errors, closed is set to true.
// A subsequent pull call must hit the `if (closed)` guard and close immediately.
const factory = (_input: ReadableStream<number>) =>
failingStream<number>(1, new Error("boom"));
const wrapped = safeBranch(factory);
const out = wrapped(fromArray([1]));
const reader = out.getReader();
// First read: inner yields value 1
const first = await reader.read();
expect(first.value).toBe(1);
// Second read: inner errors -> closed=true -> controller.close()
const second = await reader.read();
expect(second.done).toBe(true);
// Third read: `if (closed)` guard fires, closes immediately
const third = await reader.read();
expect(third.done).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import { StreamBranchFailedError } from "../../errors/stream-error.js";
import type { SafeBranchOptions } from "./safe-branch.types.js";
/**
* Wraps a sub-pipeline factory so that any error inside the inner pipeline is
* isolated from the outer pipeline. On error, the wrapper's output stream
* closes cleanly (no further chunks emitted) and the error is forwarded to
* `options.onError` as a `StreamBranchFailedError` whose `cause` is the
* original error. Without `onError`, the failure is swallowed silently.
*
* The outer pipeline never observes the inner error — `safeBranch` is the
* fault boundary.
*/
export function safeBranch<TIn, TOut>(
factory: (input: ReadableStream<TIn>) => ReadableStream<TOut>,
options: SafeBranchOptions = {}
): (input: ReadableStream<TIn>) => ReadableStream<TOut> {
const { onError } = options;
return (input: ReadableStream<TIn>): ReadableStream<TOut> => {
let inner: ReadableStream<TOut>;
try {
inner = factory(input);
} catch (err) {
const wrapped = new StreamBranchFailedError({ cause: err });
if (onError) {
onError(wrapped);
}
return new ReadableStream<TOut>({
start(controller) {
controller.close();
},
});
}
const reader = inner.getReader();
let _closed = false;
return new ReadableStream<TOut>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
_closed = true;
controller.close();
return;
}
controller.enqueue(value);
} catch (err) {
_closed = true;
const wrapped = new StreamBranchFailedError({ cause: err });
if (onError) {
onError(wrapped);
}
controller.close();
}
},
async cancel(reason) {
_closed = true;
try {
await reader.cancel(reason);
} catch {
// Cancellation errors are part of the isolated branch — swallow.
}
},
});
};
}

View File

@@ -0,0 +1,10 @@
/** Options for {@link safeBranch}. */
export interface SafeBranchOptions {
/**
* Invoked when the wrapped sub-pipeline errors. Receives a
* `StreamBranchFailedError` whose `cause` is the original error. If
* omitted, errors are swallowed silently and the output stream still
* closes cleanly.
*/
onError?: (err: unknown) => void;
}

View File

@@ -0,0 +1,2 @@
export { zip } from "./zip.js";
export type { ZipOptions, ZipShortInputPolicy } from "./zip.types.js";

View File

@@ -0,0 +1,107 @@
# zip
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`zip` performs a sample-aligned merge of N input `ReadableStream`s into a single
stream of tuples. Unlike `merge`, which interleaves values in arrival order,
`zip` waits for every input to produce a chunk before emitting one tuple per
round. This makes it the canonical primitive for combining parallel time-series,
correlated event streams, or any pair of producers whose values must be paired
position-by-position rather than raced.
The operation is fully back-pressured — readers are pulled exactly once per
output tuple — and is preserved end-to-end by propagating `cancel()` from the
output stream to every input.
## Features
- Tuple output with full TypeScript inference: `zip([s1, s2] as const)` is
typed as `ReadableStream<[T1, T2]>`.
- Three short-input policies: `close` (default), `error`, `pad`.
- `StreamZipLengthMismatchError` (code `STREAM.ZIP_LENGTH_MISMATCH`) under
`error` policy carries the index of the input that ended early.
- `pad` policy substitutes the configured `padValue` for any input that has
already ended, continuing until the longest input closes.
- Cancellation propagates to every input reader.
- Empty input list returns an immediately-closed stream.
## Usage: Basic Example
```ts
import { fromArray, toArray, zip } from "@suaveplan/streams";
const numbers = fromArray([1, 2, 3]);
const letters = fromArray(["a", "b", "c"]);
const tuples = await toArray(zip([numbers, letters] as const));
// tuples === [[1, "a"], [2, "b"], [3, "c"]]
```
## Usage: Advanced Example
```ts
import { fromArray, toArray, zip } from "@suaveplan/streams";
import { StreamZipLengthMismatchError } from "@suaveplan/streams/errors/stream-error";
// Pad short inputs so every tuple has both slots filled.
const padded = await toArray(
zip(
[fromArray([1, 2, 3, 4]), fromArray(["a", "b"])] as const,
{ onShortInput: "pad", padValue: null }
)
);
// padded === [[1, "a"], [2, "b"], [3, null], [4, null]]
// Strict mode — any length mismatch is a hard error.
try {
await toArray(
zip(
[fromArray([1, 2, 3]), fromArray(["a", "b"])] as const,
{ onShortInput: "error" }
)
);
} catch (err) {
if (err instanceof StreamZipLengthMismatchError) {
// err.code === "STREAM.ZIP_LENGTH_MISMATCH"
}
}
```
## API Reference
### `zip<T extends readonly unknown[]>(streams, options?): ReadableStream<T>`
- `streams: { readonly [K in keyof T]: ReadableStream<T[K]> }` — tuple of input
streams. Pass with `as const` for tuple inference.
- `options.onShortInput?: 'close' | 'error' | 'pad'` — short-input behaviour.
Defaults to `'close'`.
- `options.padValue?: unknown` — value substituted for ended inputs when
`onShortInput === 'pad'`. Defaults to `undefined`.
- **Returns:** `ReadableStream<T>` emitting one tuple per round.
- **Throws:** the output errors with `StreamZipLengthMismatchError` when
`onShortInput === 'error'` and any input ends before the others.
### `ZipOptions<TValue = unknown>`
Options bag passed to `zip`. See above for fields.
### `ZipShortInputPolicy`
String-literal union: `'close' | 'error' | 'pad'`.
## Implementation Notes
- Each pull round issues `read()` on every still-open reader in parallel via
`Promise.all`. This keeps tuple emission strictly ordered while letting the
runtime pipeline reads.
- The `pad` policy tracks per-input "ended" flags so closed readers are not
re-read on subsequent rounds — only `padValue` is substituted in their slot.
- `cancel()` on the output settles cancels on every input via
`Promise.allSettled`, so a single failing cancel cannot leak others.
- Read errors propagate verbatim via `controller.error(err)`; only the
`'error'` short-input policy wraps in `StreamZipLengthMismatchError`.
- An empty input list short-circuits to an immediately-closed stream rather
than starting any readers.

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "../../readable/from-array/from-array.js";
import { toArray } from "../../sinks/to-array/to-array.js";
import { zip } from "./zip.js";
describe("zip", () => {
it("zips equal-length streams into tuples", { timeout: 5000 }, async () => {
const result = await toArray(
zip([fromArray([1, 2, 3]), fromArray(["a", "b", "c"])] as const)
);
expect(result).toEqual([
[1, "a"],
[2, "b"],
[3, "c"],
]);
});
it("closes at first end under 'close' policy", {
timeout: 5000,
}, async () => {
const result = await toArray(
zip([fromArray([1, 2, 3, 4]), fromArray(["a", "b"])] as const, {
onShortInput: "close",
})
);
expect(result).toEqual([
[1, "a"],
[2, "b"],
]);
});
it("errors with STREAM.ZIP_LENGTH_MISMATCH under 'error' policy", {
timeout: 5000,
}, async () => {
const stream = zip(
[fromArray([1, 2, 3, 4]), fromArray(["a", "b"])] as const,
{ onShortInput: "error" }
);
await expect(toArray(stream)).rejects.toMatchObject({
code: "STREAM.ZIP_LENGTH_MISMATCH",
});
});
it("pads short inputs under 'pad' policy", { timeout: 5000 }, async () => {
const result = await toArray(
zip([fromArray([1, 2, 3, 4]), fromArray(["a", "b"])] as const, {
onShortInput: "pad",
padValue: "PAD",
})
);
expect(result).toEqual([
[1, "a"],
[2, "b"],
[3, "PAD"],
[4, "PAD"],
]);
});
it("produces empty output for empty streams", {
timeout: 5000,
}, async () => {
const result = await toArray(
zip([fromArray<number>([]), fromArray<string>([])] as const)
);
expect(result).toEqual([]);
});
it("propagates source.cancel to all input streams", {
timeout: 5000,
}, async () => {
let cancelCount = 0;
const makeStream = () =>
new ReadableStream<number>({
pull() {
return new Promise(() => {});
},
cancel() {
cancelCount++;
},
});
const zipped = zip([makeStream(), makeStream()] as const);
const reader = zipped.getReader();
await reader.cancel();
await new Promise((r) => setTimeout(r, 20));
expect(cancelCount).toBe(2);
});
it("round-trips: zip then unzip equal-length inputs", {
timeout: 5000,
}, async () => {
const a = [1, 2, 3];
const b = ["x", "y", "z"];
const tuples = await toArray(
zip([fromArray(a), fromArray(b)] as const)
);
const left = tuples.map((t) => t[0]);
const right = tuples.map((t) => t[1]);
expect(left).toEqual(a);
expect(right).toEqual(b);
});
it("returns immediately-closed stream for empty input list", {
timeout: 5000,
}, async () => {
const result = await toArray(zip([] as const));
expect(result).toEqual([]);
});
it("errors the output when a source read rejects (Promise.all catch path)", {
timeout: 5000,
}, async () => {
const boom = new Error("source read failed");
// A stream that errors immediately after enqueueing one value
const erroring = new ReadableStream<number>({
start(controller) {
controller.error(boom);
},
});
const normal = fromArray([10, 20]);
const zipped = zip([erroring, normal] as const);
let caught: unknown;
try {
await toArray(zipped);
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
});
it("outputClosed guard: pull after output is closed returns without re-closing", {
timeout: 5000,
}, async () => {
// When both equal-length streams end simultaneously, outputClosed is set
// to true. A subsequent pull must return early via the outputClosed guard
// without causing a double-close error.
const a = fromArray([1, 2]);
const b = fromArray(["x", "y"]);
const result = await toArray(zip([a, b] as const));
expect(result).toEqual([
[1, "x"],
[2, "y"],
]);
});
});

View File

@@ -0,0 +1,120 @@
import { StreamZipLengthMismatchError } from "../../errors/stream-error.js";
import type { ZipOptions } from "./zip.types.js";
/**
* Sample-aligned merge of N input streams into a single stream of tuples.
*
* One tuple is emitted per round once every input has produced a chunk. The
* behaviour when an input ends earlier than the others is controlled by
* {@link ZipOptions.onShortInput}:
*
* - `close` (default): the output closes when the first input ends.
* - `error`: the output errors with `StreamZipLengthMismatchError`.
* - `pad`: short inputs are padded with `options.padValue` until the longest
* input ends.
*
* Cancellation of the output stream propagates `cancel()` to every input
* stream.
*/
export function zip<T extends readonly unknown[]>(
streams: { readonly [K in keyof T]: ReadableStream<T[K]> },
options: ZipOptions = {}
): ReadableStream<T> {
const onShortInput = options.onShortInput ?? "close";
const padValue = options.padValue;
const totalInputs = streams.length;
if (totalInputs === 0) {
return new ReadableStream<T>({
start(c) {
c.close();
},
});
}
const readers = streams.map((s) => s.getReader());
// Tracks which inputs have already ended; padding uses this to substitute
// padValue rather than awaiting a closed reader.
const ended: boolean[] = new Array(totalInputs).fill(false);
let endedCount = 0;
let _outputClosed = false;
return new ReadableStream<T>({
async pull(controller) {
// Read one chunk from every still-open reader in parallel.
const reads = readers.map(async (reader, index) => {
if (ended[index]) {
return { index, done: true as const, value: undefined };
}
const result = await reader.read();
return {
index,
done: result.done === true,
value: result.value as unknown,
};
});
let results: Array<{
index: number;
done: boolean;
value: unknown;
}>;
try {
results = await Promise.all(reads);
} catch (err) {
_outputClosed = true;
controller.error(err);
return;
}
// Note any inputs that ended this round.
for (const r of results) {
if (r.done && !ended[r.index]) {
ended[r.index] = true;
endedCount++;
}
}
const anyEnded = results.some((r) => r.done);
if (anyEnded) {
if (onShortInput === "close") {
_outputClosed = true;
controller.close();
return;
}
if (onShortInput === "error") {
const firstEnded = results.find((r) => r.done) as {
index: number;
};
_outputClosed = true;
controller.error(
new StreamZipLengthMismatchError({
endedIndex: firstEnded.index,
totalInputs,
})
);
return;
}
// 'pad': continue until every input has ended.
if (endedCount === totalInputs) {
_outputClosed = true;
controller.close();
return;
}
const tuple = results.map((r) =>
r.done ? padValue : r.value
) as unknown as T;
controller.enqueue(tuple);
return;
}
const tuple = results.map((r) => r.value) as unknown as T;
controller.enqueue(tuple);
},
async cancel(reason) {
_outputClosed = true;
await Promise.allSettled(readers.map((r) => r.cancel(reason)));
},
});
}

View File

@@ -0,0 +1,18 @@
/**
* Behaviour when one or more zipped inputs end before the others.
*
* - `close` (default): the output stream closes as soon as the first input ends.
* - `error`: the output stream errors with `StreamZipLengthMismatchError`
* carrying the index of the input that ended early.
* - `pad`: short inputs continue to emit `padValue` until the longest input
* ends; tuples therefore always have one entry per input.
*/
export type ZipShortInputPolicy = "close" | "error" | "pad";
/** Options accepted by {@link zip}. */
export interface ZipOptions<TValue = unknown> {
/** What to do when one input ends before others. Defaults to `close`. */
onShortInput?: ZipShortInputPolicy;
/** Value used to pad short inputs when `onShortInput === 'pad'`. */
padValue?: TValue;
}

View File

@@ -0,0 +1,151 @@
# BaseDuplexStream
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BaseDuplexStream` is the primitive for bidirectional WHATWG streams. It bundles a `ReadableStream` and a `WritableStream` whose `source` and `sink` are supplied by the caller, with sensible queuing-strategy defaults for byte vs. object mode. It exists as the foundation for higher-level primitives (`NetworkStreamImpl` for WebRTC/WebSocket peers, future `WorkerStream` for transferable-buffer transport, etc.) and as a clean boundary for any consumer that needs "write to one end, read from the other" semantics without re-implementing the WHATWG plumbing.
The two sides are independent by design — a write to `writable` does NOT echo back on `readable` unless the supplied `source` is wired to do so. This decoupling is what makes the primitive reusable for echoing flows (test doubles), proxy flows (network bridges), and asymmetric flows (encode-on-write, decode-on-read).
## Features
- WHATWG-native `ReadableStream` + `WritableStream` exposed as `readable` / `writable` properties
- Caller-supplied `StreamSource<R>` (start / pull / cancel) and `StreamSink<W>` (start / write / abort / close)
- Default queuing strategy chooses between count-based (`objectMode: true`) and byte-size based (default) automatically
- `highWaterMark` is forwarded to both sides
- `cancel(reason)` on `readable` propagates `reason` to the source's `cancel` callback verbatim
- `abort(reason)` on `writable` propagates to the sink's `abort` callback
- All four sink hooks (`start`, `write`, `abort`, `close`) and three source hooks (`start`, `pull`, `cancel`) are optional — callers supply only what they need
- `createDuplexStream(source, sink, options?)` factory for inline usage
## Usage: Basic Example
```ts
import { createDuplexStream } from "@suaveplan/streams/primitives/duplex/duplex";
let pending: number | null = null;
const stream = createDuplexStream<number, number>(
{
pull(controller) {
if (pending !== null) {
controller.enqueue(pending);
pending = null;
}
},
},
{
write(chunk) {
// Echo the doubled value back to the readable side
pending = chunk * 2;
},
}
);
const writer = stream.writable.getWriter();
await writer.write(7);
await writer.close();
const reader = stream.readable.getReader();
console.log((await reader.read()).value); // 14
```
## Usage: Advanced Example
```ts
import { BaseDuplexStream } from "@suaveplan/streams/primitives/duplex/duplex";
import type {
StreamSink,
StreamSource,
DuplexStreamOptions,
} from "@suaveplan/streams/primitives/duplex/duplex.types";
interface Message {
seq: number;
payload: Uint8Array;
}
function createPeerStream(
onIncoming: (m: Message) => void
): BaseDuplexStream<Message, Message> {
const outgoing: Message[] = [];
let pullController:
| ReadableStreamDefaultController<Message>
| undefined;
const source: StreamSource<Message> = {
start(controller) {
pullController = controller;
},
pull(controller) {
const next = outgoing.shift();
if (next !== undefined) controller.enqueue(next);
},
cancel(reason) {
outgoing.length = 0;
pullController = undefined;
console.warn("peer stream cancelled", reason);
},
};
const sink: StreamSink<Message> = {
write(chunk) {
onIncoming(chunk);
},
close() {
pullController?.close();
},
abort(reason) {
pullController?.error(reason);
},
};
const options: DuplexStreamOptions = {
objectMode: true,
highWaterMark: 32,
};
return new BaseDuplexStream(source, sink, options);
}
```
## API Reference
### `BaseDuplexStream<R, W>`
#### `constructor(source: StreamSource<R>, sink: StreamSink<W>, options?: DuplexStreamOptions)`
Builds the bidirectional stream. Every callback on `source` and `sink` is optional.
- `options.objectMode` — `true` selects count-based queuing; `false` (default) selects byte-size queuing where each chunk's `byteLength` (or 1 if absent) counts toward `highWaterMark`.
- `options.highWaterMark` — applied to both sides when set; left to WHATWG defaults when omitted.
#### `readable: ReadableStream<R>`
The readable side, suitable for `pipeThrough` / `pipeTo` composition.
#### `writable: WritableStream<W>`
The writable side. Use `.getWriter()` for direct control or `.pipeTo` from another readable.
### `createDuplexStream<R, W>(source, sink, options?): DuplexStream<R, W>`
Inline factory equivalent to `new BaseDuplexStream(...)`.
### Types
- `StreamSource<R>` — `{ start?, pull?, cancel? }` with WHATWG controller signatures
- `StreamSink<W>` — `{ start?, write?, abort?, close? }` with WHATWG controller signatures
- `DuplexStreamOptions` — `{ objectMode?, highWaterMark? }`
- `DuplexStream<R, W>` — `{ readonly readable, readonly writable }` interface
## Implementation Notes
The constructor builds the `UnderlyingSource` and `UnderlyingSink` shapes by **conditionally** copying only the callbacks that exist on the caller-supplied objects. This avoids the WHATWG quirk where supplying `start: undefined` is materially different from omitting `start`, and it keeps the underlying-source object minimal so spec-compliant runtimes can apply their fast-path optimisations.
The `size` function lives outside the conditional copy because it's strategy-level, not source-level: a `byteLength`-aware function is installed when `objectMode` is false, and a default count-based strategy is installed when it is true. The chunk-size lookup uses optional chaining and falls back to 1 for non-byte chunks (`{ byteLength?: number } ?? 1`), which is the same approach used by `node:stream` and the WHATWG reference implementation.
`createDuplexStream` is a thin alias rather than a method on the class because the JS-style "create-some-stream" naming reads more idiomatically inside pipelines than `new BaseDuplexStream(...)`. The two have identical behaviour and runtime cost.
This primitive is intentionally minimal — it does not own a benchmark suite, an abort surface beyond the standard WHATWG plumbing, or an event surface. Higher-level primitives (`NetworkStreamImpl`) layer those concerns on top via composition.

View File

@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { BaseDuplexStream, createDuplexStream } from "./duplex.js";
describe("BaseDuplexStream", () => {
it("bidirectional flow round-trips a chunk", {
timeout: 5000,
}, async () => {
let pending: number | null = null;
const stream = new BaseDuplexStream<number, number>(
{
pull(controller) {
if (pending !== null) {
controller.enqueue(pending);
pending = null;
}
},
},
{
write(chunk) {
pending = chunk * 2;
},
}
);
const writer = stream.writable.getWriter();
await writer.write(7);
await writer.close();
const reader = stream.readable.getReader();
const first = await reader.read();
expect(first.value).toBe(14);
});
it("cancel propagates reason to source.cancel", {
timeout: 5000,
}, async () => {
const cancel = vi.fn();
const stream = new BaseDuplexStream<number, number>(
{
cancel,
},
{ write() {} }
);
const reason = new Error("done");
await stream.readable.cancel(reason);
expect(cancel).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenCalledWith(reason);
});
it("object-mode honours count-based highWaterMark", {
timeout: 5000,
}, async () => {
const stream = new BaseDuplexStream<{ id: number }, { id: number }>(
{ pull() {} },
{ write() {} },
{ objectMode: true, highWaterMark: 3 }
);
const writer = stream.writable.getWriter();
// The first three writes should resolve immediately under count-based
// backpressure even though chunks have no byteLength.
await writer.ready;
const writes = [
writer.write({ id: 1 }),
writer.write({ id: 2 }),
writer.write({ id: 3 }),
];
await Promise.all(writes);
});
it("byte-mode counts byteLength toward highWaterMark", {
timeout: 5000,
}, async () => {
// Without a byteLength on chunks the size function should fall
// back to 1 — verifying both branches of the size function.
const seen: unknown[] = [];
const stream = new BaseDuplexStream<Uint8Array, Uint8Array>(
{
pull() {},
},
{
write(chunk) {
seen.push(chunk);
},
},
{ highWaterMark: 16 }
);
const writer = stream.writable.getWriter();
await writer.write(new Uint8Array([1, 2, 3, 4]));
await writer.close();
expect(seen).toHaveLength(1);
});
it("size function returns 1 for non-byte chunks in byte mode", {
timeout: 5000,
}, async () => {
// Drives the `size: chunk?.byteLength ?? 1` fallback where chunk
// has no byteLength field.
const stream = new BaseDuplexStream<unknown, { id: number }>(
{ pull() {} },
{ write() {} },
{ objectMode: false, highWaterMark: 2 }
);
const writer = stream.writable.getWriter();
await writer.write({ id: 1 });
await writer.close();
});
it("start hooks fire on both sides", { timeout: 5000 }, async () => {
const sourceStart = vi.fn();
const sinkStart = vi.fn();
const stream = new BaseDuplexStream<number, number>(
{ start: sourceStart, pull() {} },
{ start: sinkStart, write() {} }
);
// Activate the streams to trigger start callbacks.
const reader = stream.readable.getReader();
const writer = stream.writable.getWriter();
await writer.close();
reader.releaseLock();
expect(sourceStart).toHaveBeenCalled();
expect(sinkStart).toHaveBeenCalled();
});
it("abort propagates to sink.abort", { timeout: 5000 }, async () => {
const abort = vi.fn();
const stream = new BaseDuplexStream<number, number>(
{ pull() {} },
{
write() {},
abort,
}
);
const writer = stream.writable.getWriter();
const reason = new Error("aborted");
await writer.abort(reason);
expect(abort).toHaveBeenCalledTimes(1);
expect(abort).toHaveBeenCalledWith(reason);
});
it("close hook fires when the writable side is closed", {
timeout: 5000,
}, async () => {
const close = vi.fn();
const stream = new BaseDuplexStream<number, number>(
{ pull() {} },
{ write() {}, close }
);
const writer = stream.writable.getWriter();
await writer.close();
expect(close).toHaveBeenCalledTimes(1);
});
it("createDuplexStream factory mirrors the constructor", {
timeout: 5000,
}, () => {
const stream = createDuplexStream<number, number>(
{ pull() {} },
{ write() {} }
);
expect(stream.readable).toBeInstanceOf(ReadableStream);
expect(stream.writable).toBeInstanceOf(WritableStream);
});
it("constructs with empty source and sink", { timeout: 5000 }, () => {
// Both source and sink can omit every method — the constructor
// must not assume anything.
const stream = new BaseDuplexStream({}, {});
expect(stream.readable).toBeInstanceOf(ReadableStream);
expect(stream.writable).toBeInstanceOf(WritableStream);
});
it("object-mode without highWaterMark uses default WHATWG strategy", {
timeout: 5000,
}, () => {
// Hits the `highWaterMark === undefined && sizeFn === undefined`
// branch where the queuing strategy is omitted entirely so the
// platform default applies.
const stream = new BaseDuplexStream<number, number>(
{ pull() {} },
{ write() {} },
{ objectMode: true }
);
expect(stream.readable).toBeInstanceOf(ReadableStream);
expect(stream.writable).toBeInstanceOf(WritableStream);
});
});

View File

@@ -0,0 +1,112 @@
import type {
DuplexStream,
DuplexStreamOptions,
StreamSink,
StreamSource,
} from "./duplex.types.js";
interface ChunkWithByteLength {
byteLength?: number;
}
function makeSizeFn(
objectMode: boolean | undefined
): ((chunk: unknown) => number) | undefined {
if (objectMode === true) return undefined;
return (chunk) => {
const c = chunk as ChunkWithByteLength | null | undefined;
return typeof c?.byteLength === "number" ? c.byteLength : 1;
};
}
function makeQueuingStrategy<T>(
highWaterMark: number | undefined,
sizeFn: ((chunk: unknown) => number) | undefined
): QueuingStrategy<T> | undefined {
if (highWaterMark === undefined && sizeFn === undefined) return undefined;
const strategy: { highWaterMark?: number; size?: QueuingStrategySize<T> } =
{};
if (highWaterMark !== undefined) strategy.highWaterMark = highWaterMark;
if (sizeFn !== undefined) strategy.size = sizeFn as QueuingStrategySize<T>;
return strategy as QueuingStrategy<T>;
}
/**
* Generic bidirectional stream. The readable side is wired through `source`
* and the writable side through `sink`; the two are independent — writes to
* `writable` do **not** echo to `readable` unless the caller wires the source
* to do so.
*
* Default queuing strategy:
* - `objectMode: true` → count-based, `highWaterMark` chunks
* - `objectMode: false` → byte-size based, `highWaterMark` bytes (chunks
* expected to expose `byteLength`; non-byte chunks count as 1)
*/
export class BaseDuplexStream<R = unknown, W = unknown>
implements DuplexStream<R, W>
{
readonly readable: ReadableStream<R>;
readonly writable: WritableStream<W>;
constructor(
source: StreamSource<R>,
sink: StreamSink<W>,
options: DuplexStreamOptions = {}
) {
const sizeFn = makeSizeFn(options.objectMode);
const underlyingSource: UnderlyingDefaultSource<R> = {};
if (source.start) {
underlyingSource.start = (controller) => source.start?.(controller);
}
if (source.pull) {
underlyingSource.pull = (controller) => source.pull?.(controller);
}
if (source.cancel) {
underlyingSource.cancel = (reason) => source.cancel?.(reason);
}
const underlyingSink: UnderlyingSink<W> = {};
if (sink.start) {
underlyingSink.start = (controller) => sink.start?.(controller);
}
if (sink.write) {
underlyingSink.write = (chunk, controller) =>
sink.write?.(chunk, controller);
}
if (sink.abort) {
underlyingSink.abort = (reason) => sink.abort?.(reason);
}
if (sink.close) {
underlyingSink.close = () => sink.close?.();
}
const readableStrategy = makeQueuingStrategy<R>(
options.highWaterMark,
sizeFn
);
const writableStrategy = makeQueuingStrategy<W>(
options.highWaterMark,
sizeFn
);
this.readable = readableStrategy
? new ReadableStream<R>(underlyingSource, readableStrategy)
: new ReadableStream<R>(underlyingSource);
this.writable = writableStrategy
? new WritableStream<W>(underlyingSink, writableStrategy)
: new WritableStream<W>(underlyingSink);
}
}
/**
* Factory function for {@link BaseDuplexStream}. Identical to
* `new BaseDuplexStream(...)` but reads more naturally inline.
*/
export function createDuplexStream<R = unknown, W = unknown>(
source: StreamSource<R>,
sink: StreamSink<W>,
options?: DuplexStreamOptions
): DuplexStream<R, W> {
return new BaseDuplexStream<R, W>(source, sink, options);
}

View File

@@ -0,0 +1,46 @@
/**
* Construction options for {@link BaseDuplexStream}. `objectMode` switches the
* queueing strategy so chunks are counted by item rather than by `byteLength`,
* which is the right shape for non-binary duplex flows (e.g. JSON event
* streams). `highWaterMark` is forwarded to both the readable and writable
* sides; callers that want asymmetric buffering should compose two streams
* instead of pushing this option in two directions.
*/
export interface DuplexStreamOptions {
objectMode?: boolean;
highWaterMark?: number;
}
/** WHATWG-compatible source interface — narrowed for the duplex use case. */
export interface StreamSource<R = unknown> {
start?(
controller: ReadableStreamDefaultController<R>
): void | Promise<void>;
pull?(controller: ReadableStreamDefaultController<R>): void | Promise<void>;
cancel?(reason?: unknown): void | Promise<void>;
}
/** WHATWG-compatible sink interface — narrowed for the duplex use case. */
export interface StreamSink<W = unknown> {
start?(controller: WritableStreamDefaultController): void | Promise<void>;
write?(
chunk: W,
controller: WritableStreamDefaultController
): void | Promise<void>;
abort?(reason?: unknown): void | Promise<void>;
close?(): void | Promise<void>;
}
export interface DuplexStream<R = unknown, W = unknown> {
readonly readable: ReadableStream<R>;
readonly writable: WritableStream<W>;
}
export interface NetworkStream extends DuplexStream<Uint8Array, Uint8Array> {
readonly id: string;
readonly protocol?: string;
readonly metadata: Map<string, unknown>;
readonly closed: boolean;
close(): Promise<void>;
reset(): Promise<void>;
}

View File

@@ -0,0 +1,7 @@
export { BaseDuplexStream, createDuplexStream } from "./duplex.js";
export type {
DuplexStream,
DuplexStreamOptions,
StreamSink,
StreamSource,
} from "./duplex.types.js";

View File

@@ -0,0 +1,8 @@
export {
createNetworkStream,
NetworkStreamImpl,
} from "./network.js";
export type {
NetworkStream,
NetworkStreamOptions,
} from "./network.types.js";

View File

@@ -0,0 +1,148 @@
# NetworkStreamImpl
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NetworkStreamImpl` is the bidirectional byte-stream primitive intended for network transports — WebRTC data channels, WebSockets, plain TCP, or any other peer-to-peer flow that needs an addressable, observable, resettable byte pipe. It extends the `BaseDuplexStream` shape with three things every real network stream needs and that a generic duplex doesn't carry: a stable `id`, an optional `protocol` discriminator, and a mutable `metadata` map so callers can attach session-level state (user ids, connection timestamps, peer fingerprints) to the stream itself.
It also adds two lifecycle methods — `close()` for graceful shutdown and `reset()` for abort-with-structured-error — that cleanly distinguish the "we're done" case from the "something went wrong, tear it down" case. Both are idempotent so that retry logic and cleanup hooks can call them defensively without managing state machines.
## Features
- WHATWG `readable: ReadableStream<Uint8Array>` + `writable: WritableStream<Uint8Array>`
- Stable `id`, optional `protocol` (e.g. `"webrtc"`, `"websocket"`, `"tcp"`)
- Mutable `metadata: Map<string, unknown>` — caller-supplied or empty by default
- `closed: boolean` flag observable at any time
- `close()` — idempotent graceful shutdown of both sides
- `reset()` — idempotent abort with `StreamNetworkResetError` (`STREAM.NETWORK_RESET`); the stream's `id` surfaces in `error.context.networkId`
- Writing after close or reset rejects with `StreamNetworkResetError`
- Pending readers are signalled with the same structured error on `reset()`
## Usage: Basic Example
```ts
import { createNetworkStream } from "@suaveplan/streams/primitives/network/network";
const stream = createNetworkStream("peer-1", { protocol: "webrtc" });
// Write a frame
const writer = stream.writable.getWriter();
await writer.write(new TextEncoder().encode("hello"));
// Read it on the other side
const reader = stream.readable.getReader();
const { value } = await reader.read();
console.log(new TextDecoder().decode(value!)); // "hello"
await stream.close();
```
## Usage: Advanced Example
```ts
import { NetworkStreamImpl } from "@suaveplan/streams/primitives/network/network";
import {
STREAM_CODES,
StreamNetworkResetError,
} from "@suaveplan/streams/errors/stream-error";
class WebRtcAdapter {
private readonly streams = new Map<string, NetworkStreamImpl>();
createPeer(peerId: string, userId: string): NetworkStreamImpl {
const meta = new Map<string, unknown>([
["userId", userId],
["openedAt", Date.now()],
]);
const stream = new NetworkStreamImpl(peerId, {
protocol: "webrtc",
metadata: meta,
});
this.streams.set(peerId, stream);
return stream;
}
async dropPeer(peerId: string, reason: "close" | "reset"): Promise<void> {
const stream = this.streams.get(peerId);
if (!stream) return;
await (reason === "close" ? stream.close() : stream.reset());
this.streams.delete(peerId);
}
}
// Distinguish reset from close at consumption time:
const stream = new NetworkStreamImpl("peer-x");
const reader = stream.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
process(value);
}
} catch (err) {
if ((err as { code?: string }).code === STREAM_CODES.NETWORK_RESET) {
const peerId = (err as StreamNetworkResetError).context?.["networkId"];
console.warn(`peer ${peerId} reset; reconnecting`);
} else {
throw err;
}
}
```
## API Reference
### `NetworkStreamImpl`
#### `constructor(id: string, options?: NetworkStreamOptions)`
- `id` — required. Stable identifier for this stream; surfaced verbatim in `StreamNetworkResetError` context on `reset()`.
- `options.protocol` — optional transport label.
- `options.metadata` — optional initial map. **The Map is owned by the stream after construction** — callers may keep a reference and mutate it, but the stream and the caller share the same instance.
#### `id: string`, `protocol?: string`, `metadata: Map<string, unknown>`
Read-only addressing and metadata fields.
#### `closed: boolean`
`true` once `close()` or `reset()` has run, or after the readable side has been cancelled.
#### `readable: ReadableStream<Uint8Array>`, `writable: WritableStream<Uint8Array>`
The two halves of the duplex pair. Writes to `writable` enqueue on `readable`.
#### `close(): Promise<void>`
Cleanly closes both sides. Idempotent. After `close()`, writes reject and `closed === true`.
#### `reset(): Promise<void>`
Aborts both sides with a `StreamNetworkResetError`. Pending readers reject with the same. Idempotent. The error carries the stream's `id` in `context.networkId`.
### `createNetworkStream(id, options?): NetworkStream`
Factory function — equivalent to `new NetworkStreamImpl(id, options)` but reads more naturally inline.
### `NetworkStreamOptions`
```ts
interface NetworkStreamOptions {
protocol?: string;
metadata?: Map<string, unknown>;
}
```
## Implementation Notes
The internal model is the simplest correct shape: writes call `controller.enqueue` on the readable controller. There's no internal queue beyond what WHATWG provides — backpressure is handled by the standard queuing strategy. This means a slow reader naturally applies backpressure to writes through the `getWriter().ready` promise, and consumers don't have to wire any custom flow control.
`close()` and `reset()` are both idempotent. The first call sets `isClosed = true` and triggers the matching cleanup on both sides; the second call is a no-op short-circuit. This matters because real-world transports often invoke shutdown handlers more than once (e.g. WebRTC `oniceconnectionstatechange` firing both "disconnected" and "closed"), and the consumer should be free to call `close` defensively without writing state-machine wrappers.
`reset()` sources its error class from `@suaveplan/streams` rather than throwing a generic `Error` so consumers downstream can match on `STREAM.NETWORK_RESET` without string-matching. The `context.networkId` field gives observability tools the exact stream that reset, which matters when a process holds many concurrent peer streams.
The metadata `Map` is intentionally mutable and intentionally shared between caller and stream. This is a deliberate design choice: callers often need to update connection state ("last seen", "auth token rotation") after construction, and copying the map at construction time would force them to re-read it through the stream every time. If a future use case demands isolation, a `new Map(initial)` copy can be added without breaking compatibility.
`NetworkStreamImpl` does not extend `BaseDuplexStream` even though both expose `readable` + `writable`. The two have different lifecycle semantics (network has reset/close, duplex doesn't), and inheritance would force the duplex base to grow methods it shouldn't have. They share `DuplexStream` as a structural interface instead, which is the right level of coupling.

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STREAM_CODES,
StreamNetworkResetError,
} from "../../errors/stream-error.js";
import { createNetworkStream, NetworkStreamImpl } from "./network.js";
describe("NetworkStreamImpl", () => {
it("metadata id and protocol accessible after construction", {
timeout: 5000,
}, () => {
const meta = new Map<string, unknown>([["userId", "u-42"]]);
const stream = new NetworkStreamImpl("peer-1", {
protocol: "webrtc",
metadata: meta,
});
expect(stream.id).toBe("peer-1");
expect(stream.protocol).toBe("webrtc");
expect(stream.metadata.get("userId")).toBe("u-42");
// Metadata Map is the same instance the caller passed in.
expect(stream.metadata).toBe(meta);
expect(stream.closed).toBe(false);
});
it("defaults protocol to undefined and metadata to an empty map", {
timeout: 5000,
}, () => {
const stream = new NetworkStreamImpl("peer-2");
expect(stream.protocol).toBeUndefined();
expect(stream.metadata).toBeInstanceOf(Map);
expect(stream.metadata.size).toBe(0);
});
it("write enqueues on the readable side", { timeout: 5000 }, async () => {
const stream = new NetworkStreamImpl("peer-3");
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
await writer.write(new Uint8Array([1, 2, 3]));
const first = await reader.read();
expect(first.value).toEqual(new Uint8Array([1, 2, 3]));
});
it("close() flips closed flag and rejects subsequent writes", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-4");
await stream.close();
expect(stream.closed).toBe(true);
// After close(), the writable is no longer accepting writes —
// getting a writer either throws or the write rejects.
await expect(async () => {
const writer = stream.writable.getWriter();
await writer.write(new Uint8Array([1]));
}).rejects.toBeDefined();
});
it("close() is idempotent — second call is a no-op", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-5");
await stream.close();
await expect(stream.close()).resolves.toBeUndefined();
});
it("reset() aborts pending reader with STREAM.NETWORK_RESET", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-6");
const reader = stream.readable.getReader();
const pending = reader.read();
await stream.reset();
await expect(pending).rejects.toBeInstanceOf(StreamNetworkResetError);
await expect(pending).rejects.toMatchObject({
code: STREAM_CODES.NETWORK_RESET,
context: { networkId: "peer-6" },
});
});
it("reset() is idempotent — second call is a no-op", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-7");
await stream.reset();
await expect(stream.reset()).resolves.toBeUndefined();
});
it("writing after reset rejects with STREAM.NETWORK_RESET", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-8");
await stream.reset();
await expect(async () => {
const writer = stream.writable.getWriter();
await writer.write(new Uint8Array([1]));
}).rejects.toBeDefined();
});
it("cancelling readable closes the stream", { timeout: 5000 }, async () => {
const stream = new NetworkStreamImpl("peer-9");
await stream.readable.cancel(new Error("done"));
expect(stream.closed).toBe(true);
});
it("abort() on writable propagates to the readable side", {
timeout: 5000,
}, async () => {
const stream = new NetworkStreamImpl("peer-10");
const reader = stream.readable.getReader();
const writer = stream.writable.getWriter();
const pending = reader.read();
await writer.abort(new Error("aborted"));
await expect(pending).rejects.toThrowError("aborted");
expect(stream.closed).toBe(true);
});
it("createNetworkStream factory mirrors the constructor", {
timeout: 5000,
}, () => {
const stream = createNetworkStream("peer-11", {
protocol: "websocket",
});
expect(stream).toBeDefined();
expect(stream.id).toBe("peer-11");
expect(stream.protocol).toBe("websocket");
});
it("writing after readable.cancel throws STREAM.NETWORK_RESET via write handler", {
timeout: 5000,
}, async () => {
// Drives the writable.write handler's `if (this.isClosed)` throw
// path: cancel sets isClosed via the readable cancel callback,
// leaving the writable still open from WHATWG's perspective. The
// next user-level write reaches our handler and throws.
const stream = new NetworkStreamImpl("peer-12");
await stream.readable.cancel(new Error("done"));
const writer = stream.writable.getWriter();
await expect(
writer.write(new Uint8Array([1, 2]))
).rejects.toBeInstanceOf(StreamNetworkResetError);
});
it("writable.close() handler runs when called directly", {
timeout: 5000,
}, async () => {
// Closing the writer directly (not via stream.close()) takes the
// sink's `close` callback path which flips isClosed and closes
// the readable controller.
const stream = new NetworkStreamImpl("peer-13");
const writer = stream.writable.getWriter();
await writer.close();
expect(stream.closed).toBe(true);
});
});

View File

@@ -0,0 +1,114 @@
import { StreamNetworkResetError } from "../../errors/stream-error.js";
import type { NetworkStream, NetworkStreamOptions } from "./network.types.js";
/**
* Bidirectional byte-stream pair tagged with `id`, optional `protocol`, and a
* mutable `metadata` map. Writes to the writable side enqueue on the readable
* side — the simplest network-bridge shape, suitable for in-memory transports
* (test doubles, worker bridges) and for adapters that wrap WebRTC,
* WebSocket, or TCP into a uniform readable/writable surface.
*
* - `close()` cleanly closes both sides.
* - `reset()` aborts both sides with a {@link StreamNetworkResetError}
* carrying the stream's `id` in the error context, so observers see a
* structured signal rather than a generic Error.
*/
export class NetworkStreamImpl implements NetworkStream {
readonly readable: ReadableStream<Uint8Array>;
readonly writable: WritableStream<Uint8Array>;
readonly id: string;
readonly protocol?: string;
readonly metadata: Map<string, unknown>;
private isClosed = false;
private readableController?: ReadableStreamDefaultController<Uint8Array>;
constructor(id: string, options: NetworkStreamOptions = {}) {
this.id = id;
if (options.protocol !== undefined) {
this.protocol = options.protocol;
}
this.metadata = options.metadata ?? new Map<string, unknown>();
this.readable = new ReadableStream<Uint8Array>({
start: (controller) => {
this.readableController = controller;
},
cancel: () => {
this.isClosed = true;
},
});
this.writable = new WritableStream<Uint8Array>({
write: (chunk) => {
if (this.isClosed) {
throw new StreamNetworkResetError({
message: "write to closed network stream",
networkId: this.id,
});
}
this.readableController?.enqueue(chunk);
},
close: () => {
this.readableController?.close();
this.isClosed = true;
},
abort: (reason) => {
this.readableController?.error(reason);
this.isClosed = true;
},
});
}
get closed(): boolean {
return this.isClosed;
}
/** Cleanly closes both sides. Idempotent. */
async close(): Promise<void> {
if (this.isClosed) return;
this.isClosed = true;
try {
this.readableController?.close();
} catch {
// Already closed via cancel/error — no-op.
}
try {
await this.writable.close();
} catch {
// Already closed/aborted by sink — no-op.
}
}
/**
* Aborts both sides with {@link StreamNetworkResetError}. Pending readers
* see the structured error; subsequent writes throw the same.
* Idempotent.
*/
async reset(): Promise<void> {
if (this.isClosed) return;
this.isClosed = true;
const err = new StreamNetworkResetError({ networkId: this.id });
try {
this.readableController?.error(err);
} catch {
// Already errored — no-op.
}
try {
await this.writable.abort(err);
} catch {
// Already aborted — no-op.
}
}
}
/**
* Factory function for {@link NetworkStreamImpl}. Accepts the same options
* as the constructor.
*/
export function createNetworkStream(
id: string,
options?: NetworkStreamOptions
): NetworkStream {
return new NetworkStreamImpl(id, options);
}

View File

@@ -0,0 +1,23 @@
import type { DuplexStream } from "../duplex/duplex.types.js";
/**
* A bidirectional byte-stream pair tagged with an `id`, an optional
* `protocol` discriminator, and a `metadata` map. Designed for network
* transports (WebRTC, WebSocket, TCP) where the consumer needs to address
* peers and attach connection-level state.
*/
export interface NetworkStream extends DuplexStream<Uint8Array, Uint8Array> {
readonly id: string;
readonly protocol?: string;
readonly metadata: Map<string, unknown>;
readonly closed: boolean;
close(): Promise<void>;
reset(): Promise<void>;
}
export interface NetworkStreamOptions {
/** Optional transport identifier — `"webrtc"`, `"websocket"`, etc. */
readonly protocol?: string;
/** Initial metadata. The map is owned by the stream after construction. */
readonly metadata?: Map<string, unknown>;
}

View File

@@ -0,0 +1,69 @@
# from-array
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Converts a JavaScript array into a WHATWG `ReadableStream`. Each array element is enqueued as a separate chunk in index order. The stream is closed automatically after all elements are pushed. This factory is the simplest entry point for creating a stream from in-memory data and is the building block for test fixtures and benchmark pipelines throughout the package.
The `highWaterMark` option controls the WHATWG backpressure queue depth. Keeping it at the default (`1`) applies maximum backpressure, ensuring the consumer controls the pull rate. Higher values pre-buffer more chunks, which is useful when the consumer is consistently slower than the source.
## Features
- Converts any `T[]` to `ReadableStream<T>`
- `AbortSignal` support: errors the stream immediately when the signal fires
- Configurable `highWaterMark` via `ReadableOptions`
- Zero external runtime dependencies
## Usage: Basic Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
const stream = fromArray([1, 2, 3]);
const reader = stream.getReader();
let result;
while (!(result = await reader.read()).done) {
console.log(result.value); // 1, 2, 3
}
```
## Usage: Advanced Example
```ts
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
import { mapStream } from "@suaveplan/streams/transforms/map-stream/map-stream";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const ac = new AbortController();
const stream = fromArray(["a", "b", "c"], { signal: ac.signal, highWaterMark: 4 });
const result = await toArray(stream.pipeThrough(mapStream((x) => x.toUpperCase())));
console.log(result); // ["A", "B", "C"]
// Cancellation: abort mid-flight
const longStream = fromArray(new Array(1_000_000).fill(0), { signal: ac.signal });
ac.abort();
const reader = longStream.getReader();
const { done } = await reader.read().catch(() => ({ done: true }));
console.log(done); // true (stream was aborted)
```
## API Reference
### `fromArray<T>(items: T[], options?: ReadableOptions): ReadableStream<T>`
**Parameters**:
- `items` — the source array; elements are enqueued in insertion order.
- `options.signal` — optional `AbortSignal`; errors the stream on abort.
- `options.highWaterMark` — WHATWG queue depth (default: `1`).
**Returns**: `ReadableStream<T>` that emits each element and then closes.
**Throws**: never directly; the stream's reader will throw the abort reason when the signal fires.
## Implementation Notes
The `pull` callback checks `signal.aborted` on every pull cycle rather than using an `abort` event listener. This avoids the need to clean up listeners and keeps the implementation leak-free. When the signal fires between two pull cycles the worst case is one additional chunk being enqueued before the abort is detected — this is acceptable because WHATWG streams do not guarantee atomic cancellation at the chunk boundary.

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { fromArray } from "./from-array.js";
describe("fromArray", () => {
it("emits all items in order", { timeout: 5000 }, async () => {
const stream = fromArray([1, 2, 3]);
const reader = stream.getReader();
const results: number[] = [];
let done = false;
while (!done) {
const chunk = await reader.read();
if (chunk.done) {
done = true;
} else {
results.push(chunk.value);
}
}
expect(results).toEqual([1, 2, 3]);
});
it("closes immediately for empty array", { timeout: 5000 }, async () => {
const stream = fromArray<number>([]);
const reader = stream.getReader();
const result = await reader.read();
expect(result.done).toBe(true);
});
it("works with strings", { timeout: 5000 }, async () => {
const stream = fromArray(["a", "b"]);
const reader = stream.getReader();
const r1 = await reader.read();
const r2 = await reader.read();
const r3 = await reader.read();
expect(r1).toEqual({ done: false, value: "a" });
expect(r2).toEqual({ done: false, value: "b" });
expect(r3).toEqual({ done: true, value: undefined });
});
it("aborts when signal is already aborted", { timeout: 5000 }, async () => {
const signal = AbortSignal.abort(new Error("cancelled"));
const stream = fromArray([1, 2, 3], { signal });
const reader = stream.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(Error);
});
it("aborts when signal fires mid-stream", { timeout: 5000 }, async () => {
const ac = new AbortController();
const items = new Array(100).fill(0).map((_, i) => i);
const stream = fromArray(items, { signal: ac.signal });
const reader = stream.getReader();
// read first item, then abort
await reader.read();
ac.abort(new Error("stop"));
let gotError = false;
try {
// drain remaining — eventually should error
for (let i = 0; i < 200; i++) {
const r = await reader.read();
if (r.done) break;
}
} catch {
gotError = true;
}
expect(gotError).toBe(true);
});
it("respects highWaterMark option", { timeout: 5000 }, async () => {
const stream = fromArray([1, 2, 3, 4], { highWaterMark: 4 });
const reader = stream.getReader();
const out: number[] = [];
let r = await reader.read();
while (!r.done) {
out.push(r.value);
r = await reader.read();
}
expect(out).toEqual([1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,33 @@
import type { ReadableOptions } from "../../types/common.js";
/**
* Creates a ReadableStream from a plain array.
* Each element is enqueued in order; the stream closes after all items are pushed.
*/
export function fromArray<T>(
items: T[],
options: ReadableOptions = {}
): ReadableStream<T> {
const { signal, highWaterMark = 1 } = options;
let index = 0;
return new ReadableStream<T>(
{
pull(controller) {
if (signal?.aborted) {
controller.error(signal.reason);
return;
}
if (index < items.length) {
controller.enqueue(items[index++]!);
} else {
controller.close();
}
},
cancel() {
// intentionally empty — nothing to release for arrays
},
},
new CountQueuingStrategy({ highWaterMark })
);
}

View File

@@ -0,0 +1 @@
export { fromArray } from "./from-array.js";

View File

@@ -0,0 +1,73 @@
# from-bytes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides three factories and utilities for working with binary `Uint8Array` streams: `fromBytes` slices a buffer into a chunked `ReadableStream<Uint8Array>`, `concatByteStreams` sequentially reads multiple streams into one, and `sliceByteStream` lazily emits a byte range without buffering the full stream. All operations use `Uint8Array.subarray` for zero-copy views wherever the platform permits.
These utilities are the foundation for file-processing pipelines, compression/decompression, and any feature that needs to move binary data through a backpressure-aware pipeline without unnecessary copying.
## Features
- `fromBytes`: splits a `Uint8Array` into configurable chunk sizes (default 64 KiB)
- `concatByteStreams`: sequential fan-in of multiple byte streams without buffering
- `sliceByteStream`: range-based lazy slice of a byte stream (`start`/`end` offsets)
- Zero-copy `subarray` views — no `slice` allocations unless the platform requires it
- AbortSignal support in `fromBytes`
- Configurable `highWaterMark` for WHATWG backpressure
## Usage: Basic Example
```ts
import { fromBytes } from "@suaveplan/streams/readable/from-bytes/from-bytes";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const data = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
const stream = fromBytes(data, 3); // chunks: [0,1,2], [3,4,5], [6,7]
const chunks = await toArray(stream);
console.log(chunks.map(c => Array.from(c)));
// [[0,1,2], [3,4,5], [6,7]]
```
## Usage: Advanced Example
```ts
import {
fromBytes,
concatByteStreams,
sliceByteStream,
} from "@suaveplan/streams/readable/from-bytes/from-bytes";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
const a = fromBytes(new Uint8Array([1, 2, 3]));
const b = fromBytes(new Uint8Array([4, 5, 6]));
const combined = concatByteStreams([a, b]);
// Slice bytes 1 to 4 (exclusive) from the combined stream
const sliced = sliceByteStream(combined, 1, 4);
const result = await toArray(sliced);
// result: Uint8Array([2, 3, 4])
```
## API Reference
### `fromBytes(data: Uint8Array, chunkSize?: number, options?: ReadableOptions): ReadableStream<Uint8Array>`
- `data` — source buffer; no copy is made.
- `chunkSize` — bytes per chunk (default: 65536).
- `options.signal` — AbortSignal, errors the stream on abort.
- `options.highWaterMark` — WHATWG queue depth (default: 1).
### `concatByteStreams(streams: ReadableStream<Uint8Array>[]): ReadableStream<Uint8Array>`
Reads each stream to completion before starting the next. The returned stream closes after all input streams close.
### `sliceByteStream(stream: ReadableStream<Uint8Array>, start: number, end?: number): ReadableStream<Uint8Array>`
Emits only bytes whose position falls within `[start, end)`. Cancels the underlying reader once `end` is reached.
## Implementation Notes
`fromBytes` uses `data.subarray(offset, end)` which returns a view sharing the same underlying `ArrayBuffer` — no allocation. `concatByteStreams` maintains a single active reader reference, releasing it before advancing to the next stream. `sliceByteStream` reads chunks from the underlying stream, computing per-chunk sub-ranges in a single loop without materialising the full stream.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { concatByteStreams, fromBytes, sliceByteStream } from "./from-bytes.js";
async function drain<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader();
const out: T[] = [];
let r = await reader.read();
while (!r.done) {
out.push(r.value);
r = await reader.read();
}
return out;
}
describe("fromBytes", () => {
it("emits a single chunk when data fits in one chunk", {
timeout: 5000,
}, async () => {
const data = new Uint8Array([1, 2, 3]);
const chunks = await drain(fromBytes(data, 100));
expect(chunks).toHaveLength(1);
expect(Array.from(chunks[0])).toEqual([1, 2, 3]);
});
it("splits into correct number of chunks", { timeout: 5000 }, async () => {
const data = new Uint8Array(10).fill(0xff);
const chunks = await drain(fromBytes(data, 3));
// 10 / 3 = ceil(3.33) = 4 chunks: [3, 3, 3, 1]
expect(chunks).toHaveLength(4);
expect(chunks[0].byteLength).toBe(3);
expect(chunks[3].byteLength).toBe(1);
});
it("returns empty stream for empty buffer", { timeout: 5000 }, async () => {
const chunks = await drain(fromBytes(new Uint8Array(0)));
expect(chunks).toHaveLength(0);
});
it("uses zero-copy subarray views", { timeout: 5000 }, async () => {
const data = new Uint8Array([10, 20, 30, 40]);
const chunks = await drain(fromBytes(data, 2));
// subarray shares the same underlying buffer
expect(chunks[0].buffer).toBe(data.buffer);
});
it("aborts on signal", { timeout: 5000 }, async () => {
const signal = AbortSignal.abort(new Error("abort"));
const stream = fromBytes(new Uint8Array(100), 10, { signal });
const reader = stream.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(Error);
});
it("cancel does not throw", { timeout: 5000 }, async () => {
const stream = fromBytes(new Uint8Array([1, 2, 3]));
const reader = stream.getReader();
await reader.read();
await expect(reader.cancel()).resolves.toBeUndefined();
});
});
describe("concatByteStreams", () => {
it("concatenates two streams in order", { timeout: 5000 }, async () => {
const a = fromBytes(new Uint8Array([1, 2, 3]));
const b = fromBytes(new Uint8Array([4, 5, 6]));
const chunks = await drain(concatByteStreams([a, b]));
const flat = chunks.flatMap((c) => Array.from(c));
expect(flat).toEqual([1, 2, 3, 4, 5, 6]);
});
it("handles empty streams list", { timeout: 5000 }, async () => {
const chunks = await drain(concatByteStreams([]));
expect(chunks).toHaveLength(0);
});
it("cancels current reader on cancel", { timeout: 5000 }, async () => {
const a = fromBytes(new Uint8Array(100), 10);
const b = fromBytes(new Uint8Array(100), 10);
const combined = concatByteStreams([a, b]);
const reader = combined.getReader();
await reader.read(); // get one chunk
await reader.cancel();
// no error thrown
});
});
describe("sliceByteStream", () => {
it("slices from start to end", { timeout: 5000 }, async () => {
const data = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
const stream = sliceByteStream(fromBytes(data, 3), 2, 7);
const chunks = await drain(stream);
const flat = chunks.flatMap((c) => Array.from(c));
expect(flat).toEqual([2, 3, 4, 5, 6]);
});
it("slices to end when end is omitted", { timeout: 5000 }, async () => {
const data = new Uint8Array([0, 1, 2, 3, 4]);
const stream = sliceByteStream(fromBytes(data), 2);
const chunks = await drain(stream);
const flat = chunks.flatMap((c) => Array.from(c));
expect(flat).toEqual([2, 3, 4]);
});
it("returns empty stream when start >= end", {
timeout: 5000,
}, async () => {
const data = new Uint8Array([0, 1, 2, 3]);
const stream = sliceByteStream(fromBytes(data), 5, 2);
const chunks = await drain(stream);
expect(chunks).toHaveLength(0);
});
it("returns full stream when slice covers all", {
timeout: 5000,
}, async () => {
const data = new Uint8Array([10, 20, 30]);
const stream = sliceByteStream(fromBytes(data), 0, 3);
const chunks = await drain(stream);
const flat = chunks.flatMap((c) => Array.from(c));
expect(flat).toEqual([10, 20, 30]);
});
it("cancel cancels the underlying stream reader", {
timeout: 5000,
}, async () => {
let cancelled = false;
const source = new ReadableStream<Uint8Array>({
pull() {
return new Promise(() => {});
},
cancel() {
cancelled = true;
},
});
const sliced = sliceByteStream(source, 0, 10);
const reader = sliced.getReader();
await reader.cancel();
expect(cancelled).toBe(true);
});
it("terminates exactly at end offset mid-chunk", {
timeout: 5000,
}, async () => {
// position >= end branch fires mid-stream (end within a chunk boundary)
const data = new Uint8Array([0, 1, 2, 3, 4, 5]);
// chunk size 6 — all 6 bytes in one chunk; slice bytes 1..4
const stream = sliceByteStream(fromBytes(data, 6), 1, 4);
const chunks = await drain(stream);
const flat = chunks.flatMap((c) => Array.from(c));
expect(flat).toEqual([1, 2, 3]);
});
});

View File

@@ -0,0 +1,119 @@
import type { ReadableOptions } from "../../types/common.js";
/** Default chunk size for byte streams: 64 KiB. */
const DEFAULT_CHUNK_SIZE = 65536;
/**
* Creates a ReadableStream<Uint8Array> from a Uint8Array,
* slicing it into chunks of at most `chunkSize` bytes.
*/
export function fromBytes(
data: Uint8Array,
chunkSize: number = DEFAULT_CHUNK_SIZE,
options: ReadableOptions = {}
): ReadableStream<Uint8Array> {
const { signal, highWaterMark = 1 } = options;
let offset = 0;
return new ReadableStream<Uint8Array>(
{
pull(controller) {
if (signal?.aborted) {
controller.error(signal.reason);
return;
}
if (offset >= data.byteLength) {
controller.close();
return;
}
const end = Math.min(offset + chunkSize, data.byteLength);
// zero-copy subarray view
controller.enqueue(data.subarray(offset, end));
offset = end;
},
cancel() {
// nothing to release for in-memory buffers
},
},
new CountQueuingStrategy({ highWaterMark })
);
}
/**
* Concatenates multiple byte streams sequentially into one ReadableStream<Uint8Array>.
*/
export function concatByteStreams(
streams: ReadableStream<Uint8Array>[]
): ReadableStream<Uint8Array> {
let streamIndex = 0;
let currentReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
return new ReadableStream<Uint8Array>({
async pull(controller) {
while (streamIndex < streams.length) {
if (!currentReader) {
currentReader = streams[streamIndex]!.getReader();
}
const { value, done } = await currentReader.read();
if (!done) {
controller.enqueue(value);
return;
}
currentReader.releaseLock();
currentReader = undefined;
streamIndex++;
}
controller.close();
},
async cancel() {
await currentReader?.cancel();
},
});
}
/**
* Slices a byte stream, emitting bytes from `start` (inclusive) to `end` (exclusive).
* If `end` is omitted, the slice runs to the end of the stream.
*/
export function sliceByteStream(
stream: ReadableStream<Uint8Array>,
start: number,
end?: number
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
let position = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
while (true) {
if (end !== undefined && position >= end) {
reader.cancel().catch(/* istanbul ignore next */ () => {});
controller.close();
return;
}
const { value, done } = await reader.read();
if (done) {
controller.close();
return;
}
const chunkStart = position;
const chunkEnd = position + value.byteLength;
position = chunkEnd;
const sliceStart = Math.max(0, start - chunkStart);
const sliceEnd =
end !== undefined
? Math.min(value.byteLength, end - chunkStart)
: value.byteLength;
if (sliceStart < sliceEnd) {
controller.enqueue(value.subarray(sliceStart, sliceEnd));
return;
}
}
},
async cancel() {
reader.cancel().catch(/* istanbul ignore next */ () => {});
},
});
}

View File

@@ -0,0 +1 @@
export { concatByteStreams, fromBytes, sliceByteStream } from "./from-bytes.js";

View File

@@ -0,0 +1,75 @@
# from-generator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Converts an async generator factory function into a WHATWG `ReadableStream`. The factory is invoked lazily — only when the first consumer starts pulling — which avoids eager resource acquisition. Generator teardown on cancellation is handled via `iterator.return()`, ensuring that `finally` blocks in the generator are executed and file handles, network sockets, or other resources are released.
This factory is the preferred entry point for wrapping any asynchronous data source that is naturally expressed as an `async function*` — database cursors, paginated API clients, file-line readers, or infinite event sequences.
## Features
- Lazy generator construction (factory called on first pull)
- Clean teardown via `iterator.return()` on stream cancellation
- AbortSignal support: errors the stream when the signal fires
- Configurable `highWaterMark` for WHATWG backpressure control
- Propagates generator `throw` errors through the stream's error channel
## Usage: Basic Example
```ts
import { fromAsyncGenerator } from "@suaveplan/streams/readable/from-generator/from-generator";
async function* counter(limit: number) {
for (let i = 0; i < limit; i++) yield i;
}
const stream = fromAsyncGenerator(() => counter(3));
const reader = stream.getReader();
console.log((await reader.read()).value); // 0
console.log((await reader.read()).value); // 1
console.log((await reader.read()).value); // 2
console.log((await reader.read()).done); // true
```
## Usage: Advanced Example
```ts
import { fromAsyncGenerator } from "@suaveplan/streams/readable/from-generator/from-generator";
import { toArray } from "@suaveplan/streams/sinks/to-array/to-array";
async function* fetchPages(baseUrl: string): AsyncGenerator<string> {
let cursor: string | undefined;
do {
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
const res = await fetch(url);
const data = await res.json();
yield* data.items;
cursor = data.nextCursor;
} while (cursor);
}
const ac = new AbortController();
const stream = fromAsyncGenerator(() => fetchPages("/api/items"), { signal: ac.signal });
const items = await toArray(stream);
```
## API Reference
### `fromAsyncGenerator<T>(gen: () => AsyncGenerator<T>, options?: ReadableOptions): ReadableStream<T>`
**Parameters**:
- `gen` — factory function that returns an `AsyncGenerator<T>`; called once on first pull.
- `options.signal` — optional `AbortSignal`; errors the stream on abort.
- `options.highWaterMark` — WHATWG queue depth (default: `1`).
**Returns**: `ReadableStream<T>` that yields each value from the generator in order.
**Throws**: never directly. Generator errors propagate through the stream's error channel and surface as rejected `reader.read()` promises.
## Implementation Notes
The generator instance is created lazily inside `pull` rather than in `start`. This means that if a `ReadableStream` is constructed but never read, the generator factory is never invoked and no resources are acquired. Cancellation calls `iterator.return(undefined)` which triggers any `try/finally` cleanup in the generator. The `cancel` method is `async` to await the return value in case the generator performs async cleanup.

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { fromAsyncGenerator } from "./from-generator.js";
async function* range(n: number): AsyncGenerator<number> {
for (let i = 0; i < n; i++) yield i;
}
describe("fromAsyncGenerator", () => {
it("emits generator values in order", { timeout: 5000 }, async () => {
const stream = fromAsyncGenerator(() => range(4));
const reader = stream.getReader();
const out: number[] = [];
let r = await reader.read();
while (!r.done) {
out.push(r.value);
r = await reader.read();
}
expect(out).toEqual([0, 1, 2, 3]);
});
it("closes when generator is done", { timeout: 5000 }, async () => {
const stream = fromAsyncGenerator(() => range(0));
const reader = stream.getReader();
const r = await reader.read();
expect(r.done).toBe(true);
});
it("propagates generator errors", { timeout: 5000 }, async () => {
async function* failing() {
yield 1;
throw new Error("boom");
}
const stream = fromAsyncGenerator(failing);
const reader = stream.getReader();
await reader.read(); // gets 1
await expect(reader.read()).rejects.toThrow("boom");
});
it("calls generator return on cancel", { timeout: 5000 }, async () => {
const cleanupFn = vi.fn();
async function* withCleanup() {
try {
yield 1;
yield 2;
} finally {
cleanupFn();
}
}
const stream = fromAsyncGenerator(withCleanup);
const reader = stream.getReader();
await reader.read(); // yields 1
await reader.cancel();
expect(cleanupFn).toHaveBeenCalledOnce();
});
it("aborts when signal is already set", { timeout: 5000 }, async () => {
const signal = AbortSignal.abort(new Error("stopped"));
const stream = fromAsyncGenerator(() => range(10), { signal });
const reader = stream.getReader();
await expect(reader.read()).rejects.toBeInstanceOf(Error);
});
it("respects highWaterMark option", { timeout: 5000 }, async () => {
const stream = fromAsyncGenerator(() => range(5), { highWaterMark: 3 });
const reader = stream.getReader();
const out: number[] = [];
let r = await reader.read();
while (!r.done) {
out.push(r.value);
r = await reader.read();
}
expect(out).toEqual([0, 1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,44 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
import type { ReadableOptions } from "../../types/common.js";
/**
* Creates a ReadableStream from an async generator factory.
* The factory is called once when the first reader pulls.
*/
export function fromAsyncGenerator<T>(
gen: () => AsyncGenerator<T>,
options: ReadableOptions = {}
): ReadableStream<T> {
const { signal, highWaterMark = 1 } = options;
let iterator: AsyncGenerator<T> | undefined;
return new ReadableStream<T>(
{
async pull(controller) {
if (signal?.aborted) {
controller.error(
new StreamAbortedError({ cause: signal.reason })
);
return;
}
if (!iterator) {
iterator = gen();
}
try {
const { value, done } = await iterator.next();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
} catch (err) {
controller.error(err);
}
},
async cancel() {
await iterator?.return?.(undefined);
},
},
new CountQueuingStrategy({ highWaterMark })
);
}

View File

@@ -0,0 +1 @@
export { fromAsyncGenerator } from "./from-generator.js";

View File

@@ -0,0 +1,66 @@
# for-each
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides three stream sinks: `forEach` calls a user function for each chunk without accumulating results in memory, `toWritable` pipes a readable stream to a writable stream using the WHATWG `pipeTo` API, and `drain` reads and discards all chunks from a stream to completion.
These are the primary sinks for side-effectful consumption and plumbing: writing chunks to a file, sending them over a network, logging, or simply clearing a stream. They complement `toArray` and `toBytes` which materialise stream contents into memory.
## Features
- `forEach`: sequential, low-memory chunk processing
- `toWritable`: native WHATWG `pipeTo` with optional abort signal
- `drain`: discard all chunks efficiently
- AbortSignal support in `forEach` and `drain`
- Lock-safe: always releases reader lock
## Usage: Basic Example
```ts
import { forEach } from "@suaveplan/streams/sinks/for-each/for-each";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
let sum = 0;
await forEach(fromArray([1, 2, 3, 4, 5]), (n) => { sum += n; });
console.log(sum); // 15
```
## Usage: Advanced Example
```ts
import { forEach, toWritable, drain } from "@suaveplan/streams/sinks/for-each/for-each";
import { fromArray } from "@suaveplan/streams/readable/from-array/from-array";
// Pipe to a writable
const received: string[] = [];
const writable = new WritableStream<string>({
write(chunk) { received.push(chunk); }
});
await toWritable(fromArray(["a", "b", "c"]), writable);
console.log(received); // ["a", "b", "c"]
// Drain a stream (discard all chunks)
await drain(fromArray([1, 2, 3, 4, 5]));
```
## API Reference
### `forEach<T>(stream: ReadableStream<T>, fn: (chunk: T) => void | Promise<void>, signal?: AbortSignal): Promise<void>`
- `fn` — called for each chunk; may be async.
- `signal` — rejects with `StreamAbortedError` on abort.
### `toWritable<T>(stream: ReadableStream<T>, writable: WritableStream<T>, signal?: AbortSignal): Promise<void>`
Pipes `stream` to `writable` using `stream.pipeTo(writable)`. Propagates errors from both sides.
### `drain(stream: ReadableStream<unknown>, signal?: AbortSignal): Promise<void>`
Reads all chunks and discards them. Useful for exhausting a stream whose side effects are in the transforms.
## Implementation Notes
`forEach` uses the same abort-signal pattern as `toArray`: an event listener calls `reader.cancel()` to interrupt a pending `read()`, and the main loop checks `signal.aborted` before each read for pre-aborted signals. `toWritable` delegates entirely to `ReadableStream.pipeTo` which has native error propagation and abort support in the WHATWG spec. `drain` is a thin wrapper over `forEach` with a no-op callback — it exists as a named concept rather than requiring callers to know the pattern.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StreamAbortedError } from "../../errors/stream-error.js";
import { fromArray } from "../../readable/from-array/from-array.js";
import { drain, forEach, toWritable } from "./for-each.js";
describe("forEach", () => {
it("calls fn for each chunk", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray([1, 2, 3]), (n) => {
seen.push(n);
});
expect(seen).toEqual([1, 2, 3]);
});
it("awaits async fn", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray([1, 2, 3]), async (n) => {
await Promise.resolve();
seen.push(n);
});
expect(seen).toEqual([1, 2, 3]);
});
it("throws StreamAbortedError on pre-aborted signal", {
timeout: 5000,
}, async () => {
const signal = AbortSignal.abort(new Error("stop"));
await expect(
forEach(fromArray([1, 2, 3]), () => {}, signal)
).rejects.toBeInstanceOf(StreamAbortedError);
});
it("handles empty stream", { timeout: 5000 }, async () => {
const seen: number[] = [];
await forEach(fromArray<number>([]), (n) => seen.push(n));
expect(seen).toEqual([]);
});
it("releases lock on error", { timeout: 5000 }, async () => {
const stream = new ReadableStream({
start(controller) {
controller.error(new Error("boom"));
},
});
await expect(forEach(stream, () => {})).rejects.toThrow("boom");
expect(stream.locked).toBe(false);
});
it("triggers onAbort listener mid-stream (pre-first-read)", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
const stream = new ReadableStream<number>({
pull() {
return new Promise(() => {});
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 20));
ac.abort(new Error("mid-forEach abort"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("post-read abort check fires after first chunk", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let pullCount = 0;
const stream = new ReadableStream<number>({
pull(controller) {
pullCount++;
if (pullCount === 1) {
controller.enqueue(1);
return;
}
return new Promise(() => {});
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 30));
ac.abort(new Error("abort-after-first-chunk"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
it("catch block re-wraps plain stream error when signal is aborted", {
timeout: 30000,
}, async () => {
const ac = new AbortController();
let streamController: ReadableStreamDefaultController<number>;
const stream = new ReadableStream<number>({
start(c) {
streamController = c;
},
});
const promise = forEach(stream, () => {}, ac.signal);
await new Promise((r) => setTimeout(r, 10));
streamController!.error(new Error("stream-error"));
ac.abort(new Error("abort-reason"));
await expect(promise).rejects.toBeInstanceOf(StreamAbortedError);
});
});
describe("toWritable", () => {
it("pipes chunks to writable", { timeout: 5000 }, async () => {
const received: number[] = [];
const writable = new WritableStream<number>({
write(chunk) {
received.push(chunk);
},
});
await toWritable(fromArray([1, 2, 3]), writable);
expect(received).toEqual([1, 2, 3]);
});
it("accepts optional signal and uses pipeTo with signal option", {
timeout: 5000,
}, async () => {
const received: number[] = [];
const writable = new WritableStream<number>({
write(chunk) {
received.push(chunk);
},
});
const ac = new AbortController();
await toWritable(fromArray([1, 2]), writable, ac.signal);
expect(received).toEqual([1, 2]);
});
});
describe("drain", () => {
it("drains stream without returning values", {
timeout: 5000,
}, async () => {
let count = 0;
const stream = new ReadableStream<number>({
start(controller) {
for (let i = 0; i < 5; i++) {
count++;
controller.enqueue(i);
}
controller.close();
},
});
await drain(stream);
expect(count).toBe(5);
});
it("resolves on empty stream", { timeout: 5000 }, async () => {
await expect(drain(fromArray([]))).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,70 @@
import { StreamAbortedError } from "../../errors/stream-error.js";
/**
* Iterates over all chunks in a ReadableStream, calling `fn` for each one.
* Rejects with StreamAbortedError when the signal fires.
*/
export async function forEach<T>(
stream: ReadableStream<T>,
fn: (chunk: T) => void | Promise<void>,
signal?: AbortSignal
): Promise<void> {
const reader = stream.getReader();
const onAbort = signal
? () => {
reader.cancel(signal.reason).catch(() => {});
}
: undefined;
if (onAbort && signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
try {
while (true) {
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
const { value, done } = await reader.read();
// Re-check after awaiting — abort may have fired during the read
if (signal?.aborted) {
throw new StreamAbortedError({ cause: signal.reason });
}
if (done) break;
await fn(value);
}
} catch (err) {
if (signal?.aborted && !(err instanceof StreamAbortedError)) {
throw new StreamAbortedError({ cause: err });
}
throw err;
} finally {
if (onAbort && signal) {
signal.removeEventListener("abort", onAbort);
}
reader.releaseLock();
}
}
/**
* Pipes a ReadableStream to a WritableStream.
* Uses WHATWG pipeTo under the hood.
*/
export async function toWritable<T>(
stream: ReadableStream<T>,
writable: WritableStream<T>,
signal?: AbortSignal
): Promise<void> {
await stream.pipeTo(writable, signal ? { signal } : {});
}
/**
* Drains a ReadableStream to completion, discarding all chunks.
*/
export async function drain(
stream: ReadableStream<unknown>,
signal?: AbortSignal
): Promise<void> {
await forEach(stream, () => {}, signal);
}

Some files were not shown because too many files have changed in this diff Show More