first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

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.