Skip to content

Platform Adapters

Bridges between real I/O environments (Node.js terminal, xterm.js) and the Readable/Writable contracts that fishbowl bins consume. Adapters are the only code that touches platform-specific APIs.

Two canonical adapters:

  • nodeStdio() — Node.js process (process.stdin/stdout/stderr)
  • xtermStdio(term) — xterm.js Terminal instance (browser or Electron)

Both return { stdin, stdout, stderr, getTermSize }. The stdin object implements the full Readable interface including the optional platform capabilities (setRawMode, tryRead).

Capability Detection

Optional methods on Readable are detected with ?. at the call site. Core bins never check for these — only WASM runners and other infrastructure code do.

// wasmExec: check before switching mode
if (proc.stdin.setRawMode !== undefined) {
proc.stdin.setRawMode(true)
}
// wasmExec: check before synchronous pre-buffering
if (proc.stdin.tryRead !== undefined) {
const chunk = proc.stdin.tryRead(4096)
}

Decision: capability detection, not a subtype. A PlatformReadable subtype would leak into SpawnOpts, ProcContext, and every type that passes stdin around. Optional methods on the shared Readable interface keep the type surface small and avoid casting at call sites.

Raw Mode Flow

Raw mode delivers keypresses one byte at a time, bypassing line buffering. Used by WASM programs (vim, micro-editor, etc.) that manage their own input.

User keypress
→ adapter.term.onData / process.stdin 'data' event
→ adapter rawByteQueue / passed directly (not through readline)
→ proc.stdin.read(4096) [async, 1 byte at a time in raw mode]
→ wasmExec pipeStdinToInput() → inputQueue
→ Asyncify TTY bridge get_char()
→ WASM program

Lifecycle:

  1. wasmExec detects ttyMode: 'raw' and calls proc.stdin.setRawMode(true)
  2. Adapter tears down readline / switches dispatch to raw byte queue
  3. WASM program runs; each read(stdin, ...) call yields via Asyncify
  4. wasmExec calls proc.stdin.setRawMode(false) in its finally block
  5. Adapter restores readline for the next shell prompt

nodeStdio raw mode: closes the PassThrough pipe feeding readline, calls process.stdin.setRawMode(true), and signals the readline iterator to pause. On setRawMode(false), restores process.stdin to cooked mode and signals the iterator to recreate readline.

xtermStdio raw mode: sets a rawMode flag and recreates the rawByteQueue (to flush stale data from a previous session). onData dispatches directly to the byte queue instead of readline.

Terminal Size Flow

Terminal dimensions are needed by WASM programs that use TIOCGWINSZ (e.g., vim computing window layout).

adapter.getTermSize() [reads term.rows/cols or process.stdout.rows/columns]
→ passed in SpawnOpts.getTermSize to kernel.spawn()
→ kernel stores on ProcessIO._io.getTermSize
→ kernel.makeProcContext() copies to ProcContext.getTermSize
→ wasmExec createDefaultBridge(): bridge.size = () => proc.getTermSize!()
→ Asyncify TTY bridge installTTYBridge(): ioctl TIOCGWINSZ handler
→ WASM program receives correct rows/cols

getTermSize is inherited through the spawn chain: if a child process doesn’t provide its own, it inherits the parent’s. This means vim spawned inside a shell pipeline that was started from xtermStdio will still get the correct terminal size.

Stdin by Reference Identity

stdin (the Readable object) is passed by reference through the spawn chain. When a shell spawns a WASM bin directly (no pipe redirection), the child’s proc.stdin IS the same object as the adapter’s stdin. This means:

  • setRawMode on proc.stdin directly controls the adapter
  • tryRead on proc.stdin directly drains the adapter’s buffer
  • No wrapping, no copying — identity is preserved

When stdin IS redirected (pipe or explicit fd), the child receives a different Readable backed by the pipe. In that case setRawMode and tryRead are absent, and wasmExec falls back to standard async reads.

Adapter Return Types

// nodeStdio (async — dynamic imports for node:readline, node:stream)
async function nodeStdio(opts?: NodeStdioOptions): Promise<NodeStdio>
interface NodeStdio {
stdin: Readable // includes setRawMode + tryRead
stdout: Writable
stderr: Writable
getTermSize(): TerminalSize
}
// xtermStdio (sync)
function xtermStdio(term: XtermTerminal, opts?: XtermStdioOptions): XtermStdio
interface XtermStdio {
stdin: Readable // includes setRawMode + tryRead
stdout: Writable
stderr: Writable
getTermSize(): TerminalSize
fitToContainer(): void // resize to DOM container
setCompletionProvider(fn: (prefix: string) => Promise<string[]>): void
}

Decision: nodeStdio is async. It dynamically imports node:readline and node:stream at runtime to avoid breaking tsc in the browser-compatible core build. xtermStdio is sync because it only uses browser-compatible APIs.

Typical Usage

// Node.js CLI
const { stdin, stdout, stderr, getTermSize } = await nodeStdio({ prompt: '$ ' })
const image = await Unix().use(stdSystem()).build()
const instance = image.boot()
await instance.spawn('/bin/sh', [], { stdin, stdout, stderr, getTermSize })
// Browser / Electron with xterm.js
const term = new Terminal()
const io = xtermStdio(term, { prompt: '$ ' })
const image = await Unix().use(stdSystem()).build()
const instance = image.boot()
await instance.spawn('/bin/sh', [], {
stdin: io.stdin,
stdout: io.stdout,
stderr: io.stderr,
getTermSize: io.getTermSize,
})