Files
skills/docs/devtools/tooling/readline/src/interface/interface.md
2026-05-29 08:20:56 +01:00

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 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

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 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.