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.jsTerminalinstance (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 modeif (proc.stdin.setRawMode !== undefined) { proc.stdin.setRawMode(true)}
// wasmExec: check before synchronous pre-bufferingif (proc.stdin.tryRead !== undefined) { const chunk = proc.stdin.tryRead(4096)}Decision: capability detection, not a subtype. A
PlatformReadablesubtype would leak intoSpawnOpts,ProcContext, and every type that passesstdinaround. Optional methods on the sharedReadableinterface 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 programLifecycle:
wasmExecdetectsttyMode: 'raw'and callsproc.stdin.setRawMode(true)- Adapter tears down readline / switches dispatch to raw byte queue
- WASM program runs; each
read(stdin, ...)call yields via Asyncify wasmExeccallsproc.stdin.setRawMode(false)in itsfinallyblock- 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/colsgetTermSize 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:
setRawModeonproc.stdindirectly controls the adaptertryReadonproc.stdindirectly 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:readlineandnode:streamat runtime to avoid breaking tsc in the browser-compatible core build.xtermStdiois sync because it only uses browser-compatible APIs.
Typical Usage
// Node.js CLIconst { 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.jsconst 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,})