Initial Commit
This commit is contained in:
162
packages/devtools/tooling/readline/src/errors/errors.md
Normal file
162
packages/devtools/tooling/readline/src/errors/errors.md
Normal 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.
|
||||
59
packages/devtools/tooling/readline/src/errors/errors.test.ts
Normal file
59
packages/devtools/tooling/readline/src/errors/errors.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
65
packages/devtools/tooling/readline/src/errors/errors.ts
Normal file
65
packages/devtools/tooling/readline/src/errors/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
packages/devtools/tooling/readline/src/errors/index.ts
Normal file
5
packages/devtools/tooling/readline/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { ReadlineCode } from "./errors.js";
|
||||
export {
|
||||
READLINE_CODES,
|
||||
ReadlineError,
|
||||
} from "./errors.js";
|
||||
3
packages/devtools/tooling/readline/src/index.ts
Normal file
3
packages/devtools/tooling/readline/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./errors/index.js";
|
||||
export * from "./interface/index.js";
|
||||
export * from "./reader/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createInterface } from "./interface.js";
|
||||
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
|
||||
116
packages/devtools/tooling/readline/src/interface/interface.md
Normal file
116
packages/devtools/tooling/readline/src/interface/interface.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
142
packages/devtools/tooling/readline/src/interface/interface.ts
Normal file
142
packages/devtools/tooling/readline/src/interface/interface.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
2
packages/devtools/tooling/readline/src/reader/index.ts
Normal file
2
packages/devtools/tooling/readline/src/reader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { readLines } from "./reader.js";
|
||||
export type { ReadLinesOptions } from "./reader.types.js";
|
||||
114
packages/devtools/tooling/readline/src/reader/reader.md
Normal file
114
packages/devtools/tooling/readline/src/reader/reader.md
Normal 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.
|
||||
93
packages/devtools/tooling/readline/src/reader/reader.test.ts
Normal file
93
packages/devtools/tooling/readline/src/reader/reader.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
36
packages/devtools/tooling/readline/src/reader/reader.ts
Normal file
36
packages/devtools/tooling/readline/src/reader/reader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ReadLinesOptions {
|
||||
maxLineLength?: number;
|
||||
encoding?: BufferEncoding;
|
||||
skipEmpty?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
2
packages/devtools/tooling/readline/src/scanner/index.ts
Normal file
2
packages/devtools/tooling/readline/src/scanner/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createScanner } from "./scanner.js";
|
||||
export type { ScannerOptions } from "./scanner.types.js";
|
||||
93
packages/devtools/tooling/readline/src/scanner/scanner.md
Normal file
93
packages/devtools/tooling/readline/src/scanner/scanner.md
Normal 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.
|
||||
115
packages/devtools/tooling/readline/src/scanner/scanner.test.ts
Normal file
115
packages/devtools/tooling/readline/src/scanner/scanner.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
106
packages/devtools/tooling/readline/src/scanner/scanner.ts
Normal file
106
packages/devtools/tooling/readline/src/scanner/scanner.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ScannerOptions {
|
||||
maxLineLength?: number;
|
||||
encoding?: BufferEncoding;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
Reference in New Issue
Block a user