Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the complete error taxonomy for `@suaveplan/readline`. Every failure surface
in the package — scanner overflows, interface lifecycle violations, and reader I/O
faults — maps to a stable, machine-readable code exposed through `READLINE_CODES`.
Using `defineCodes` from `@suaveplan/error` guarantees that codes are interned as
readonly string literals at build time, so TypeScript narrows `err.code` to the
exact union member without requiring string comparisons at runtime.
## Features
- **Three sub-namespaces** (`SCANNER`, `INTERFACE`, `READER`) keep codes from
colliding and communicate which layer of the stack raised the error.
- **Five error codes**: `LINE_TOO_LONG`, `SCANNER.STREAM_ERROR`, `INTERFACE.CLOSED`,
`INTERFACE.ABORTED`, and `READER.STREAM_ERROR`.
- **`ReadlineError`** extends `SuaveplanError<ReadlineCode>` — gives callers a single
`instanceof ReadlineError` guard that covers all readline failures.
- **`messageKey` property** stored on the error instance for i18n lookup or
structured-log emission without re-parsing the message string.
- **Cause chaining** via the standard `options.cause` field, forwarded to the
`SuaveplanError` constructor and surfaced on `err.cause`.
- **Structured context** via `options.context` — arbitrary key/value pairs attached
as `err.context` for diagnostic payloads (e.g., `{ maxLineLength: 4096 }`).
## Usage: Basic Example
```ts
import { ReadlineError, READLINE_CODES } from "@suaveplan/readline/errors/errors";
throw new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long"
);
```
## Usage: Advanced Example
Compose cause-chaining and structured context for a scanner overflow that wraps an
underlying stream error:
```ts
import { ReadlineError, READLINE_CODES } from "@suaveplan/readline/errors/errors";
function assertLineLength(line: string, max: number): void {
if (line.length > max) {
throw new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long",
{ context: { maxLineLength: max, actual: line.length } }
);
}
}
async function handleStreamError(raw: unknown): Promise<never> {
const cause = raw instanceof Error ? raw : new Error(String(raw));
throw new ReadlineError(
READLINE_CODES.READER.STREAM_ERROR,
"readline.reader.stream_error",
{ cause }
);
}
// Discriminate at the call site:
try {
await readLines(stream);
} catch (err) {
if (err instanceof ReadlineError) {
console.error(err.code, err.context); // "READLINE.SCANNER.LINE_TOO_LONG" { maxLineLength: 4096, actual: 5000 }
}
}
```
## API Reference
### `READLINE_CODES`
```ts
export const READLINE_CODES: {
SCANNER: {
LINE_TOO_LONG: "READLINE.SCANNER.LINE_TOO_LONG";
STREAM_ERROR: "READLINE.SCANNER.STREAM_ERROR";
};
INTERFACE: {
CLOSED: "READLINE.INTERFACE.CLOSED";
ABORTED: "READLINE.INTERFACE.ABORTED";
};
READER: {
STREAM_ERROR: "READLINE.READER.STREAM_ERROR";
};
}
```
| Code | Meaning |
|---|---|
| `READLINE.SCANNER.LINE_TOO_LONG` | Input line exceeds the configured maximum byte/character limit. |
| `READLINE.SCANNER.STREAM_ERROR` | The underlying readable stream emitted an `error` event during scanning. |
| `READLINE.INTERFACE.CLOSED` | An operation was attempted after the readline interface was closed. |
| `READLINE.INTERFACE.ABORTED` | An operation was cancelled via `AbortSignal` or an explicit `close()` call. |
| `READLINE.READER.STREAM_ERROR` | A reader-level stream I/O failure occurred (e.g., broken pipe, permission denied). |
### `ReadlineCode`
```ts
export type ReadlineCode =
| "READLINE.SCANNER.LINE_TOO_LONG"
| "READLINE.SCANNER.STREAM_ERROR"
| "READLINE.INTERFACE.CLOSED"
| "READLINE.INTERFACE.ABORTED"
| "READLINE.READER.STREAM_ERROR";
```
Union of all five readline error code literals. Use as a type parameter or for
exhaustive `switch` discrimination.
### `ReadlineError`
```ts
export class ReadlineError extends SuaveplanError<ReadlineCode> {
public readonly messageKey: string;
public constructor(
code: ReadlineCode,
messageKey: string,
options?: SuaveplanErrorOptions
);
}
```
**Parameters**
| Param | Type | Description |
|---|---|---|
| `code` | `ReadlineCode` | One of the five `READLINE_CODES` values. |
| `messageKey` | `string` | i18n message key (e.g. `"readline.scanner.line_too_long"`). |
| `options.cause` | `unknown` | Wraps a lower-level error as the causal chain. |
| `options.context` | `ErrorContext` | Arbitrary key/value diagnostic payload attached to `err.context`. |
| `options.traceId` | `string` | Optional OTel trace ID for cross-service correlation. |
**Returns** A `ReadlineError` instance. Inherits `err.code`, `err.context`,
`err.cause`, `err.timestamp`, and `err.traceId` from `SuaveplanError`.
## Implementation Notes
**Why three `defineCodes` calls?** A single flat namespace (`READLINE.*`) risks code
collisions as the package grows. Partitioning into `SCANNER`, `INTERFACE`, and
`READER` mirrors the three distinct layers of the readline stack: byte scanning,
the high-level interface, and the raw stream reader. This also allows call sites to
catch and re-throw at layer boundaries without string-prefix matching.
**Why store `messageKey` on the instance?** `SuaveplanError` resolves the message
string via `resolveMessage()` at construction time and discards the key. Retaining it
as a public readonly property lets structured loggers and telemetry sinks emit the
key alongside the resolved string, enabling i18n round-trips and log aggregation by
key without parsing the human-readable message.
**`defineCodes` internment** produces `"READLINE.SCANNER.LINE_TOO_LONG"` (not a
Symbol or numeric constant), which survives JSON serialisation, log shipping, and
wire-format inclusion in `SuaveplanError`'s Zod-validated payload unchanged.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { READLINE_CODES, ReadlineError } from "./errors.js";
describe("READLINE_CODES", { timeout: 5000 }, () => {
it("produces fully-qualified scanner codes", () => {
expect(READLINE_CODES.SCANNER.LINE_TOO_LONG).toBe(
"READLINE.SCANNER.LINE_TOO_LONG"
);
expect(READLINE_CODES.SCANNER.STREAM_ERROR).toBe(
"READLINE.SCANNER.STREAM_ERROR"
);
});
it("produces fully-qualified interface codes", () => {
expect(READLINE_CODES.INTERFACE.CLOSED).toBe(
"READLINE.INTERFACE.CLOSED"
);
expect(READLINE_CODES.INTERFACE.ABORTED).toBe(
"READLINE.INTERFACE.ABORTED"
);
});
it("produces fully-qualified reader codes", () => {
expect(READLINE_CODES.READER.STREAM_ERROR).toBe(
"READLINE.READER.STREAM_ERROR"
);
});
});
describe("ReadlineError", { timeout: 5000 }, () => {
it("is an instance of Error", () => {
const err = new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long"
);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("READLINE.SCANNER.LINE_TOO_LONG");
expect(err.messageKey).toBe("readline.scanner.line_too_long");
});
it("chains cause", () => {
const cause = new Error("upstream");
const err = new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("attaches context", () => {
const err = new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long",
{ context: { maxLineLength: 4096 } }
);
expect(err.context).toEqual({ maxLineLength: 4096 });
});
});

View File

@@ -0,0 +1,65 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
const SCANNER_CODES = defineCodes("READLINE.SCANNER", [
"LINE_TOO_LONG",
"STREAM_ERROR",
] as const);
const INTERFACE_CODES = defineCodes("READLINE.INTERFACE", [
"CLOSED",
"ABORTED",
] as const);
const READER_CODES = defineCodes("READLINE.READER", ["STREAM_ERROR"] as const);
/**
* Error code registry for `@suaveplan/readline`, organised by sub-namespace.
*
* - `READLINE.SCANNER.LINE_TOO_LONG` — input line exceeds the configured maximum
* - `READLINE.SCANNER.STREAM_ERROR` — underlying readable stream emitted an error
* - `READLINE.INTERFACE.CLOSED` — operation attempted on a closed readline interface
* - `READLINE.INTERFACE.ABORTED` — operation was cancelled via AbortSignal or explicit close
* - `READLINE.READER.STREAM_ERROR` — reader-level stream I/O failure
*/
export const READLINE_CODES = {
SCANNER: SCANNER_CODES,
INTERFACE: INTERFACE_CODES,
READER: READER_CODES,
} as const;
export type ReadlineCode =
| (typeof SCANNER_CODES)[keyof typeof SCANNER_CODES]
| (typeof INTERFACE_CODES)[keyof typeof INTERFACE_CODES]
| (typeof READER_CODES)[keyof typeof READER_CODES];
/**
* Typed error for all `@suaveplan/readline` failures.
*
* Extends `SuaveplanError` with a strongly-typed `code` drawn from
* `READLINE_CODES`, giving callers narrow `instanceof` discrimination and
* a stable machine-readable code for logging/alerting.
*
* @example
* ```ts
* import { ReadlineError, READLINE_CODES } from "@suaveplan/readline/errors/errors";
*
* throw new ReadlineError(
* READLINE_CODES.SCANNER.LINE_TOO_LONG,
* "readline.scanner.line_too_long",
* { cause: originalError, context: { maxLineLength: 4096 } }
* );
* ```
*/
export class ReadlineError extends SuaveplanError<ReadlineCode> {
public readonly messageKey: string;
public constructor(
code: ReadlineCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.messageKey = messageKey;
}
}

View File

@@ -0,0 +1,5 @@
export type { ReadlineCode } from "./errors.js";
export {
READLINE_CODES,
ReadlineError,
} from "./errors.js";

View File

@@ -0,0 +1,3 @@
export * from "./errors/index.js";
export * from "./interface/index.js";
export * from "./reader/index.js";

View File

@@ -0,0 +1,2 @@
export { createInterface } from "./interface.js";
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";

View File

@@ -0,0 +1,116 @@
# interface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createInterface()` produces an interactive readline interface that wraps the lower-level `createScanner()` generator with a higher-level API suited for CLI tools, prompts, and line-by-line pipeline processing. It serves as the primary user-facing entry point for `@suaveplan/readline`, replacing `node:readline`'s callback-heavy `Interface` class with a fully promise-based, abort-signal-aware, and OpenTelemetry-instrumented alternative. Every interface instance is covered by a single OTel span that begins at construction and ends when `close()` is called or the async iterator drains naturally.
## Features
- **`question(prompt, signal?)`** — writes a prompt string to the optional output stream, then awaits the next line from input; rejects with `INTERFACE.CLOSED` when the interface is closed and `INTERFACE.ABORTED` when an `AbortSignal` fires
- **`close()`** — idempotent close method; sets `closed = true` and ends the OTel span; safe to call multiple times
- **`closed` getter** — synchronous boolean reflecting the current open/closed state
- **Async iterator** (`for await...of`) — iterate all lines from the underlying scanner; automatically marks the interface closed when the stream drains
- **AbortSignal support** — both the interface-level signal (passed to `createInterface`) and a per-question signal are honoured; abort mid-question rejects with `INTERFACE.ABORTED`
- **OTel span lifetime** — a `readline.interface` span is started on construction and ended on close or stream drain; abort sets `SpanStatusCode.ERROR` before ending
## Usage: Basic Example
```ts
import { Readable } from "node:stream";
import { createInterface } from "@suaveplan/readline";
const input = Readable.from("Alice\nBob\n");
const rl = createInterface({ input });
const name = await rl.question("Enter name: ");
console.log(`Hello, ${name}!`); // "Hello, Alice!"
rl.close();
```
## Usage: Advanced Example
```ts
import { Readable, Writable } from "node:stream";
import { createInterface } from "@suaveplan/readline";
// Iterate all lines with abort support
const controller = new AbortController();
const input = Readable.from("line1\nline2\nline3\n");
const rl = createInterface({ input, signal: controller.signal });
const collected: string[] = [];
for await (const line of rl) {
collected.push(line);
if (line === "line2") controller.abort(); // stop early
}
// collected === ["line1", "line2"]
// Question with per-call signal override
const perQuestion = new AbortController();
const rl2 = createInterface({ input: Readable.from("response\n") });
setTimeout(() => perQuestion.abort(), 100);
try {
const ans = await rl2.question("Say something: ", perQuestion.signal);
console.log(ans);
} catch (err) {
// ReadlineError with code READLINE_CODES.INTERFACE.ABORTED
}
rl2.close();
```
## API Reference
### `createInterface(options: ReadlineOptions): ReadlineInterface`
Creates and returns a new readline interface wrapping the provided `Readable` stream.
**Parameters**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `options.input` | `Readable` | Yes | The source readable stream to scan line-by-line |
| `options.output` | `Writable` | No | Optional writable stream; prompts are written here |
| `options.signal` | `AbortSignal` | No | Optional interface-level abort signal |
**Returns** `ReadlineInterface`
---
### `ReadlineInterface`
| Member | Signature | Description |
|--------|-----------|-------------|
| `question` | `(prompt: string, signal?: AbortSignal) => Promise<string>` | Write prompt and await the next line. Throws `INTERFACE.CLOSED` if the interface is closed; throws `INTERFACE.ABORTED` if a signal fires |
| `close` | `() => void` | Idempotent; marks the interface closed and ends the OTel span |
| `closed` | `readonly boolean` | `true` after `close()` has been called |
| `[Symbol.asyncIterator]` | `() => AsyncIterator<string>` | Iterate all remaining lines; stops when closed or stream drains |
---
### `ReadlineOptions`
```ts
interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
```
**Throws** `ReadlineError` with codes from `READLINE_CODES.INTERFACE`:
- `INTERFACE.CLOSED` — method called after `close()` or stream drained
- `INTERFACE.ABORTED` — abort signal fired before or during a `question()` call
## Implementation Notes
**OTel span design** — The span starts immediately in `createInterface()` (not lazily) so it faithfully represents the full interface lifetime. `span.end()` is called exactly once: either from `close()`, from the async iterator when the stream drains, or when an abort surfaces in the iterator. An abort path also calls `span.setStatus(ERROR)` before ending. Double-close is guarded by a `if (closed) return` early exit.
**Why `iter.next()` instead of `for await`** — `question()` needs to await exactly one line at a time, interleaved with prompt writes, which rules out `for await`. Calling `iter.next()` directly gives precise per-line control over the shared iterator and lets the abort handler race cleanly against the next-line promise without leaving dangling iterations.
**Double-abort guard** — `question()` checks `effectiveSignal?.aborted` synchronously before attaching the listener, preventing a TOCTOU where the signal is already aborted when `question()` is entered. The abort listener is removed via `removeEventListener` after `iter.next()` resolves to avoid memory leaks on long-lived interfaces.
**Shared iterator state** — `createScanner` is a single generator; both `question()` and the async iterator share the same `iter`. Callers must not mix `question()` calls and `for await` on the same interface concurrently — the semantics are sequential.

View File

@@ -0,0 +1,160 @@
import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { READLINE_CODES } from "../errors/errors.js";
import { createInterface } from "./interface.js";
function makeReadable(lines: string[]): Readable {
return Readable.from(Buffer.from(`${lines.join("\n")}\n`));
}
function makeWritable(): { stream: Writable; output: string[] } {
const output: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) {
output.push(chunk.toString());
cb();
},
});
return { stream, output };
}
describe("createInterface — question", { timeout: 5000 }, () => {
it("returns the next line from input", { timeout: 5000 }, async () => {
const { stream: out } = makeWritable();
const rl = createInterface({
input: makeReadable(["hello"]),
output: out,
});
expect(await rl.question("prompt> ")).toBe("hello");
rl.close();
});
it("writes the prompt to output", { timeout: 5000 }, async () => {
const { stream: out, output } = makeWritable();
const rl = createInterface({
input: makeReadable(["answer"]),
output: out,
});
await rl.question("enter: ");
rl.close();
expect(output.join("")).toContain("enter: ");
});
it("throws INTERFACE.CLOSED after close()", { timeout: 5000 }, async () => {
const rl = createInterface({ input: makeReadable([]) });
rl.close();
await expect(rl.question("x")).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.CLOSED,
});
});
it("marks closed=true after close()", { timeout: 5000 }, () => {
const rl = createInterface({ input: makeReadable([]) });
expect(rl.closed).toBe(false);
rl.close();
expect(rl.closed).toBe(true);
});
it("calling close() twice is a no-op", { timeout: 5000 }, () => {
const rl = createInterface({ input: makeReadable([]) });
rl.close();
expect(() => rl.close()).not.toThrow();
expect(rl.closed).toBe(true);
});
it("throws INTERFACE.ABORTED when signal fires", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const r = new Readable({ read() {} }); // never emits data
const rl = createInterface({ input: r, signal: controller.signal });
const p = rl.question("prompt");
controller.abort();
await expect(p).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.ABORTED,
});
rl.close();
});
it("throws INTERFACE.ABORTED when signal is already aborted before question()", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const rl = createInterface({
input: makeReadable(["ignored"]),
signal: controller.signal,
});
await expect(rl.question("prompt")).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.ABORTED,
});
rl.close();
});
});
describe("createInterface — asyncIterator", { timeout: 5000 }, () => {
it("iterates lines from input", { timeout: 5000 }, async () => {
const rl = createInterface({ input: makeReadable(["a", "b", "c"]) });
const lines: string[] = [];
for await (const line of rl) lines.push(line);
expect(lines).toEqual(["a", "b", "c"]);
});
it("stops iteration when closed", { timeout: 5000 }, async () => {
const rl = createInterface({ input: makeReadable(["x", "y"]) });
const lines: string[] = [];
for await (const line of rl) {
lines.push(line);
rl.close();
}
expect(lines.length).toBe(1);
});
it("terminates immediately when already closed (for-await starts after close)", {
timeout: 5000,
}, async () => {
const rl = createInterface({ input: makeReadable(["z"]) });
rl.close();
const lines: string[] = [];
for await (const line of rl) lines.push(line);
expect(lines).toEqual([]);
});
it("throws INTERFACE.ABORTED when signal fires during for-await", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const r = new Readable({ read() {} }); // never emits data
const rl = createInterface({ input: r, signal: controller.signal });
const iter = rl[Symbol.asyncIterator]();
const p = iter.next();
controller.abort();
await expect(p).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.ABORTED,
});
});
it("throws INTERFACE.ABORTED when signal is already aborted before iter.next()", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const r = new Readable({ read() {} });
const rl = createInterface({ input: r, signal: controller.signal });
const iter = rl[Symbol.asyncIterator]();
await expect(iter.next()).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.ABORTED,
});
});
});
describe("createInterface — OTel span", { timeout: 5000 }, () => {
it("does not throw when no OTel exporter is registered (no-op tracer)", {
timeout: 5000,
}, () => {
expect(() => {
const rl = createInterface({ input: makeReadable([]) });
rl.close();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,142 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { READLINE_CODES, ReadlineError } from "../errors/errors.js";
import { createScanner } from "../scanner/scanner.js";
import type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
const tracer = trace.getTracer("@suaveplan/readline", "0.1.0");
export function createInterface(options: ReadlineOptions): ReadlineInterface {
const { input, output, signal: ifaceSignal } = options;
let closed = false;
const span = tracer.startSpan("readline.interface");
const scanner = createScanner(
input,
ifaceSignal ? { signal: ifaceSignal } : undefined
);
const iter = scanner[Symbol.asyncIterator]();
function assertOpen(): void {
if (closed) {
throw new ReadlineError(
READLINE_CODES.INTERFACE.CLOSED,
"readline.interface.closed"
);
}
}
const rl: ReadlineInterface = {
get closed() {
return closed;
},
async question(
prompt: string,
questionSignal?: AbortSignal
): Promise<string> {
assertOpen();
const effectiveSignal = questionSignal ?? ifaceSignal;
if (effectiveSignal?.aborted) {
throw new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause: effectiveSignal.reason }
);
}
if (output) output.write(prompt);
return new Promise<string>((resolve, reject) => {
let abortHandler: (() => void) | undefined;
if (effectiveSignal) {
abortHandler = () => {
span.setStatus({
code: SpanStatusCode.ERROR,
message: "aborted",
});
reject(
new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause: effectiveSignal.reason }
)
);
};
effectiveSignal.addEventListener("abort", abortHandler, {
once: true,
});
}
iter.next()
.then((result) => {
if (abortHandler) {
effectiveSignal?.removeEventListener(
"abort",
abortHandler
);
}
if (result.done) {
reject(
new ReadlineError(
READLINE_CODES.INTERFACE.CLOSED,
"readline.interface.closed"
)
);
} else {
resolve(result.value);
}
})
.catch(reject);
});
},
close() {
if (closed) return;
closed = true;
span.end();
},
[Symbol.asyncIterator](): AsyncIterator<string> {
return {
async next(): Promise<IteratorResult<string>> {
if (closed) return { done: true, value: undefined };
if (ifaceSignal?.aborted) {
closed = true;
span.setStatus({
code: SpanStatusCode.ERROR,
message: "aborted",
});
span.end();
throw new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted"
);
}
const result = await iter.next();
// If abort fired while awaiting, the scanner unblocked with done:true
if (ifaceSignal?.aborted) {
closed = true;
span.setStatus({
code: SpanStatusCode.ERROR,
message: "aborted",
});
span.end();
throw new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted"
);
}
if (result.done) {
closed = true;
span.end();
return { done: true, value: undefined };
}
return { done: false, value: result.value };
},
};
},
};
return rl;
}

View File

@@ -0,0 +1,14 @@
import type { Readable, Writable } from "node:stream";
export interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
export interface ReadlineInterface {
question(prompt: string, signal?: AbortSignal): Promise<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}

View File

@@ -0,0 +1,2 @@
export { readLines } from "./reader.js";
export type { ReadLinesOptions } from "./reader.types.js";

View File

@@ -0,0 +1,114 @@
# reader
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `reader` module exposes `readLines()`, a unified async-generator entry point for line-by-line reading from any of three input forms: a Node.js `Readable` stream, a WHATWG `ReadableStream`, or a plain file-path string. It bridges the gap between the low-level `createScanner` byte-parser and the diverse I/O surfaces consumers work with in real applications.
By accepting all three input kinds under a single function signature, `readLines()` lets callers swap between reading from stdin, a web `fetch()` response body, or an on-disk log file without changing any surrounding code. The function delegates all byte-parsing and line-assembly work to `createScanner`, keeping its own concerns limited to source normalisation and the `skipEmpty` filter.
## Features
- **File-path string input** — pass a filesystem path and `readLines` opens a read stream automatically via `createReadStream`
- **Node.js `Readable` input** — pass any `Readable` (stdin, `net.Socket`, custom, etc.) directly
- **WHATWG `ReadableStream` input** — pass a Web Streams `ReadableStream` (e.g. from `fetch().body`); bridged to Node `Readable` via `Readable.fromWeb`
- **`skipEmpty` option** — filter out blank lines before they reach the caller
- **`AbortSignal` support** — stop iteration at any point by aborting the controller; the generator exits cleanly on the next yield boundary
- **`maxLineLength` and `encoding` passthrough** — forwarded verbatim to `createScanner`
## Usage: Basic Example
Read every line from a local file:
```ts
import { readLines } from "@suaveplan/readline/reader/reader";
for await (const line of readLines("/var/log/app.log")) {
console.log(line);
}
```
Read from stdin:
```ts
import { readLines } from "@suaveplan/readline/reader/reader";
import { stdin } from "node:process";
for await (const line of readLines(stdin)) {
process.stdout.write(line + "\n");
}
```
## Usage: Advanced Example
Fetch a remote newline-delimited JSON stream, skip blank lines, and abort after the first error event:
```ts
import { readLines } from "@suaveplan/readline/reader/reader";
const controller = new AbortController();
const response = await fetch("https://example.com/events", {
signal: controller.signal,
});
if (!response.body) throw new Error("No body");
const results: unknown[] = [];
try {
for await (const line of readLines(response.body, {
skipEmpty: true,
signal: controller.signal,
maxLineLength: 65536,
})) {
const event = JSON.parse(line) as { type: string; payload: unknown };
results.push(event.payload);
if (event.type === "error") {
controller.abort();
}
}
} catch {
// generator exits cleanly; partially collected results are still usable
}
```
## API Reference
### `readLines(input, options?)`
```ts
async function* readLines(
input: Readable | ReadableStream | string,
options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown>
```
**Parameters**
| Parameter | Type | Description |
|---|---|---|
| `input` | `Readable \| ReadableStream \| string` | The data source. A string is treated as a file path. |
| `options.maxLineLength` | `number` (optional) | Maximum bytes per line before a `LINE_TOO_LONG` error is thrown. Defaults to `4096`. |
| `options.encoding` | `BufferEncoding` (optional) | Text encoding for decoded lines. Defaults to `"utf8"`. |
| `options.skipEmpty` | `boolean` (optional) | When `true`, lines that are empty strings after decoding are silently dropped. |
| `options.signal` | `AbortSignal` (optional) | Aborts iteration early. The generator exits on the next yield boundary without throwing. |
**Returns** `AsyncGenerator<string>` — each yielded value is one decoded line, CR/LF stripped.
**Throws** `ReadlineError` with code `SCANNER.STREAM_ERROR` on underlying stream errors, or `SCANNER.LINE_TOO_LONG` when a line exceeds `maxLineLength`.
### `ReadLinesOptions`
Interface re-exported from `reader.types.ts`. All fields are optional.
## Implementation Notes
**WHATWG stream bridging.** `Readable.fromWeb` (available in Node ≥ 17 and Bun ≥ 1.0) converts a `ReadableStream<Uint8Array>` into a Node `Readable`. The import of `node:stream` is deferred (`await import`) so the static module graph does not load it for callers that only use WHATWG paths — though in practice all three code paths require `node:stream`, so this is mainly a style alignment with future environments where the module may be conditionally present.
**Detection heuristic.** WHATWG streams are identified by the presence of a `.getReader` method, which is part of the `ReadableStream` spec and absent from Node `Readable`. This avoids an `instanceof` check that would fail across realm boundaries (e.g. cross-context `ReadableStream` objects from a `fetch` polyfill).
**Delegation to `createScanner`.** All byte-parsing, CR/LF normalisation, buffer management, and `AbortSignal` checking live in `createScanner`. `readLines` is intentionally thin — it adds only source normalisation and the `skipEmpty` filter. This keeps each layer testable in isolation and avoids duplicating byte-level logic.
**`skipEmpty` placement.** The empty-line filter is applied after `createScanner` yields, not inside the scanner, so that callers who need raw empty lines (e.g. protocol parsers that use blank lines as delimiters) can obtain them by omitting the option.

View File

@@ -0,0 +1,93 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { readLines } from "./reader.js";
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
const out: string[] = [];
for await (const line of gen) out.push(line);
return out;
}
describe("readLines — Readable", { timeout: 5000 }, () => {
it("reads lines from a Node Readable", { timeout: 5000 }, async () => {
const r = Readable.from(Buffer.from("one\ntwo\nthree"));
expect(await collect(readLines(r))).toEqual(["one", "two", "three"]);
});
it("skips empty lines when skipEmpty is true", {
timeout: 5000,
}, async () => {
const r = Readable.from(Buffer.from("a\n\nb\n\nc"));
expect(await collect(readLines(r, { skipEmpty: true }))).toEqual([
"a",
"b",
"c",
]);
});
it("aborts mid-iteration via AbortSignal", { timeout: 5000 }, async () => {
const controller = new AbortController();
const lines: string[] = [];
const r = Readable.from(Buffer.from("x\ny\nz"));
for await (const line of readLines(r, { signal: controller.signal })) {
lines.push(line);
if (lines.length === 1) controller.abort();
}
expect(lines.length).toBe(1);
});
});
describe("readLines — file path", { timeout: 5000 }, () => {
it("reads lines from a file path string", { timeout: 5000 }, async () => {
const dir = await mkdtemp(join(tmpdir(), "readline-test-"));
const file = join(dir, "test.txt");
await writeFile(file, "alpha\nbeta\ngamma");
try {
expect(await collect(readLines(file))).toEqual([
"alpha",
"beta",
"gamma",
]);
} finally {
await rm(dir, { recursive: true });
}
});
});
describe("readLines — options", { timeout: 5000 }, () => {
it("respects maxLineLength option", { timeout: 5000 }, async () => {
const r = Readable.from(Buffer.from("hi\nthere"));
expect(await collect(readLines(r, { maxLineLength: 512 }))).toEqual([
"hi",
"there",
]);
});
it("respects encoding option", { timeout: 5000 }, async () => {
const r = Readable.from(Buffer.from("hello\nworld"));
expect(await collect(readLines(r, { encoding: "utf8" }))).toEqual([
"hello",
"world",
]);
});
});
describe("readLines — WHATWG ReadableStream", { timeout: 5000 }, () => {
it("reads lines from a WHATWG ReadableStream", {
timeout: 5000,
}, async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("line1\nline2\n"));
controller.close();
},
});
expect(
await collect(readLines(stream as unknown as ReadableStream))
).toEqual(["line1", "line2"]);
});
});

View File

@@ -0,0 +1,36 @@
import { createReadStream } from "node:fs";
import type { Readable } from "node:stream";
import { createScanner } from "../scanner/scanner.js";
import type { ReadLinesOptions } from "./reader.types.js";
export async function* readLines(
input: Readable | ReadableStream | string,
options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown> {
const signal = options?.signal;
let readable: Readable;
if (typeof input === "string") {
readable = createReadStream(input);
} else if (typeof (input as ReadableStream).getReader === "function") {
const { Readable: NodeReadable } = await import("node:stream");
readable = NodeReadable.fromWeb(
input as Parameters<typeof NodeReadable.fromWeb>[0]
);
} else {
readable = input as Readable;
}
const scannerOptions = {
...(options?.maxLineLength !== undefined && {
maxLineLength: options.maxLineLength,
}),
...(options?.encoding !== undefined && { encoding: options.encoding }),
...(signal !== undefined && { signal }),
};
for await (const line of createScanner(readable, scannerOptions)) {
if (options?.skipEmpty && line === "") continue;
yield line;
}
}

View File

@@ -0,0 +1,6 @@
export interface ReadLinesOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
skipEmpty?: boolean;
signal?: AbortSignal;
}

View File

@@ -0,0 +1,2 @@
export { createScanner } from "./scanner.js";
export type { ScannerOptions } from "./scanner.types.js";

View File

@@ -0,0 +1,93 @@
# scanner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createScanner` is the internal byte-level line-reading engine for `@suaveplan/readline`. It consumes a Node.js `Readable` stream and yields decoded string lines one at a time via an `AsyncGenerator`. This module is intentionally kept off the public `package.json` subpath exports — it is an implementation detail consumed exclusively by the `reader` and `interface` modules within this package.
The scanner solves a subtle problem: Node.js streams are push-based (they emit `"data"` events synchronously), while `AsyncGenerator` consumers are pull-based (they request values via `next()`). Bridging the two correctly requires a bounded async push queue that buffers emitted items until the consumer is ready to receive them.
## Features
- **LF and CRLF line termination** — bare `\n` and `\r\n` sequences are both handled; bare `\r` (old Mac line endings) is treated as whitespace.
- **Unterminated final line** — a line that ends at stream EOF without a trailing newline is emitted correctly.
- **Empty input** — an empty stream yields zero lines without error.
- **Max line length guard** — if `maxLineLength` is exceeded before a line terminator is found, a `ReadlineError` with code `READLINE_CODES.SCANNER.LINE_TOO_LONG` is thrown immediately.
- **Stream error propagation** — any `"error"` event on the input stream is wrapped in a `ReadlineError` with code `READLINE_CODES.SCANNER.STREAM_ERROR` and rethrown to the caller.
- **AbortSignal support** — passing `signal` causes the generator to stop yielding and return cleanly when the signal fires. The abort check is performed both before and after each `dequeue()` await to catch signals that arrive while the generator is suspended.
- **Async push queue** — a lightweight internal queue (not `EventEmitter`) decouples the push-based stream from the pull-based generator without leaking abstractions.
## Usage: Basic Example
```ts
import { Readable } from "node:stream";
import { createScanner } from "./scanner.js";
const readable = Readable.from("line one\nline two\nline three\n");
for await (const line of createScanner(readable)) {
console.log(line);
}
// Output:
// line one
// line two
// line three
```
## Usage: Advanced Example
```ts
import { createReadStream } from "node:fs";
import { createScanner } from "./scanner.js";
const controller = new AbortController();
const stream = createReadStream("/var/log/app.log");
// Abort after reading 100 lines
let count = 0;
for await (const line of createScanner(stream, {
maxLineLength: 8192,
encoding: "utf8",
signal: controller.signal,
})) {
console.log(line);
if (++count >= 100) controller.abort();
}
```
## API Reference
### `createScanner(input, opts?)`
Creates an `AsyncGenerator<string>` that yields lines read from `input`.
**Parameters**
| Name | Type | Default | Description |
|---|---|---|---|
| `input` | `Readable` | — | Node.js `Readable` stream to scan |
| `opts.maxLineLength` | `number` | `4096` | Maximum byte length of any single line before a `LINE_TOO_LONG` error is thrown |
| `opts.encoding` | `BufferEncoding` | `"utf8"` | Character encoding used when decoding bytes to strings |
| `opts.signal` | `AbortSignal` | `undefined` | Optional cancellation signal; generator stops cleanly when aborted |
**Returns**
`AsyncGenerator<string, void, unknown>` — yields one decoded line per iteration (newline terminators stripped).
**Throws**
- `ReadlineError` with code `READLINE_CODES.SCANNER.LINE_TOO_LONG` when a line exceeds `maxLineLength` bytes without a line terminator.
- `ReadlineError` with code `READLINE_CODES.SCANNER.STREAM_ERROR` wrapping the original `Error` when the underlying stream emits an `"error"` event.
## Implementation Notes
**Why a bounded async push queue instead of `EventEmitter`?**
Node.js streams emit `"data"` synchronously on whichever tick the chunk arrives. If we naively awaited an `EventEmitter`-backed `Promise` we would lose chunks that fired between `await` points. The bounded queue (`queue: QueueItem[]` + a single pending `waitResolve` resolver) solves this: if the generator is currently suspended in `dequeue()`, the new item is delivered directly to the waiting resolver; otherwise it is buffered in the queue until the next `dequeue()` call. This gives correct back-pressure semantics without a heavyweight library.
**Why two AbortSignal checks per iteration?**
The `while (true)` loop checks `signal?.aborted` both _before_ calling `dequeue()` and _after_ the `await` resolves. The first check handles the case where the signal was already set when the loop ticked. The second handles the race where the signal fires _while_ the generator is suspended inside `dequeue()` waiting for the next queue item. Without the post-await check, a late abort could cause one spurious extra line to be yielded before the generator noticed the signal.
**`lineBuffer` pre-allocation**
A single `Buffer.alloc(maxLen)` is allocated once per `createScanner` call, avoiding repeated heap allocations on every `"data"` event. Bytes are accumulated in-place; `subarray` is used on emit to avoid a copy.

View File

@@ -0,0 +1,115 @@
import { Readable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { READLINE_CODES, ReadlineError } from "../errors/errors.js";
import { createScanner } from "./scanner.js";
function toReadable(input: string): Readable {
return Readable.from(Buffer.from(input));
}
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
const lines: string[] = [];
for await (const line of gen) lines.push(line);
return lines;
}
describe("createScanner", { timeout: 5000 }, () => {
it("yields lines separated by LF", async () => {
const lines = await collect(
createScanner(toReadable("hello\nworld\n"))
);
expect(lines).toEqual(["hello", "world"]);
});
it("yields lines separated by CRLF", async () => {
const lines = await collect(
createScanner(toReadable("foo\r\nbar\r\n"))
);
expect(lines).toEqual(["foo", "bar"]);
});
it("yields last line without trailing newline", async () => {
const lines = await collect(createScanner(toReadable("a\nb")));
expect(lines).toEqual(["a", "b"]);
});
it("yields nothing for empty input", async () => {
const lines = await collect(createScanner(toReadable("")));
expect(lines).toEqual([]);
});
it("yields a single blank line", async () => {
const lines = await collect(createScanner(toReadable("\n")));
expect(lines).toEqual([""]);
});
it("throws ReadlineError when line exceeds maxLineLength", async () => {
const long = "x".repeat(10);
await expect(
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
).rejects.toThrow(ReadlineError);
});
it("throws ReadlineError with LINE_TOO_LONG code", async () => {
const long = "x".repeat(10);
await expect(
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.LINE_TOO_LONG });
});
it("propagates stream errors as ReadlineError", async () => {
const r = new Readable({ read() {} });
const gen = createScanner(r);
const p = collect(gen);
r.destroy(new Error("boom"));
await expect(p).rejects.toMatchObject({
code: READLINE_CODES.SCANNER.STREAM_ERROR,
});
});
it("honours AbortSignal mid-iteration", async () => {
const controller = new AbortController();
const lines: string[] = [];
const r = Readable.from(
["line1\n", "line2\n", "line3\n"].map(Buffer.from)
);
for await (const line of createScanner(r, {
signal: controller.signal,
})) {
lines.push(line);
if (lines.length === 1) controller.abort();
}
expect(lines.length).toBe(1);
});
});
describe("createScanner property tests", { timeout: 30000 }, () => {
it("round-trips arbitrary multiline strings", async () => {
// Property: joining scanned lines with "\n" reproduces the original text.
// Constraint: the last element must be non-empty (or array empty) so that
// joining does NOT produce a trailing "\n". A trailing "\n" would be consumed
// as a complete line terminator and NOT re-emitted as a subsequent empty string,
// which is the correct readline behaviour but breaks the naive round-trip check.
await fc.assert(
fc.asyncProperty(
fc
.array(fc.string({ maxLength: 100 }), {
minLength: 0,
maxLength: 20,
})
.filter(
(arr) => arr.length === 0 || arr[arr.length - 1] !== ""
),
async (inputLines) => {
const text = inputLines.join("\n");
const lines = await collect(
createScanner(toReadable(text))
);
expect(lines.join("\n")).toBe(text);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,106 @@
import type { Readable } from "node:stream";
import { READLINE_CODES, ReadlineError } from "../errors/errors.js";
import type { ScannerOptions } from "./scanner.types.js";
const DEFAULT_MAX_LINE_LENGTH = 4096;
type QueueItem =
| { kind: "line"; value: string }
| { kind: "done" }
| { kind: "error"; error: Error };
export async function* createScanner(
input: Readable,
opts?: ScannerOptions
): AsyncGenerator<string, void, unknown> {
const maxLen = opts?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
const encoding = opts?.encoding ?? "utf8";
const signal = opts?.signal;
const queue: QueueItem[] = [];
let waitResolve: ((item: QueueItem) => void) | null = null;
function push(item: QueueItem): void {
if (waitResolve) {
const r = waitResolve;
waitResolve = null;
r(item);
} else {
queue.push(item);
}
}
function dequeue(): Promise<QueueItem> {
if (queue.length > 0) return Promise.resolve(queue.shift()!);
return new Promise<QueueItem>((resolve) => {
waitResolve = resolve;
});
}
const lineBuffer = Buffer.alloc(maxLen);
let lineLength = 0;
function emitLine(): void {
push({
kind: "line",
value: lineBuffer.subarray(0, lineLength).toString(encoding),
});
lineLength = 0;
}
input.on("data", (data: Buffer) => {
for (let i = 0; i < data.length; i++) {
const byte = data[i]!;
if (byte === 10) {
emitLine();
} else if (byte === 13) {
// CR — skip; CRLF handled when LF arrives
} else {
if (lineLength >= maxLen) {
push({
kind: "error",
error: new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long",
{ context: { maxLineLength: maxLen } }
),
});
lineLength = 0;
return;
}
lineBuffer[lineLength++] = byte;
}
}
});
input.on("end", () => {
if (lineLength > 0) emitLine();
push({ kind: "done" });
});
input.on("error", (err: Error) => {
push({
kind: "error",
error: new ReadlineError(
READLINE_CODES.SCANNER.STREAM_ERROR,
"readline.scanner.stream_error",
{ cause: err }
),
});
});
// Unblock any pending dequeue() when the signal fires
signal?.addEventListener("abort", () => push({ kind: "done" }), {
once: true,
});
while (true) {
if (signal?.aborted) break;
const item = await dequeue();
// NOTE: check again after await — abort may have fired while waiting in dequeue()
if (signal?.aborted) break;
if (item.kind === "error") throw item.error;
if (item.kind === "done") break;
yield item.value;
}
}

View File

@@ -0,0 +1,5 @@
export interface ScannerOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
signal?: AbortSignal;
}