5.7 KiB
interface
(no submodules)
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 withINTERFACE.CLOSEDwhen the interface is closed andINTERFACE.ABORTEDwhen anAbortSignalfiresclose()— idempotent close method; setsclosed = trueand ends the OTel span; safe to call multiple timesclosedgetter — 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 withINTERFACE.ABORTED - OTel span lifetime — a
readline.interfacespan is started on construction and ended on close or stream drain; abort setsSpanStatusCode.ERRORbefore ending
Usage: Basic Example
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
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
interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
Throws ReadlineError with codes from READLINE_CODES.INTERFACE:
INTERFACE.CLOSED— method called afterclose()or stream drainedINTERFACE.ABORTED— abort signal fired before or during aquestion()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.