From f2e5a1116cd0d950eb4ae1c4daff87bf5fcf8e5d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 13:28:16 +0000 Subject: [PATCH 1/5] feat(std): add the node:tty shim --- packages/jco-std/package.json | 14 + .../src/wasi/0.2.x/node/24.x.x/errors/core.ts | 3 + .../wasi/0.2.x/node/24.x.x/tty-host-node.ts | 199 +++++++++++ .../src/wasi/0.2.x/node/24.x.x/tty-host.ts | 47 +++ .../wasi/0.2.x/node/24.x.x/tty-interface.d.ts | 10 + .../jco-std/src/wasi/0.2.x/node/24.x.x/tty.ts | 9 + .../src/wasi/0.2.x/node/24.x.x/tty/README.md | 52 +++ .../src/wasi/0.2.x/node/24.x.x/tty/colors.ts | 278 +++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/tty/core.ts | 335 ++++++++++++++++++ .../wasi/0.2.x/node/24.x.x/tty/host-utils.ts | 34 ++ .../src/wasi/0.2.x/node/24.x.x/tty/types.ts | 93 +++++ packages/jco-std/tsconfig.json | 1 + packages/jco-std/wit/node-0.1.0/tty.wit | 59 +++ 13 files changed, 1134 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host-node.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-interface.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/README.md create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/colors.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/core.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/host-utils.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/types.ts create mode 100644 packages/jco-std/wit/node-0.1.0/tty.wit diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 12aafc295..9fdba2b8d 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -444,6 +444,20 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/repl.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/repl.js", "default": "./dist/wasi/0.2.x/node/24.x.x/repl.js" + }, + "./wasi/0.2.x/node/24.x.x/tty": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tty.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/tty.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/tty.js" + }, + "./wasi/0.2.x/node/24.x.x/tty/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tty-host.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/tty-host.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/tty-host.js" + }, + "./wasi/0.2.x/node/24.x.x/tty/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tty-host-node.d.ts", + "node": "./dist/wasi/0.2.x/node/24.x.x/tty-host-node.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts index b0b84a1a3..3e5108f9c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts @@ -15,12 +15,14 @@ export type ErrorCode = | "ERR_ILLEGAL_CONSTRUCTOR" | "ERR_INVALID_ARG_TYPE" | "ERR_INVALID_ARG_VALUE" + | "ERR_INVALID_FD" | "ERR_INVALID_RETURN_VALUE" | "ERR_INVALID_THIS" | "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API" | "ERR_JCO_UNSUPPORTED_NODE_API" | "ERR_MISSING_ARGS" | "ERR_OUT_OF_RANGE" + | "ERR_TTY_INIT_FAILED" // Jco-specific codes. Every `ERR_JCO_*` code a shim raises is declared here so the set is // auditable in one place; per-builtin modules import these rather than restating literals. | "ERR_JCO_CHILD_PROCESS_ADAPTER_REQUIRED" @@ -32,6 +34,7 @@ export type ErrorCode = | "ERR_JCO_HTTP_ADAPTER_REQUIRED" | "ERR_JCO_INSPECTOR_ADAPTER_REQUIRED" | "ERR_JCO_OS_ADAPTER_REQUIRED" + | "ERR_JCO_TTY_ADAPTER_REQUIRED" | "ERR_JCO_HTTP_IMPLEMENTATION" | "ERR_JCO_INSPECTOR_HOST" | "ERR_JCO_INSPECTOR_UNAVAILABLE" diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host-node.ts new file mode 100644 index 000000000..f57743124 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host-node.ts @@ -0,0 +1,199 @@ +/** + * Opt-in Node host adapter for the terminal capability. + * + * Descriptors are the embedding process's own. A handle is Node's real `tty.ReadStream` or + * `tty.WriteStream` on the descriptor, which is where Node's exact `ERR_TTY_INIT_FAILED` comes + * from; input and output themselves go through `fs.readSync`/`fs.writeSync`, so libuv never + * competes with the guest for the terminal's bytes and reads block as the guest expects. + */ +import { readSync, writeSync } from "node:fs"; +import { Buffer } from "node:buffer"; +import nodeProcess from "node:process"; +import { ReadStream as NodeReadStream, WriteStream as NodeWriteStream, isatty } from "node:tty"; + +import { captureTtyCall } from "./tty/host-utils.js"; +import { errorRecord } from "./internal/host-error.js"; +import type { TtyDirection, TtyEnvironment, TtyProvider } from "./tty/types.js"; + +interface Handle { + stream: NodeReadStream | NodeWriteStream; + refs: number; +} + +const handles = new Map(); + +const key = (fd: number, direction: TtyDirection): string => `${direction}:${fd}`; + +/** How long to wait before retrying a descriptor that is in non-blocking mode. */ +const RETRY_MS = 5; + +function sleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function wouldBlock(error: unknown): boolean { + const code = errorRecord(error).code; + return code === "EAGAIN" || code === "EWOULDBLOCK"; +} + +/** Run `operation` and rethrow an `'error'` the stream emitted during it, as Node's tty does. */ +function captureEmittedError(stream: NodeReadStream | NodeWriteStream, operation: () => T): T { + let emitted: unknown; + const onError = (error: unknown): void => { + emitted = error; + }; + stream.on("error", onError); + try { + const result = operation(); + if (emitted !== undefined) { + throw emitted; + } + return result; + } finally { + stream.off("error", onError); + } +} + +function descriptorError(syscall: string, fd: number): Error { + return Object.assign(new Error(`${syscall} EBADF: no open terminal handle for fd ${fd}`), { + code: "EBADF", + errno: -9, + syscall, + }); +} + +function readHandle(fd: number): NodeReadStream { + const stream = handles.get(key(fd, "read"))?.stream; + if (!(stream instanceof NodeReadStream)) { + throw descriptorError("setRawMode", fd); + } + return stream; +} + +function writeHandle(fd: number): NodeWriteStream { + const stream = handles.get(key(fd, "write"))?.stream; + if (!(stream instanceof NodeWriteStream)) { + throw descriptorError("getWindowSize", fd); + } + return stream; +} + +export const isTty: TtyProvider["isTty"] = (fd) => captureTtyCall(() => isatty(fd)); + +export const open: TtyProvider["open"] = (fd, direction) => + captureTtyCall(() => { + const entry = handles.get(key(fd, direction)); + if (entry) { + entry.refs += 1; + return; + } + const stream = direction === "read" ? new NodeReadStream(fd) : new NodeWriteStream(fd); + // The read handle only carries raw mode; it must never start reading itself. + stream.pause(); + // An idle handle must not keep the embedding process alive. + stream.unref(); + handles.set(key(fd, direction), { stream, refs: 1 }); + }); + +export const close: TtyProvider["close"] = (fd, direction) => + captureTtyCall(() => { + const id = key(fd, direction); + const entry = handles.get(id); + if (!entry) { + return; + } + entry.refs -= 1; + if (entry.refs > 0) { + return; + } + handles.delete(id); + if (entry.stream instanceof NodeReadStream && entry.stream.isRaw) { + entry.stream.setRawMode(false); + } + // The standard descriptors are shared with the embedding process and stay open, as Node + // keeps its own stdio open; anything else is the application's to close. + if (fd > 2) { + entry.stream.destroy(); + } + }); + +export const windowSize: TtyProvider["windowSize"] = (fd) => + captureTtyCall(() => { + const stream = writeHandle(fd); + const refresh = (stream as { _refreshSize?: () => void })._refreshSize; + if (typeof refresh === "function") { + captureEmittedError(stream, () => refresh.call(stream)); + } + const [columns, rows] = stream.getWindowSize(); + if (typeof columns !== "number" || typeof rows !== "number") { + throw Object.assign(new Error("getWindowSize ENOTSUP: the terminal reports no size"), { + code: "ENOTSUP", + syscall: "getWindowSize", + }); + } + return { columns, rows }; + }); + +export const setRawMode: TtyProvider["setRawMode"] = (fd, enabled) => + captureTtyCall(() => { + const stream = readHandle(fd); + captureEmittedError(stream, () => stream.setRawMode(enabled)); + }); + +export const read: TtyProvider["read"] = (fd, maxBytes) => + captureTtyCall(() => { + const buffer = Buffer.allocUnsafe(Math.max(1, maxBytes)); + for (;;) { + try { + const length = readSync(fd, buffer, 0, buffer.length, null); + return new Uint8Array(buffer.subarray(0, length)); + } catch (error) { + if (wouldBlock(error)) { + sleep(RETRY_MS); + continue; + } + // A hung-up pseudo-terminal reads as EIO on Linux; libuv reports both as end of input. + const code = errorRecord(error).code; + if (code === "EOF" || code === "EIO") { + return new Uint8Array(0); + } + throw error; + } + } + }); + +export const write: TtyProvider["write"] = (fd, data) => + captureTtyCall(() => { + let offset = 0; + while (offset < data.byteLength) { + try { + offset += writeSync(fd, data, offset, data.byteLength - offset); + } catch (error) { + if (!wouldBlock(error)) { + throw error; + } + sleep(RETRY_MS); + } + } + }); + +export const environment: TtyProvider["environment"] = () => + captureTtyCall( + (): TtyEnvironment => + Object.entries(nodeProcess.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + +const host: TtyProvider = { + isTty, + open, + close, + windowSize, + setRawMode, + read, + write, + environment, +}; + +export default host; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host.ts new file mode 100644 index 000000000..3ecb47b62 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-host.ts @@ -0,0 +1,47 @@ +import { adapterRequiredMessage } from "./internal/deny-host.js"; +import type { TtyError, TtyProvider } from "./tty/types.js"; + +/** + * JS bindings lower thrown records into the `err` case of WIT `result`. + * The guest reconstructs the same structured error used by the explicit Node host. + */ +const denied = (): never => { + throw { + name: "Error", + message: adapterRequiredMessage("node:tty"), + code: "ERR_JCO_TTY_ADAPTER_REQUIRED", + } satisfies TtyError; +}; + +/** + * The default adapter intentionally grants no terminal. Every operation, including + * `isatty` on an in-range descriptor, is denied until the application maps a provider. + */ +export const isTty: TtyProvider["isTty"] = denied; + +export const open: TtyProvider["open"] = denied; + +export const close: TtyProvider["close"] = denied; + +export const windowSize: TtyProvider["windowSize"] = denied; + +export const setRawMode: TtyProvider["setRawMode"] = denied; + +export const read: TtyProvider["read"] = denied; + +export const write: TtyProvider["write"] = denied; + +export const environment: TtyProvider["environment"] = denied; + +const host: TtyProvider = { + isTty, + open, + close, + windowSize, + setRawMode, + read, + write, + environment, +}; + +export default host; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-interface.d.ts new file mode 100644 index 000000000..26b63d740 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty-interface.d.ts @@ -0,0 +1,10 @@ +import type { TtyDirection, TtyEnvironment, TtyWindowSize } from "./tty/types.js"; + +export function isTty(fd: number): boolean; +export function open(fd: number, direction: TtyDirection): void; +export function close(fd: number, direction: TtyDirection): void; +export function windowSize(fd: number): TtyWindowSize; +export function setRawMode(fd: number, enabled: boolean): void; +export function read(fd: number, maxBytes: number): Uint8Array; +export function write(fd: number, data: Uint8Array): void; +export function environment(): TtyEnvironment; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty.ts new file mode 100644 index 000000000..ff33d9365 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty.ts @@ -0,0 +1,9 @@ +import * as host from "jco:node/tty@0.1.0"; + +import { createTty } from "./tty/core.js"; + +const tty = createTty(host); + +export type * from "./tty/types.js"; +export const { isatty, ReadStream, WriteStream } = tty; +export default tty; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/README.md b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/README.md new file mode 100644 index 000000000..274ae9f8c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/README.md @@ -0,0 +1,52 @@ +# TTY source provenance + +The TypeScript port targets **Node v24.20.0**, commit +[`71b8b174857e25106d39b61a9e6f30d927da8b01`](https://github.com/nodejs/node/tree/71b8b174857e25106d39b61a9e6f30d927da8b01), +the same pin as the readline port whose cursor callbacks it reuses. The upstream +notices are retained in each ported source file. + +| Local file | Upstream source | Local adaptations | +| --------------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| `core.ts` | `lib/tty.js` | `tty_wrap` becomes the `jco:node/tty` provider; `net.Socket` becomes `stream.Duplex` | +| `colors.ts` | `lib/internal/tty.js` | Default environment from the provider; `process` as an optional global | +| `types.ts` | `@types/node` 24 tty declarations | Structural stream types shared with the stream port; Jco-owned provider contract | +| `host-utils.ts` | Jco host boundary | Error records with Node's `info` field | + +`unenv@2.0.0-rc.24` was inspected at `node/tty`: `isatty()` is always `false` and the +streams are bare classes with a fixed 80x24 size whose `write` calls `console.log`. +None of it is reused or admitted to Jco's alias list. + +WASI 0.2's `wasi:cli/terminal-*` interfaces only answer "is stdin/stdout/stderr a +terminal", carry no raw mode or window size, and cannot be bound by componentize-js +today, so the module is host-backed like `node:console` and `node:os`. Descriptors +are the host process's own: `new tty.WriteStream(1)` is the embedding process's +standard output when that is a terminal. + +## Runtime differences + +- The prototype chain is `WriteStream → TerminalOutput → Duplex → Readable → Stream → +EventEmitter` (`ReadStream → TerminalInput → …`): an internal terminal stream carries + the `_read`/`_write`/`_destroy` plumbing where Node has `net.Socket`, which does not + exist without `wasi:sockets`, so `instanceof net.Socket` is false. Every documented + member of both public classes is present and enumerable as in Node. +- A `ReadStream` is not writable and a `WriteStream` is not readable. Node's streams + are sockets over a descriptor opened read-write; the unused side is disabled here. +- Reading blocks the component. A flowing `ReadStream` pulls one chunk at a time + from the provider and emits `'data'` synchronously between pulls; `pause()` stops + pulling after the current chunk. There is no event loop to interleave other work + while the terminal is idle. +- `'resize'` is emitted only by `_refreshSize()`. A component receives no `SIGWINCH`, + so the application decides when to re-query the size. +- `getColorDepth()`/`hasColors()` read the provider's environment when none is + passed. The `process.platform === 'win32'` branch reads a `process` global when one + exists and answers Windows' 16-color floor, since the release probe needs `node:os`; + `warnOnDeactivatedColors` uses `process.emitWarning` when a `process` global has it. +- `setRawMode()` failures are emitted as `'error'` with the provider's record, as Node + emits its `ErrnoException`. +- Construction fails before any stream exists, as in Node: `ERR_INVALID_FD` for a bad + descriptor and the provider's `ERR_TTY_INIT_FAILED` (with `errno`, `syscall` and + `info`) for a descriptor that is not a terminal. With the deny-by-default provider + every operation, including `isatty` on an in-range descriptor, throws + `ERR_JCO_TTY_ADAPTER_REQUIRED`. + +No API in `node:tty` is deprecated at the pin. diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/colors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/colors.ts new file mode 100644 index 000000000..38591750e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/colors.ts @@ -0,0 +1,278 @@ +// MIT License + +// Copyright (c) Sindre Sorhus (sindresorhus.com) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/internal/tty.js. +// Local changes: TypeScript types, ES intrinsics, the default environment comes +// from the terminal provider rather than `process.env`, `process` is read as an +// optional global for the platform check and the deactivated-colors warning, and +// the Windows release probe (which needs `node:os`) resolves to Windows' 16-color +// floor. See ./README.md for runtime boundaries. + +import { validateInteger } from "../readline/compat.js"; +import type { ColorEnvironment } from "./types.js"; + +const COLORS_2 = 1; +const COLORS_16 = 4; +const COLORS_256 = 8; +const COLORS_16m = 24; + +// Some entries were taken from `dircolors` +// (https://linux.die.net/man/1/dircolors). The corresponding terminals might +// support more than 16 colors, but this was not tested for. +// +// Copyright (C) 1996-2016 Free Software Foundation, Inc. Copying and +// distribution of this file, with or without modification, are permitted +// provided the copyright notice and this notice are preserved. +const TERM_ENVS: Readonly> = { + eterm: COLORS_16, + cons25: COLORS_16, + console: COLORS_16, + cygwin: COLORS_16, + dtterm: COLORS_16, + gnome: COLORS_16, + hurd: COLORS_16, + jfbterm: COLORS_16, + konsole: COLORS_16, + kterm: COLORS_16, + mlterm: COLORS_16, + mosh: COLORS_16m, + putty: COLORS_16, + st: COLORS_16, + // http://lists.schmorp.de/pipermail/rxvt-unicode/2016q2/002261.html + "rxvt-unicode-24bit": COLORS_16m, + // https://bugs.launchpad.net/terminator/+bug/1030562 + terminator: COLORS_16m, + "xterm-kitty": COLORS_16m, +}; + +const CI_ENVS_MAP = new Map( + Object.entries({ + APPVEYOR: COLORS_256, + BUILDKITE: COLORS_256, + CIRCLECI: COLORS_16m, + DRONE: COLORS_256, + GITEA_ACTIONS: COLORS_16m, + GITHUB_ACTIONS: COLORS_16m, + GITLAB_CI: COLORS_256, + TRAVIS: COLORS_256, + }), +); + +const TERM_ENVS_REG_EXP = [ + /ansi/, + /color/, + /linux/, + /direct/, + /^con[0-9]*x[0-9]/, + /^rxvt/, + /^screen/, + /^xterm/, + /^vt100/, + /^vt220/, +]; + +interface ProcessGlobal { + platform?: unknown; + emitWarning?: unknown; +} + +/** The `process` global when one exists; this module never imports `node:process`. */ +function processGlobal(): ProcessGlobal | undefined { + const candidate = (globalThis as { process?: unknown }).process; + return typeof candidate === "object" && candidate !== null + ? (candidate as ProcessGlobal) + : undefined; +} + +function hasOwn(env: ColorEnvironment, name: string): boolean { + return Object.prototype.hasOwnProperty.call(env, name); +} + +export interface ColorFunctions { + getColorDepth(env?: ColorEnvironment): number; + hasColors(count?: number | ColorEnvironment, env?: ColorEnvironment): boolean; +} + +/** + * Build `getColorDepth` and `hasColors` over a lazily read default environment. Nothing is read + * from the provider until a call omits `env`, so an explicit environment never touches the host. + */ +export function createColorFunctions(defaultEnvironment: () => ColorEnvironment): ColorFunctions { + let warned = false; + function warnOnDeactivatedColors(env: ColorEnvironment): void { + if (warned) { + return; + } + let name = ""; + if (env.NODE_DISABLE_COLORS !== undefined && env.NODE_DISABLE_COLORS !== "") { + name = "NODE_DISABLE_COLORS"; + } + if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") { + if (name !== "") { + name += "' and '"; + } + name += "NO_COLOR"; + } + + if (name !== "") { + const emitWarning = processGlobal()?.emitWarning; + if (typeof emitWarning === "function") { + emitWarning( + `The '${name}' env is ignored due to the 'FORCE_COLOR' env being set.`, + "Warning", + ); + } + warned = true; + } + } + + // The `getColorDepth` API got inspired by multiple sources such as + // https://github.com/chalk/supports-color, + // https://github.com/isaacs/color-support. + function getColorDepth(env: ColorEnvironment = defaultEnvironment()): number { + // Use level 0-3 to support the same levels as `chalk` does. This is done for + // consistency throughout the ecosystem. + if (env.FORCE_COLOR !== undefined) { + switch (env.FORCE_COLOR) { + case "": + case "1": + case "true": + warnOnDeactivatedColors(env); + return COLORS_16; + case "2": + warnOnDeactivatedColors(env); + return COLORS_256; + case "3": + warnOnDeactivatedColors(env); + return COLORS_16m; + default: + return COLORS_2; + } + } + + if ( + (env.NODE_DISABLE_COLORS !== undefined && env.NODE_DISABLE_COLORS !== "") || + // See https://no-color.org/ + (env.NO_COLOR !== undefined && env.NO_COLOR !== "") || + // The "dumb" special terminal, as defined by terminfo, doesn't support + // ANSI color control codes. + // See https://invisible-island.net/ncurses/terminfo.ti.html#toc-_Specials + env.TERM === "dumb" + ) { + return COLORS_2; + } + + if (processGlobal()?.platform === "win32") { + // Node reads the Windows build number from `os.release()` to promote this to 256 colors + // (build 10586) or 16m colors (build 14931). A component has no `node:os` here, so the + // answer is Windows' documented floor. + return COLORS_16; + } + + if (env.TMUX) { + return COLORS_16m; + } + + // Azure DevOps + if (hasOwn(env, "TF_BUILD") && hasOwn(env, "AGENT_NAME")) { + return COLORS_16; + } + + if (hasOwn(env, "CI")) { + for (const [envName, colors] of CI_ENVS_MAP) { + if (hasOwn(env, envName)) { + return colors; + } + } + if (env.CI_NAME === "codeship") { + return COLORS_256; + } + return COLORS_2; + } + + if ("TEAMCITY_VERSION" in env) { + return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.exec(env.TEAMCITY_VERSION ?? "") !== null + ? COLORS_16 + : COLORS_2; + } + + switch (env.TERM_PROGRAM) { + case "iTerm.app": + if (!env.TERM_PROGRAM_VERSION || /^[0-2]\./.exec(env.TERM_PROGRAM_VERSION) !== null) { + return COLORS_256; + } + return COLORS_16m; + case "HyperTerm": + case "MacTerm": + return COLORS_16m; + case "Apple_Terminal": + return COLORS_256; + } + + if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") { + return COLORS_16m; + } + + if (env.TERM) { + if (/truecolor/.exec(env.TERM) !== null) { + return COLORS_16m; + } + + if (/^xterm-256/.exec(env.TERM) !== null) { + return COLORS_256; + } + + const termEnv = env.TERM.toLowerCase(); + + if (TERM_ENVS[termEnv]) { + return TERM_ENVS[termEnv]; + } + if (TERM_ENVS_REG_EXP.some((term) => term.exec(termEnv) !== null)) { + return COLORS_16; + } + } + // Move 16 color COLORTERM below 16m and 256 + if (env.COLORTERM) { + return COLORS_16; + } + return COLORS_2; + } + + function hasColors(count?: number | ColorEnvironment, env?: ColorEnvironment): boolean { + let colors: number; + if ( + env === undefined && + (count === undefined || (typeof count === "object" && count !== null)) + ) { + env = count ?? undefined; + colors = 16; + } else { + validateInteger(count, "count", 2); + colors = count; + } + + return colors <= 2 ** getColorDepth(env); + } + + return { getColorDepth, hasColors }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/core.ts new file mode 100644 index 000000000..27800ab14 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/core.ts @@ -0,0 +1,335 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from nodejs/node v24.20.0, commit +// 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/tty.js. +// Local changes: TypeScript types; the `tty_wrap` binding becomes the +// `jco:node/tty` provider, addressed by descriptor; `net.Socket` becomes the +// portable `stream.Duplex` with the unused side disabled; reads are blocking +// pulls from the provider. See ./README.md for runtime boundaries. + +import { Duplex } from "../stream/index.js"; +import { clearLine, clearScreenDown, cursorTo, moveCursor } from "../readline/callbacks.js"; +import { codedError, invalidArgType, systemError } from "../errors/core.js"; +import { callHost, decodeErrno } from "../internal/host-error.js"; +import { createColorFunctions } from "./colors.js"; +import type { Callback } from "../stream/types.js"; +import type { + ColorEnvironment, + ReadStreamConstructor, + ReadStreamOptions, + TtyError, + TtyModule, + TtyProvider, + TtyResult, + WriteStreamConstructor, +} from "./types.js"; + +/** libuv's default terminal read allocation; the provider returns at most this much per pull. */ +const READ_CHUNK_BYTES = 64 * 1024; + +/** Node's `ERR_INVALID_FD`. */ +function invalidFd(fd: unknown): RangeError { + return codedError( + new RangeError(`"fd" must be a positive integer: ${String(fd)}`), + "ERR_INVALID_FD", + ); +} + +/** Rebuild the error a provider serialized: Node's `ERR_TTY_INIT_FAILED` carries `info`. */ +function providerError(record: TtyError): Error { + const info = record.info + ? { + errno: decodeErrno(record.info.errno), + code: record.info.code, + message: record.info.message, + syscall: record.info.syscall, + } + : undefined; + const error = systemError({ + message: record.message, + code: record.code ?? "ERR_JCO_TTY_HOST", + errno: decodeErrno(record.errno), + syscall: record.syscall, + info, + }); + if (record.name !== "Error") { + error.name = record.name; + } + return error; +} + +function bytesOf(chunk: unknown): Uint8Array { + if (chunk instanceof Uint8Array) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + if (ArrayBuffer.isView(chunk)) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + throw invalidArgType("chunk", ["string", "Buffer", "TypedArray", "DataView"], chunk); +} + +/** Node's constructors are plain functions: calling one without `new` constructs. */ +function constructible unknown>(target: T): T { + const callable = new Proxy(target, { + apply(callee, _thisArgument, argumentsList: unknown[]) { + return Reflect.construct(callee, argumentsList); + }, + }); + Object.defineProperty(target.prototype, "constructor", { + value: callable, + writable: true, + enumerable: false, + configurable: true, + }); + return callable; +} + +/** Node assigns these prototype members, so they are enumerable own keys of the prototype. */ +function enumerable(prototype: object, keys: readonly string[]): void { + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (descriptor) { + Object.defineProperty(prototype, key, { ...descriptor, enumerable: true }); + } + } +} + +/** Create the `node:tty` module over an explicit terminal provider. */ +export function createTty(host: TtyProvider): TtyModule { + const call = (operation: () => T | TtyResult): T => callHost(operation, providerError); + + function validateFd(fd: unknown): asserts fd is number { + if ((fd as number) >> 0 !== fd || (fd as number) < 0) { + throw invalidFd(fd); + } + } + + function isatty(fd: unknown): boolean { + return ( + Number.isInteger(fd) && + (fd as number) >= 0 && + (fd as number) <= 2147483647 && + call(() => host.isTty(fd as number)) + ); + } + + const { getColorDepth, hasColors } = createColorFunctions( + (): ColorEnvironment => Object.fromEntries(call(() => host.environment())), + ); + + // Node's tty streams are `net.Socket`s over a `TTY` handle, and the stream plumbing + // (`_read`, `_write`, `_destroy`) lives on `Socket.prototype`. These two classes stand in for + // the socket so that `ReadStream.prototype` and `WriteStream.prototype` carry exactly the own + // members Node's do. + class TerminalInput extends Duplex { + readonly #fd: number; + + constructor(fd: number, options?: ReadStreamOptions) { + super({ readableHighWaterMark: 0, ...options, writable: false }); + this.#fd = fd; + } + + protected get fd(): number { + return this.#fd; + } + + // A pull blocks the component until the terminal has input; the flowing loop then emits + // `'data'` synchronously between pulls. An empty read is the terminal's end of input. + override _read(): void { + let bytes: Uint8Array; + try { + bytes = call(() => host.read(this.fd, READ_CHUNK_BYTES)); + } catch (error) { + this.destroy(error as Error); + return; + } + this.push(bytes.byteLength === 0 ? null : bytes); + } + + override _destroy(error: Error | null, callback: Callback): void { + try { + call(() => host.close(this.fd, "read")); + } catch (closeError) { + callback(error ?? (closeError as Error)); + return; + } + callback(error); + } + } + + class TerminalOutput extends Duplex { + readonly #fd: number; + + constructor(fd: number) { + super({ readableHighWaterMark: 0, readable: false }); + this.#fd = fd; + } + + protected get fd(): number { + return this.#fd; + } + + override _write(chunk: unknown, _encoding: string, callback: Callback): void { + try { + const bytes = bytesOf(chunk); + call(() => host.write(this.fd, bytes)); + } catch (error) { + callback(error as Error); + return; + } + callback(); + } + + override _destroy(error: Error | null, callback: Callback): void { + try { + call(() => host.close(this.fd, "write")); + } catch (closeError) { + callback(error ?? (closeError as Error)); + return; + } + callback(error); + } + } + + class ReadStream extends TerminalInput { + isRaw = false; + isTTY = true; + + constructor(fd: unknown, options?: ReadStreamOptions) { + validateFd(fd); + // Node's `new TTY(fd)` runs before the stream exists: an `ERR_TTY_INIT_FAILED` leaves + // nothing behind to close. + call(() => host.open(fd, "read")); + super(fd, options); + } + + setRawMode(flag: unknown): this { + const enabled = !!flag; + try { + call(() => host.setRawMode(this.fd, enabled)); + } catch (error) { + this.emit("error", error); + return this; + } + this.isRaw = enabled; + return this; + } + } + + class WriteStream extends TerminalOutput { + declare isTTY: boolean; + declare columns: number | undefined; + declare rows: number | undefined; + + constructor(fd: unknown) { + validateFd(fd); + call(() => host.open(fd, "write")); + super(fd); + + // Node keeps `columns`/`rows` absent when the terminal reports no size. + const size = this.#windowSize(); + if (size) { + this.columns = size.columns; + this.rows = size.rows; + } + } + + #windowSize(): { columns: number; rows: number } | undefined { + try { + return call(() => host.windowSize(this.fd)); + } catch { + return undefined; + } + } + + getColorDepth(env?: ColorEnvironment): number { + return getColorDepth(env); + } + + hasColors(count?: number | ColorEnvironment, env?: ColorEnvironment): boolean { + return hasColors(count, env); + } + + _refreshSize(): void { + const oldCols = this.columns; + const oldRows = this.rows; + let size: { columns: number; rows: number }; + try { + size = call(() => host.windowSize(this.fd)); + } catch (error) { + this.emit("error", error); + return; + } + const { columns: newCols, rows: newRows } = size; + if (oldCols !== newCols || oldRows !== newRows) { + this.columns = newCols; + this.rows = newRows; + this.emit("resize"); + } + } + + // Backwards-compat + cursorTo(x: number, y?: number | Callback, callback?: Callback): boolean { + return cursorTo(this, x, y, callback); + } + + moveCursor(dx: number, dy: number, callback?: Callback): boolean { + return moveCursor(this, dx, dy, callback); + } + + clearLine(dir: -1 | 0 | 1, callback?: Callback): boolean { + return clearLine(this, dir, callback); + } + + clearScreenDown(callback?: Callback): boolean { + return clearScreenDown(this, callback); + } + + getWindowSize(): [number | undefined, number | undefined] { + return [this.columns, this.rows]; + } + } + + Object.defineProperty(WriteStream.prototype, "isTTY", { + value: true, + writable: true, + enumerable: true, + configurable: true, + }); + enumerable(ReadStream.prototype, ["setRawMode"]); + enumerable(WriteStream.prototype, [ + "getColorDepth", + "hasColors", + "_refreshSize", + "cursorTo", + "moveCursor", + "clearLine", + "clearScreenDown", + "getWindowSize", + ]); + + return { + isatty, + ReadStream: constructible(ReadStream) as unknown as ReadStreamConstructor, + WriteStream: constructible(WriteStream) as unknown as WriteStreamConstructor, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/host-utils.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/host-utils.ts new file mode 100644 index 000000000..e2d262c19 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/host-utils.ts @@ -0,0 +1,34 @@ +import type { TtyError, TtyErrorInfo } from "./types.js"; +import { + encodeErrno, + errorRecord, + serializeHostError, + stringField, +} from "../internal/host-error.js"; + +function errorInfo(value: unknown): TtyErrorInfo | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = errorRecord(value); + return { + errno: encodeErrno(record.errno), + code: stringField(record.code), + message: stringField(record.message), + syscall: stringField(record.syscall), + }; +} + +/** Serialize a thrown provider error into the typed terminal component boundary. */ +export function serializeTtyError(error: unknown): TtyError { + return { ...serializeHostError(error), info: errorInfo(errorRecord(error).info) }; +} + +/** Run a synchronous provider operation and preserve its structured error. */ +export function captureTtyCall(operation: () => T): T { + try { + return operation(); + } catch (error) { + throw serializeTtyError(error); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/types.ts new file mode 100644 index 000000000..a2320a062 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tty/types.ts @@ -0,0 +1,93 @@ +/** + * Public value shapes follow @types/node 24 tty.d.ts (MIT). The WIT provider types are Jco-owned + * and carry no dependency on Node declarations; they must stay identical to the records + * `jco:node/tty@0.1.0` declares, since transpiled bindings produce exactly these object shapes. + */ +import type { HostErrno, HostErrorBase, HostImports, HostResult } from "../internal/wit-types.js"; +import type { Callback, Duplex, DuplexOptions } from "../stream/types.js"; + +/** The libuv failure Node attaches to `ERR_TTY_INIT_FAILED` as `info`. */ +export interface TtyErrorInfo { + errno?: HostErrno; + code?: string; + message?: string; + syscall?: string; +} + +export interface TtyError extends HostErrorBase { + info?: TtyErrorInfo; +} + +export type TtyResult = HostResult; + +export type TtyDirection = "read" | "write"; + +export interface TtyWindowSize { + columns: number; + rows: number; +} + +/** Environment name/value pairs, as the shared `jco:node/types` `env-vars` type lowers. */ +export type TtyEnvironment = [string, string][]; + +/** The `jco:node/tty@0.1.0` contract, in the tagged-result form the WIT declares. */ +export interface TtyHost { + isTty(fd: number): TtyResult; + open(fd: number, direction: TtyDirection): TtyResult; + close(fd: number, direction: TtyDirection): TtyResult; + windowSize(fd: number): TtyResult; + setRawMode(fd: number, enabled: boolean): TtyResult; + read(fd: number, maxBytes: number): TtyResult; + write(fd: number, data: Uint8Array): TtyResult; + environment(): TtyResult; +} + +/** A provider may return bare values and throw error records, as jco's bindings do. */ +export type TtyProvider = HostImports; + +/** An environment object as `getColorDepth()` and `hasColors()` read it. */ +export type ColorEnvironment = Readonly>; + +/** Options accepted by `new tty.ReadStream(fd, options)`; the stream's sides are fixed. */ +export type ReadStreamOptions = Omit; + +export interface ReadStream extends Duplex { + isRaw: boolean; + isTTY: boolean; + setRawMode(mode: boolean): this; +} + +export interface WriteStream extends Duplex { + isTTY: boolean; + /** Present only when the terminal reported a size, as in Node. */ + columns: number | undefined; + rows: number | undefined; + getColorDepth(env?: ColorEnvironment): number; + hasColors(count?: number, env?: ColorEnvironment): boolean; + hasColors(env?: ColorEnvironment): boolean; + _refreshSize(): void; + cursorTo(x: number, y?: number, callback?: Callback): boolean; + cursorTo(x: number, callback: Callback): boolean; + moveCursor(dx: number, dy: number, callback?: Callback): boolean; + clearLine(dir: -1 | 0 | 1, callback?: Callback): boolean; + clearScreenDown(callback?: Callback): boolean; + getWindowSize(): [number | undefined, number | undefined]; +} + +export interface ReadStreamConstructor { + new (fd: number, options?: ReadStreamOptions): ReadStream; + (fd: number, options?: ReadStreamOptions): ReadStream; + prototype: ReadStream; +} + +export interface WriteStreamConstructor { + new (fd: number): WriteStream; + (fd: number): WriteStream; + prototype: WriteStream; +} + +export interface TtyModule { + isatty(fd: number): boolean; + ReadStream: ReadStreamConstructor; + WriteStream: WriteStreamConstructor; +} diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index 189fae10c..e8d2929b3 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -17,6 +17,7 @@ "wasi:sockets/ip-name-lookup@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], "wasi:sockets/tcp-create-socket@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], "jco:node/process@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/process-interface.d.ts"], + "jco:node/tty@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/tty-interface.d.ts"], "jco:node/child-process@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/child-process-interface.d.ts"], "jco:node/cluster@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/cluster-interface.d.ts"], "jco:node/console@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/console-interface.d.ts"], diff --git a/packages/jco-std/wit/node-0.1.0/tty.wit b/packages/jco-std/wit/node-0.1.0/tty.wit new file mode 100644 index 000000000..a0c3afcb1 --- /dev/null +++ b/packages/jco-std/wit/node-0.1.0/tty.wit @@ -0,0 +1,59 @@ +package jco:node@0.1.0; + +/// Terminal access for `node:tty`. No capability is granted by default; the Node provider exposes +/// the terminals of the embedding process, addressed by file descriptor as Node does. +interface tty { + use types.{env-vars}; + + variant errno { + number(s64), + symbolic(string), + } + + /// The libuv failure Node attaches to `ERR_TTY_INIT_FAILED` as `info`. + record error-info { + errno: option, + code: option, + message: option, + syscall: option, + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + info: option, + } + + /// Which side of a terminal a stream uses. + enum direction { + read, + write, + } + + record terminal-size { + columns: u32, + rows: u32, + } + + /// Whether `fd` refers to a terminal. The guest range-checks the descriptor first. + is-tty: func(fd: u32) -> result; + /// Acquire a terminal handle on `fd` for one direction. Fails the way Node's `uv_tty_init` + /// does when `fd` is not a terminal; a handle may be acquired more than once. + open: func(fd: u32, direction: direction) -> result<_, error>; + /// Release one acquisition of a handle. Raw mode is restored when the last read handle goes. + close: func(fd: u32, direction: direction) -> result<_, error>; + /// The terminal's current size; fails when the terminal does not report one. + window-size: func(fd: u32) -> result; + /// Switch the terminal's input between cooked and raw mode. + set-raw-mode: func(fd: u32, enabled: bool) -> result<_, error>; + /// Block until input is available and return up to `max-bytes` of it; an empty list is the + /// end of input. + read: func(fd: u32, max-bytes: u32) -> result, error>; + /// Write all of `data` before returning. + write: func(fd: u32, data: list) -> result<_, error>; + /// The environment `getColorDepth()` and `hasColors()` consult when none is passed. + environment: func() -> result; +} From a19c17cc1dcee6264113270d19e900e893a2f49f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 13:28:16 +0000 Subject: [PATCH 2/5] test(std): add node:tty unit and pty tests --- .../node/24.x.x/helpers/tty-pty-child.mjs | 36 ++++ .../wasi/0.2.x/node/24.x.x/helpers/tty-pty.py | 59 ++++++ .../wasi/0.2.x/node/24.x.x/helpers/tty.ts | 149 ++++++++++++++ .../0.2.x/node/24.x.x/tty/get-color-depth.ts | 155 ++++++++++++++ .../wasi/0.2.x/node/24.x.x/tty/has-colors.ts | 59 ++++++ .../test/wasi/0.2.x/node/24.x.x/tty/host.ts | 137 +++++++++++++ .../test/wasi/0.2.x/node/24.x.x/tty/isatty.ts | 48 +++++ .../test/wasi/0.2.x/node/24.x.x/tty/module.ts | 83 ++++++++ .../test/wasi/0.2.x/node/24.x.x/tty/pty.ts | 58 ++++++ .../wasi/0.2.x/node/24.x.x/tty/read-stream.ts | 179 ++++++++++++++++ .../0.2.x/node/24.x.x/tty/write-stream.ts | 191 ++++++++++++++++++ 11 files changed, 1154 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty-child.mjs create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty.py create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/get-color-depth.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/has-colors.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/host.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/isatty.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/pty.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/read-stream.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/write-stream.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty-child.mjs b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty-child.mjs new file mode 100644 index 000000000..8da4f591f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty-child.mjs @@ -0,0 +1,36 @@ +// Runs under a pseudo-terminal: the built module over the Node host, driving readline through +// real terminal streams. Prints a REPORT line the test parses. +import { createTty } from "../../../../../../dist/wasi/0.2.x/node/24.x.x/tty/core.js"; +import host from "../../../../../../dist/wasi/0.2.x/node/24.x.x/tty-host-node.js"; +import { createInterface } from "../../../../../../dist/wasi/0.2.x/node/24.x.x/readline.js"; + +const tty = createTty(host); +const report = { isatty: [0, 1, 2].map((fd) => tty.isatty(fd)), events: [] }; + +const output = new tty.WriteStream(1); +const input = new tty.ReadStream(0); +report.size = output.getWindowSize(); +report.hasColors = output.hasColors(); +report.depth = output.getColorDepth({ TERM: "xterm-256color" }); +report.rawBefore = input.isRaw; +output.on("resize", () => report.events.push("resize")); +output._refreshSize(); + +const rl = createInterface({ input, output }); +report.terminal = rl.terminal; +rl.question("Name? ", (answer) => { + report.answer = answer; + report.rawDuring = input.isRaw; + rl.close(); + report.rawAfter = input.isRaw; + output.cursorTo(0); + output.clearLine(0); + output.write(`REPORT ${JSON.stringify(report)}\n`); + // Exit only once the runner acknowledges the report: a Linux pseudo-terminal can drop output + // still buffered when its last slave descriptor closes. + input.once("data", () => { + output.destroy(); + input.destroy(); + }); + input.resume(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty.py b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty.py new file mode 100644 index 000000000..163b7e781 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty-pty.py @@ -0,0 +1,59 @@ +"""Run a command on a pseudo-terminal of a fixed size with scripted interaction. + +usage: tty-pty.py ROWS COLS STEPS COMMAND... + +STEPS is a JSON list of {"expect": text} (wait until the output so far contains text) and +{"send": text} (write text to the terminal). The result is a JSON object on stdout with the +terminal output and the command's exit status. +""" +import json +import os +import pty +import sys + +rows, cols = int(sys.argv[1]), int(sys.argv[2]) +steps = json.loads(sys.argv[3]) +command = sys.argv[4:] + +master, slave = pty.openpty() +import fcntl, struct, termios # noqa: E401 + +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + +pid = os.fork() +if pid == 0: + os.close(master) + os.login_tty(slave) + os.execvp(command[0], command) + +os.close(slave) +output = b"" + + +def read_more(): + global output + try: + chunk = os.read(master, 65536) + except OSError: + return False + if not chunk: + return False + output += chunk + return True + + +for step in steps: + if "expect" in step: + while step["expect"].encode() not in output: + if not read_more(): + break + elif "send" in step: + os.write(master, step["send"].encode()) + +while read_more(): + pass + +_, status = os.waitpid(pid, 0) +sys.stdout.write( + json.dumps({"output": output.decode("utf-8", "replace"), "status": os.waitstatus_to_exitcode(status)}) +) diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty.ts new file mode 100644 index 000000000..84f933a95 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/tty.ts @@ -0,0 +1,149 @@ +import type { + TtyDirection, + TtyError, + TtyProvider, + TtyWindowSize, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/types.js"; + +/** Node's own `ERR_TTY_INIT_FAILED` for a descriptor that is not a terminal, as a WIT record. */ +export const NOT_A_TERMINAL: TtyError = { + name: "SystemError", + message: "TTY initialization failed: uv_tty_init returned EINVAL (invalid argument)", + code: "ERR_TTY_INIT_FAILED", + errno: { tag: "number", val: -22n }, + syscall: "uv_tty_init", + info: { + errno: { tag: "number", val: -22n }, + code: "EINVAL", + message: "invalid argument", + syscall: "uv_tty_init", + }, +}; + +export interface FakeTerminalOptions { + /** Descriptors that are terminals; everything else fails to open. Default: 0, 1 and 2. */ + terminals?: number[]; + /** The reported size, a function returning it, or `null` for a terminal without one. */ + size?: TtyWindowSize | (() => TtyWindowSize) | null; + /** Chunks delivered by successive reads; exhausted input is the end of input. */ + input?: Array; + environment?: Record; + /** Thrown by every `setRawMode`. */ + rawModeFailure?: TtyError; + /** Thrown by every `read`. */ + readFailure?: TtyError; + /** Thrown by every `write`. */ + writeFailure?: TtyError; +} + +export interface FakeTerminal { + host: TtyProvider; + /** Every provider call, in order, as `name(args)`. */ + calls: string[]; + /** Everything written, decoded as UTF-8. */ + output: string; + /** Raw mode per descriptor, as last set. */ + raw: Map; + /** Open handles per `direction:fd`, with acquisition counts. */ + handles: Map; +} + +/** A scripted terminal provider that records what the guest asked of it. */ +export function fakeTerminal(options: FakeTerminalOptions = {}): FakeTerminal { + const terminals = new Set(options.terminals ?? [0, 1, 2]); + const input = [...(options.input ?? [])]; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const calls: string[] = []; + const raw = new Map(); + const handles = new Map(); + const terminal: FakeTerminal = { + calls, + output: "", + raw, + handles, + host: { + isTty(fd) { + calls.push(`isTty(${fd})`); + return terminals.has(fd); + }, + open(fd, direction: TtyDirection) { + calls.push(`open(${fd}, ${direction})`); + if (!terminals.has(fd)) { + throw NOT_A_TERMINAL; + } + const key = `${direction}:${fd}`; + handles.set(key, (handles.get(key) ?? 0) + 1); + }, + close(fd, direction) { + calls.push(`close(${fd}, ${direction})`); + const key = `${direction}:${fd}`; + const count = (handles.get(key) ?? 0) - 1; + if (count > 0) { + handles.set(key, count); + } else { + handles.delete(key); + } + }, + windowSize(fd) { + calls.push(`windowSize(${fd})`); + const size = typeof options.size === "function" ? options.size() : options.size; + if (size === null) { + throw { + name: "Error", + message: "getWindowSize ENOTSUP: the terminal reports no size", + code: "ENOTSUP", + syscall: "getWindowSize", + } satisfies TtyError; + } + return size ?? { columns: 80, rows: 24 }; + }, + setRawMode(fd, enabled) { + calls.push(`setRawMode(${fd}, ${enabled})`); + if (options.rawModeFailure) { + throw options.rawModeFailure; + } + raw.set(fd, enabled); + }, + read(fd, maxBytes) { + calls.push(`read(${fd}, ${maxBytes})`); + if (options.readFailure) { + throw options.readFailure; + } + const chunk = input.shift(); + if (chunk === undefined) { + return new Uint8Array(0); + } + return typeof chunk === "string" ? encoder.encode(chunk) : chunk; + }, + write(fd, data) { + calls.push(`write(${fd}, ${data.byteLength})`); + if (options.writeFailure) { + throw options.writeFailure; + } + terminal.output += decoder.decode(data); + }, + environment() { + calls.push("environment()"); + return Object.entries(options.environment ?? {}); + }, + }, + }; + return terminal; +} + +export function errorOf(fn: () => unknown): Error & Record { + try { + fn(); + } catch (error) { + if (error instanceof Error) { + return error as Error & Record; + } + throw new Error(`Expected an Error, got ${String(error)}`); + } + throw new Error("Expected operation to fail"); +} + +export function nextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/get-color-depth.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/get-color-depth.ts new file mode 100644 index 000000000..4f1c52366 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/get-color-depth.ts @@ -0,0 +1,155 @@ +import nodeTty from "node:tty"; +import { describe, expect, test, vi } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import { createColorFunctions } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/colors.js"; +import { fakeTerminal } from "../helpers/tty.js"; +import { describeDifferential } from "../helpers/assert.js"; + +type Env = Record; + +/** Every branch of Node's `getColorDepth`, in the order the function tests them. */ +const environments: Env[] = [ + {}, + { FORCE_COLOR: "" }, + { FORCE_COLOR: "1" }, + { FORCE_COLOR: "true" }, + { FORCE_COLOR: "2" }, + { FORCE_COLOR: "3" }, + { FORCE_COLOR: "0" }, + { FORCE_COLOR: "false" }, + { FORCE_COLOR: "2", TERM: "dumb" }, + { NO_COLOR: "1" }, + { NO_COLOR: "", TERM: "xterm-256color" }, + { NODE_DISABLE_COLORS: "1", COLORTERM: "truecolor" }, + { NODE_DISABLE_COLORS: "", COLORTERM: "truecolor" }, + { TERM: "dumb" }, + { TMUX: "1" }, + { TMUX: "" }, + { TF_BUILD: "", AGENT_NAME: "" }, + { TF_BUILD: "" }, + { CI: "" }, + { CI: "true", GITHUB_ACTIONS: "true" }, + { CI: "true", GITEA_ACTIONS: "true" }, + { CI: "true", CIRCLECI: "true" }, + { CI: "true", TRAVIS: "true" }, + { CI: "true", APPVEYOR: "true" }, + { CI: "true", BUILDKITE: "true" }, + { CI: "true", DRONE: "true" }, + { CI: "true", GITLAB_CI: "true" }, + { CI: "true", CI_NAME: "codeship" }, + { CI: "true", CI_NAME: "other", COLORTERM: "truecolor" }, + { TEAMCITY_VERSION: "9.1.0" }, + { TEAMCITY_VERSION: "9.0.1" }, + { TEAMCITY_VERSION: "10.0" }, + { TEAMCITY_VERSION: "2020.1" }, + { TEAMCITY_VERSION: "" }, + { TERM_PROGRAM: "iTerm.app" }, + { TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "2.9" }, + { TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.1" }, + { TERM_PROGRAM: "HyperTerm" }, + { TERM_PROGRAM: "MacTerm" }, + { TERM_PROGRAM: "Apple_Terminal" }, + { TERM_PROGRAM: "vscode", TERM: "xterm" }, + { COLORTERM: "truecolor" }, + { COLORTERM: "24bit" }, + { COLORTERM: "yes" }, + { COLORTERM: "yes", TERM: "unknown" }, + { TERM: "xterm-truecolor" }, + { TERM: "xterm-256color" }, + { TERM: "xterm-256" }, + { TERM: "xterm" }, + { TERM: "XTERM-KITTY" }, + { TERM: "xterm-kitty" }, + { TERM: "konsole" }, + { TERM: "mosh" }, + { TERM: "rxvt-unicode-24bit" }, + { TERM: "rxvt-unicode" }, + { TERM: "terminator" }, + { TERM: "screen-256color" }, + { TERM: "screen" }, + { TERM: "linux" }, + { TERM: "vt100" }, + { TERM: "vt220" }, + { TERM: "con80x25" }, + { TERM: "cons25" }, + { TERM: "ansi" }, + { TERM: "foo-color" }, + { TERM: "foo-direct" }, + { TERM: "unknown" }, + { TERM: "" }, + { TERM: "", COLORTERM: "" }, +]; + +const oracle = nodeTty.WriteStream.prototype.getColorDepth; + +describeDifferential("tty.WriteStream#getColorDepth()", () => { + test.skipIf(process.platform === "win32")("matches Node for every environment branch", () => { + const tty = createTty(fakeTerminal().host); + const subject = tty.WriteStream.prototype.getColorDepth; + for (const env of environments) { + expect(subject.call(undefined, env), JSON.stringify(env)).toBe(oracle.call(undefined, env)); + } + }); + + test.skipIf(process.platform === "win32")( + "reads the provider's environment when none is given, once per call", + () => { + const terminal = fakeTerminal({ environment: { TERM: "xterm-256color", FORCE_COLOR: "" } }); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + expect(output.getColorDepth()).toBe(4); + expect(terminal.calls.filter((call) => call === "environment()")).toHaveLength(1); + expect(output.getColorDepth()).toBe(4); + expect(terminal.calls.filter((call) => call === "environment()")).toHaveLength(2); + // The host process's own environment answers the same as Node's default. + const native = fakeTerminal({ + environment: Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + }); + expect(createTty(native.host).WriteStream.prototype.getColorDepth.call(undefined)).toBe( + oracle.call(undefined), + ); + }, + ); +}); + +describe("tty.WriteStream#getColorDepth() warnings", () => { + test("warns once when FORCE_COLOR overrides a deactivation, through the process global", () => { + const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + try { + const { getColorDepth } = createColorFunctions(() => ({})); + expect(getColorDepth({ FORCE_COLOR: "1", NO_COLOR: "1" })).toBe(4); + expect(getColorDepth({ FORCE_COLOR: "3", NODE_DISABLE_COLORS: "1", NO_COLOR: "1" })).toBe(24); + expect(emitWarning).toHaveBeenCalledTimes(1); + expect(emitWarning).toHaveBeenCalledWith( + "The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.", + "Warning", + ); + const fresh = createColorFunctions(() => ({})); + fresh.getColorDepth({ FORCE_COLOR: "", NODE_DISABLE_COLORS: "1", NO_COLOR: "1" }); + expect(emitWarning).toHaveBeenLastCalledWith( + "The 'NODE_DISABLE_COLORS' and 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.", + "Warning", + ); + fresh.getColorDepth({ FORCE_COLOR: "1", NO_COLOR: "" }); + expect(emitWarning).toHaveBeenCalledTimes(2); + } finally { + emitWarning.mockRestore(); + } + }); + + test("never reads the provider when an environment is passed", () => { + const reads: number[] = []; + const { getColorDepth } = createColorFunctions(() => { + reads.push(1); + return { TERM: "dumb" }; + }); + expect(getColorDepth({ TERM: "xterm-256color" })).toBe(8); + expect(reads).toEqual([]); + expect(getColorDepth()).toBe(1); + expect(reads).toEqual([1]); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/has-colors.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/has-colors.ts new file mode 100644 index 000000000..0103de27c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/has-colors.ts @@ -0,0 +1,59 @@ +import nodeTty from "node:tty"; +import { expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import { errorOf, fakeTerminal } from "../helpers/tty.js"; +import { describeDifferential } from "../helpers/assert.js"; + +const oracle = nodeTty.WriteStream.prototype.hasColors; +const truecolor = { COLORTERM: "truecolor" }; +const dumb = { TERM: "dumb" }; + +describeDifferential("tty.WriteStream#hasColors()", () => { + test.skipIf(process.platform === "win32")("accepts every documented call shape like Node", () => { + const tty = createTty(fakeTerminal().host); + const subject = tty.WriteStream.prototype.hasColors; + const cases: unknown[][] = [ + [16, truecolor], + [256, truecolor], + [2 ** 24, truecolor], + [2 ** 24 + 1, truecolor], + [2, dumb], + [3, dumb], + [16, { TERM: "xterm" }], + [256, { TERM: "xterm" }], + [truecolor], + [dumb], + [{ TERM: "xterm-256color" }], + ]; + for (const args of cases) { + expect(Reflect.apply(subject, undefined, args), JSON.stringify(args)).toBe( + Reflect.apply(oracle, undefined, args), + ); + } + }); + + test("validates the count exactly as Node does", () => { + const tty = createTty(fakeTerminal().host); + const subject = tty.WriteStream.prototype.hasColors; + // 2 ** 53 is left out: Node prints the received value with numeric separators, which the + // shared ERR_OUT_OF_RANGE helper does not reproduce. + for (const count of [1, 0, -1, 1.5, "16", null, Number.NaN]) { + const actual = errorOf(() => Reflect.apply(subject, undefined, [count, truecolor])); + const expected = errorOf(() => Reflect.apply(oracle, undefined, [count, truecolor])); + expect(actual.code, String(count)).toBe(expected.code); + expect(actual.message, String(count)).toBe(expected.message); + expect(actual.constructor).toBe(expected.constructor); + } + }); +}); + +test("hasColors() defaults to 16 colors against the provider's environment", () => { + const terminal = fakeTerminal({ environment: { TERM: "xterm" } }); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + expect(output.hasColors()).toBe(true); + expect(output.hasColors(256)).toBe(false); + expect(output.hasColors({ COLORTERM: "truecolor" })).toBe(true); + expect(output.hasColors(2 ** 24)).toBe(false); + expect(terminal.calls.filter((call) => call === "environment()")).toHaveLength(3); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/host.ts new file mode 100644 index 000000000..4d83749cc --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/host.ts @@ -0,0 +1,137 @@ +import { closeSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import nodeTty from "node:tty"; +import { describe, expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import denyHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty-host.js"; +import nodeHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty-host-node.js"; +import { serializeTtyError } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/host-utils.js"; +import { errorOf } from "../helpers/tty.js"; + +function recordOf(fn: () => unknown): Record { + try { + fn(); + } catch (error) { + if (typeof error === "object" && error !== null && !(error instanceof Error)) { + return error as Record; + } + throw new Error(`Expected a serialized record, got ${String(error)}`); + } + throw new Error("Expected operation to fail"); +} + +/** A descriptor that is certainly not a terminal. */ +function fileDescriptor(): { fd: number; path: string } { + const path = join(tmpdir(), `jco-tty-${process.pid}-${Math.random().toString(16).slice(2)}`); + writeFileSync(path, ""); + return { fd: openSync(path, "r+"), path }; +} + +describe("deny-by-default terminal provider", () => { + test("refuses every operation with the adapter-required record", () => { + const operations: Array<[string, () => unknown]> = [ + ["isTty", () => denyHost.isTty(1)], + ["open", () => denyHost.open(1, "write")], + ["close", () => denyHost.close(1, "write")], + ["windowSize", () => denyHost.windowSize(1)], + ["setRawMode", () => denyHost.setRawMode(0, true)], + ["read", () => denyHost.read(0, 1)], + ["write", () => denyHost.write(1, new Uint8Array(1))], + ["environment", () => denyHost.environment()], + ]; + for (const [name, operation] of operations) { + expect(recordOf(operation), name).toEqual({ + name: "Error", + message: "node:tty requires an application-provided host adapter", + code: "ERR_JCO_TTY_ADAPTER_REQUIRED", + }); + } + }); + + test("surfaces as coded errors through the module", () => { + const tty = createTty(denyHost); + for (const operation of [ + () => tty.isatty(0), + () => new tty.ReadStream(0), + () => new tty.WriteStream(1), + () => tty.WriteStream.prototype.getColorDepth.call(undefined), + () => tty.WriteStream.prototype.hasColors.call(undefined), + ]) { + const error = errorOf(operation); + expect(error.code).toBe("ERR_JCO_TTY_ADAPTER_REQUIRED"); + expect(error).toBeInstanceOf(Error); + } + }); +}); + +describe("Node terminal provider", () => { + test("answers isatty for the embedding process's descriptors", () => { + const { fd } = fileDescriptor(); + try { + for (const candidate of [0, 1, 2, fd, 4096]) { + expect(nodeHost.isTty(candidate)).toBe(nodeTty.isatty(candidate)); + } + } finally { + closeSync(fd); + } + }); + + test("opening a descriptor that is not a terminal reproduces Node's own error", () => { + const { fd } = fileDescriptor(); + try { + const expected = errorOf(() => new nodeTty.WriteStream(fd)); + expect(recordOf(() => nodeHost.open(fd, "write"))).toEqual(serializeTtyError(expected)); + expect(recordOf(() => nodeHost.open(fd, "read"))).toEqual(serializeTtyError(expected)); + + const tty = createTty(nodeHost); + const actual = errorOf(() => new tty.WriteStream(fd)); + expect(actual.name).toBe(expected.name); + expect(actual.code).toBe("ERR_TTY_INIT_FAILED"); + expect(actual.message).toBe(expected.message); + expect(actual.errno).toBe(expected.errno); + expect(actual.syscall).toBe(expected.syscall); + expect(actual.info).toEqual(expected.info); + } finally { + closeSync(fd); + } + }); + + test("operations on descriptors that were never opened fail as bad descriptors", () => { + expect(recordOf(() => nodeHost.setRawMode(4096, true))).toMatchObject({ + code: "EBADF", + syscall: "setRawMode", + }); + expect(recordOf(() => nodeHost.windowSize(4096))).toMatchObject({ + code: "EBADF", + syscall: "getWindowSize", + }); + expect(nodeHost.close(4096, "write")).toBeUndefined(); + }); + + test("reads and writes the descriptor's bytes", () => { + const { fd, path } = fileDescriptor(); + const reader = openSync(path, "r"); + try { + nodeHost.write(fd, new TextEncoder().encode("héllo")); + expect(readFileSync(path, "utf8")).toBe("héllo"); + expect(new TextDecoder().decode(nodeHost.read(reader, 3))).toBe("hé"); + expect(new TextDecoder().decode(nodeHost.read(reader, 64 * 1024))).toBe("llo"); + // End of input is an empty read. + expect(nodeHost.read(reader, 16)).toEqual(new Uint8Array(0)); + expect(recordOf(() => nodeHost.read(4096, 1))).toMatchObject({ code: "EBADF" }); + expect(recordOf(() => nodeHost.write(4096, new Uint8Array(1)))).toMatchObject({ + code: "EBADF", + }); + } finally { + closeSync(fd); + closeSync(reader); + } + }); + + test("exposes the embedding process's environment", () => { + expect(nodeHost.environment()).toEqual( + Object.entries(process.env).filter((entry) => typeof entry[1] === "string"), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/isatty.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/isatty.ts new file mode 100644 index 000000000..afdbb9bca --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/isatty.ts @@ -0,0 +1,48 @@ +import nodeTty from "node:tty"; +import { describe, expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import denyHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty-host.js"; +import { errorOf, fakeTerminal } from "../helpers/tty.js"; + +const outOfRange: unknown[] = [ + -1, + "1", + 1.5, + 2147483648, + null, + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + {}, + [1], + true, + 0n, +]; + +describe("tty.isatty()", () => { + test("rejects everything Node rejects without consulting the provider", () => { + const terminal = fakeTerminal({ terminals: [0, 1, 2, 2147483647] }); + const tty = createTty(terminal.host); + for (const value of outOfRange) { + expect(tty.isatty(value as number), String(value)).toBe(nodeTty.isatty(value as number)); + } + expect(terminal.calls).toEqual([]); + }); + + test("asks the provider about every in-range descriptor", () => { + const terminal = fakeTerminal({ terminals: [0, 2147483647] }); + const tty = createTty(terminal.host); + expect(tty.isatty(0)).toBe(true); + expect(tty.isatty(1)).toBe(false); + expect(tty.isatty(2147483647)).toBe(true); + expect(terminal.calls).toEqual(["isTty(0)", "isTty(1)", "isTty(2147483647)"]); + }); + + test("is denied by default", () => { + const tty = createTty(denyHost); + expect(tty.isatty(-1)).toBe(false); + const error = errorOf(() => tty.isatty(1)); + expect(error.code).toBe("ERR_JCO_TTY_ADAPTER_REQUIRED"); + expect(error.message).toBe("node:tty requires an application-provided host adapter"); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/module.ts new file mode 100644 index 000000000..fb406c7fc --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/module.ts @@ -0,0 +1,83 @@ +import nodeTty from "node:tty"; +import { describe, expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import { + Duplex, + Readable, + Stream, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/stream/index.js"; +import { fakeTerminal } from "../helpers/tty.js"; + +function sortedKeys(value: object): string[] { + return Reflect.ownKeys(value).map(String).sort(); +} + +function descriptors(value: object): Record { + return Object.fromEntries( + Reflect.ownKeys(value).map((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return [ + String(key), + descriptor && { + enumerable: descriptor.enumerable, + writable: descriptor.writable, + configurable: descriptor.configurable, + kind: descriptor.get ? "accessor" : typeof descriptor.value, + }, + ]; + }), + ); +} + +describe("node:tty module", () => { + test("matches Node's export keys and descriptors", () => { + const tty = createTty(fakeTerminal().host); + expect(sortedKeys(tty)).toEqual(sortedKeys(nodeTty)); + expect(descriptors(tty)).toEqual(descriptors(nodeTty)); + }); + + test("matches Node's prototype members, names and lengths", () => { + const tty = createTty(fakeTerminal().host); + for (const name of ["ReadStream", "WriteStream"] as const) { + expect(sortedKeys(tty[name].prototype)).toEqual(sortedKeys(nodeTty[name].prototype)); + expect(descriptors(tty[name].prototype)).toEqual(descriptors(nodeTty[name].prototype)); + expect(tty[name].name).toBe(name); + expect(tty[name].length).toBe(nodeTty[name].length); + expect(tty[name].prototype.constructor).toBe(tty[name]); + } + expect(tty.WriteStream.prototype.isTTY).toBe(true); + expect(tty.isatty.length).toBe(nodeTty.isatty.length); + }); + + test("constructs with and without new as a Duplex stream", () => { + const tty = createTty(fakeTerminal().host); + const input = new tty.ReadStream(0); + const output = Reflect.apply(tty.WriteStream, undefined, [1]); + expect(input).toBeInstanceOf(tty.ReadStream); + expect(Reflect.apply(tty.ReadStream, undefined, [0])).toBeInstanceOf(tty.ReadStream); + expect(output).toBeInstanceOf(tty.WriteStream); + for (const stream of [input, output]) { + expect(stream).toBeInstanceOf(Duplex); + expect(stream).toBeInstanceOf(Readable); + expect(stream).toBeInstanceOf(Stream); + } + expect(input).not.toBeInstanceOf(tty.WriteStream); + // The socket stand-in sits between the public class and Duplex, as net.Socket does in Node. + expect(Object.getPrototypeOf(Object.getPrototypeOf(tty.WriteStream.prototype))).toBe( + Duplex.prototype, + ); + expect(Object.keys(input)).not.toContain("fd"); + expect(Object.keys(output)).not.toContain("fd"); + }); + + test("touches the provider only when asked", () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + expect(terminal.calls).toEqual([]); + expect(tty.isatty(-1)).toBe(false); + expect(tty.WriteStream.prototype.getColorDepth.call(undefined, { TERM: "dumb" })).toBe(1); + expect(terminal.calls).toEqual([]); + new tty.WriteStream(1); + expect(terminal.calls).toEqual(["open(1, write)", "windowSize(1)"]); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/pty.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/pty.ts new file mode 100644 index 000000000..c59bf4bac --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/pty.ts @@ -0,0 +1,58 @@ +// The Node provider and the built module on a real pseudo-terminal: descriptors 0-2 are a +// terminal of a known size, readline runs in terminal mode over the streams, raw mode is +// switched for the question and restored afterwards, and the answer arrives through a blocking +// read. Requires python3's pty module, so it is skipped where that is unavailable. +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import which from "which"; + +const exec = promisify(execFile); +const python = which.sync("python3", { nothrow: true }); +const helpers = new URL("../helpers/", import.meta.url); +const built = existsSync( + fileURLToPath( + new URL("../../../../../../dist/wasi/0.2.x/node/24.x.x/tty/core.js", import.meta.url), + ), +); + +test.skipIf(!python || !built || process.platform === "win32")( + "drives readline through real terminal streams on a pseudo-terminal", + async () => { + const steps = [{ expect: "Name? " }, { send: "Ada\r" }, { expect: "REPORT " }, { send: "\n" }]; + const { stdout } = await exec( + python as string, + [ + fileURLToPath(new URL("tty-pty.py", helpers)), + "40", + "120", + JSON.stringify(steps), + process.execPath, + fileURLToPath(new URL("tty-pty-child.mjs", helpers)), + ], + { timeout: 60_000 }, + ); + const { output, status } = JSON.parse(stdout) as { output: string; status: number }; + expect(status, output).toBe(0); + // readline's cursor sequences precede the line, so locate the marker rather than the line start. + const start = output.indexOf("REPORT "); + expect(start, output).toBeGreaterThan(-1); + const report = JSON.parse(output.slice(start + "REPORT ".length).split(/\r|\n/)[0]); + expect(report).toEqual({ + isatty: [true, true, true], + size: [120, 40], + hasColors: expect.any(Boolean), + depth: 8, + rawBefore: false, + terminal: true, + answer: "Ada", + rawDuring: true, + rawAfter: false, + events: [], + }); + expect(output).toContain("Name? "); + }, + 90_000, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/read-stream.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/read-stream.ts new file mode 100644 index 000000000..5b76d754f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/read-stream.ts @@ -0,0 +1,179 @@ +import nodeTty from "node:tty"; +import { describe, expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import { NOT_A_TERMINAL, errorOf, fakeTerminal, nextTick } from "../helpers/tty.js"; + +function once( + emitter: { once(event: string, listener: (value: T) => void): unknown }, + event: string, +): Promise { + return new Promise((resolve) => emitter.once(event, resolve)); +} + +describe("tty.ReadStream", () => { + test("rejects invalid descriptors exactly as Node does, before touching the provider", () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + for (const fd of [-1, 1.5, "1", undefined, null, 2 ** 31]) { + const actual = errorOf(() => new tty.ReadStream(fd as number)); + const expected = errorOf(() => new nodeTty.ReadStream(fd as number)); + expect(actual.code).toBe("ERR_INVALID_FD"); + expect(actual).toBeInstanceOf(RangeError); + expect(actual.message).toBe(expected.message); + expect(actual.code).toBe(expected.code); + } + expect(terminal.calls).toEqual([]); + }); + + test("fails to construct on a descriptor that is not a terminal", () => { + const terminal = fakeTerminal({ terminals: [0] }); + const tty = createTty(terminal.host); + const error = errorOf(() => new tty.ReadStream(7)); + expect(error.name).toBe("SystemError"); + expect(error.code).toBe("ERR_TTY_INIT_FAILED"); + expect(error.message).toBe(NOT_A_TERMINAL.message); + expect(error.errno).toBe(-22); + expect(error.syscall).toBe("uv_tty_init"); + expect(error.info).toEqual({ + errno: -22, + code: "EINVAL", + message: "invalid argument", + syscall: "uv_tty_init", + }); + expect(terminal.calls).toEqual(["open(7, read)"]); + expect(terminal.handles.size).toBe(0); + }); + + test("starts cooked, readable and not writable", () => { + const tty = createTty(fakeTerminal().host); + const input = new tty.ReadStream(0); + expect(Object.keys(input)).toEqual(expect.arrayContaining(["isRaw", "isTTY"])); + expect(input.isRaw).toBe(false); + expect(input.isTTY).toBe(true); + expect(input.readable).toBe(true); + expect(input.writable).toBe(false); + expect(Object.hasOwn(input, "setRawMode")).toBe(false); + }); + + test("setRawMode() switches the terminal through the provider and coerces its argument", () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + const input = new tty.ReadStream(0); + expect(input.setRawMode(true)).toBe(input); + expect(input.isRaw).toBe(true); + expect(terminal.raw.get(0)).toBe(true); + input.setRawMode("" as unknown as boolean); + expect(input.isRaw).toBe(false); + input.setRawMode(1 as unknown as boolean); + expect(input.isRaw).toBe(true); + expect(terminal.calls.slice(1)).toEqual([ + "setRawMode(0, true)", + "setRawMode(0, false)", + "setRawMode(0, true)", + ]); + }); + + test("setRawMode() failures are emitted as 'error' and leave isRaw alone", () => { + const failure = { + name: "Error", + message: "setRawMode ENOTTY: inappropriate ioctl for device", + code: "ENOTTY", + errno: { tag: "number" as const, val: -25n }, + syscall: "setRawMode", + }; + const tty = createTty(fakeTerminal({ rawModeFailure: failure }).host); + const input = new tty.ReadStream(0); + const errors: unknown[] = []; + input.on("error", (error) => errors.push(error)); + expect(input.setRawMode(true)).toBe(input); + expect(input.isRaw).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + message: failure.message, + code: "ENOTTY", + errno: -25, + syscall: "setRawMode", + }); + }); + + test("flows by pulling one chunk per read until the terminal ends", async () => { + const terminal = fakeTerminal({ input: ["ab", "cd", new Uint8Array([0x65])] }); + const tty = createTty(terminal.host); + const input = new tty.ReadStream(0); + const chunks: string[] = []; + const closed = once(input, "close"); + input.on("data", (chunk) => chunks.push(String(chunk))); + expect(terminal.calls.filter((call) => call.startsWith("read"))).toEqual([]); + await once(input, "end"); + expect(chunks).toEqual(["ab", "cd", "e"]); + expect(terminal.calls.filter((call) => call.startsWith("read"))).toEqual( + Array(4).fill("read(0, 65536)"), + ); + await closed; + expect(terminal.calls.at(-1)).toBe("close(0, read)"); + expect(terminal.handles.size).toBe(0); + }); + + test("pause() stops pulling after the current chunk", async () => { + const terminal = fakeTerminal({ input: ["one", "two", "three"] }); + const tty = createTty(terminal.host); + const input = new tty.ReadStream(0); + const chunks: string[] = []; + input.on("data", (chunk) => { + chunks.push(String(chunk)); + if (chunks.length < 3) { + input.pause(); + } + }); + await nextTick(); + expect(chunks).toEqual(["one"]); + expect(terminal.calls.filter((call) => call.startsWith("read"))).toHaveLength(1); + input.resume(); + await nextTick(); + expect(chunks).toEqual(["one", "two"]); + expect(terminal.calls.filter((call) => call.startsWith("read"))).toHaveLength(2); + const ended = once(input, "end"); + input.resume(); + await ended; + expect(chunks).toEqual(["one", "two", "three"]); + expect(terminal.calls.filter((call) => call.startsWith("read"))).toHaveLength(4); + }); + + test("honours stream options such as an encoding", async () => { + const tty = createTty(fakeTerminal({ input: ["héllo"] }).host); + const input = new tty.ReadStream(0, { encoding: "utf8" }); + const chunks: unknown[] = []; + input.on("data", (chunk) => chunks.push(chunk)); + await once(input, "end"); + expect(chunks).toEqual(["héllo"]); + }); + + test("a failing read destroys the stream with the provider's error", async () => { + const failure = { name: "Error", message: "read EIO: i/o error", code: "EIO", syscall: "read" }; + const terminal = fakeTerminal({ readFailure: failure }); + const tty = createTty(terminal.host); + const input = new tty.ReadStream(0); + const closed = once(input, "close"); + input.resume(); + const error = await once(input, "error"); + expect(error.message).toBe(failure.message); + expect(error.code).toBe("EIO"); + await closed; + expect(input.destroyed).toBe(true); + expect(terminal.handles.size).toBe(0); + }); + + test("destroy() releases the terminal handle", async () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + const first = new tty.ReadStream(0); + const second = new tty.ReadStream(0); + expect(terminal.handles.get("read:0")).toBe(2); + first.destroy(); + await once(first, "close"); + expect(terminal.handles.get("read:0")).toBe(1); + second.destroy(); + await once(second, "close"); + expect(terminal.handles.size).toBe(0); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/write-stream.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/write-stream.ts new file mode 100644 index 000000000..a5e2fed99 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tty/write-stream.ts @@ -0,0 +1,191 @@ +import nodeTty from "node:tty"; +import { describe, expect, test } from "vitest"; +import { createTty } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tty/core.js"; +import { errorOf, fakeTerminal, nextTick } from "../helpers/tty.js"; + +function once( + emitter: { once(event: string, listener: (value: T) => void): unknown }, + event: string, +): Promise { + return new Promise((resolve) => emitter.once(event, resolve)); +} + +describe("tty.WriteStream", () => { + test("rejects invalid descriptors exactly as Node does, before touching the provider", () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + for (const fd of [-1, 1.5, "1", undefined]) { + const actual = errorOf(() => new tty.WriteStream(fd as number)); + const expected = errorOf(() => new nodeTty.WriteStream(fd as number)); + expect(actual).toBeInstanceOf(RangeError); + expect(actual.code).toBe(expected.code); + expect(actual.message).toBe(expected.message); + } + expect(terminal.calls).toEqual([]); + }); + + test("fails to construct on a descriptor that is not a terminal", () => { + const terminal = fakeTerminal({ terminals: [] }); + const tty = createTty(terminal.host); + const error = errorOf(() => new tty.WriteStream(1)); + expect(error.code).toBe("ERR_TTY_INIT_FAILED"); + expect(error.name).toBe("SystemError"); + expect(terminal.calls).toEqual(["open(1, write)"]); + }); + + test("reports the terminal size when the provider has one", () => { + const tty = createTty(fakeTerminal({ size: { columns: 132, rows: 43 } }).host); + const output = new tty.WriteStream(1); + expect(Object.keys(output)).toEqual(expect.arrayContaining(["columns", "rows"])); + expect(output.columns).toBe(132); + expect(output.rows).toBe(43); + expect(output.getWindowSize()).toEqual([132, 43]); + expect(output.isTTY).toBe(true); + expect(Object.hasOwn(output, "isTTY")).toBe(false); + expect(output.writable).toBe(true); + expect(output.readable).toBe(false); + }); + + test("leaves the size absent when the terminal reports none, as Node does", () => { + const terminal = fakeTerminal({ size: null }); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(2); + expect("columns" in output).toBe(false); + expect("rows" in output).toBe(false); + expect(output.getWindowSize()).toEqual([undefined, undefined]); + expect(terminal.calls).toEqual(["open(2, write)", "windowSize(2)"]); + }); + + test("writes strings and bytes through the provider", async () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + const callbacks: unknown[] = []; + expect(output.write("héllo ", (error) => callbacks.push(error))).toBe(true); + output.write(new TextEncoder().encode("wörld")); + output.write(new Uint16Array([0x2021])); + await nextTick(); + expect(terminal.output).toBe("héllo wörld! "); + expect(callbacks).toEqual([undefined]); + expect(terminal.calls.filter((call) => call.startsWith("write"))).toEqual([ + "write(1, 7)", + "write(1, 6)", + "write(1, 2)", + ]); + }); + + test("end() finishes and releases the handle", async () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + output.end("bye\n"); + await once(output, "close"); + expect(terminal.output).toBe("bye\n"); + expect(terminal.calls.at(-1)).toBe("close(1, write)"); + expect(terminal.handles.size).toBe(0); + }); + + test("a failing write errors the stream with the provider's error", async () => { + const failure = { + name: "Error", + message: "write EPIPE: broken pipe", + code: "EPIPE", + syscall: "write", + }; + const tty = createTty(fakeTerminal({ writeFailure: failure }).host); + const output = new tty.WriteStream(1); + const callbacks: unknown[] = []; + output.write("x", (error) => callbacks.push(error)); + const error = await once(output, "error"); + expect(error.code).toBe("EPIPE"); + expect(error.message).toBe(failure.message); + expect(callbacks).toEqual([error]); + }); + + test("_refreshSize() re-queries the provider and emits 'resize' on change", () => { + let size = { columns: 80, rows: 24 }; + const terminal = fakeTerminal({ size: () => size }); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + const events: string[] = []; + output.on("resize", () => events.push("resize")); + output._refreshSize(); + expect(events).toEqual([]); + size = { columns: 100, rows: 24 }; + output._refreshSize(); + expect(events).toEqual(["resize"]); + expect(output.getWindowSize()).toEqual([100, 24]); + expect(terminal.calls.filter((call) => call.startsWith("windowSize"))).toHaveLength(3); + }); + + test("_refreshSize() emits the provider's error when the size is unavailable", () => { + let available = true; + const tty = createTty( + fakeTerminal({ + size: () => { + if (!available) { + throw { + name: "Error", + message: "getWindowSize EBADF: bad file descriptor", + code: "EBADF", + syscall: "getWindowSize", + }; + } + return { columns: 1, rows: 1 }; + }, + }).host, + ); + const output = new tty.WriteStream(1); + const errors: Array = []; + output.on("error", (error) => errors.push(error as Error & { code?: string })); + available = false; + output._refreshSize(); + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("EBADF"); + expect(errors[0].syscall).toBe("getWindowSize"); + expect(output.getWindowSize()).toEqual([1, 1]); + }); + + test("cursor helpers write the same sequences as Node and call back", async () => { + const terminal = fakeTerminal(); + const tty = createTty(terminal.host); + const output = new tty.WriteStream(1); + const expected: string[] = []; + const oracle = { write: (data: string) => expected.push(data) > 0 }; + const callbacks: unknown[] = []; + const callback = (error?: Error | null): void => { + callbacks.push(error); + }; + const results = [ + output.cursorTo(2), + output.cursorTo(1, 3), + output.moveCursor(-2, 3), + output.clearLine(-1), + output.clearLine(1), + output.clearLine(0), + output.clearScreenDown(), + output.cursorTo(4, callback), + output.moveCursor(0, 0, callback), + output.clearLine(0, callback), + output.clearScreenDown(callback), + ]; + const proto = nodeTty.WriteStream.prototype; + proto.cursorTo.call(oracle, 2); + proto.cursorTo.call(oracle, 1, 3); + proto.moveCursor.call(oracle, -2, 3); + proto.clearLine.call(oracle, -1); + proto.clearLine.call(oracle, 1); + proto.clearLine.call(oracle, 0); + proto.clearScreenDown.call(oracle); + proto.cursorTo.call(oracle, 4); + proto.moveCursor.call(oracle, 0, 0); + proto.clearLine.call(oracle, 0); + proto.clearScreenDown.call(oracle); + await nextTick(); + expect(terminal.output).toBe(expected.join("")); + expect(results.every((result) => result === true)).toBe(true); + // Each callback fires once without an error, whether the sequence was written or skipped. + expect(callbacks).toHaveLength(4); + expect(callbacks.every((error) => error == null)).toBe(true); + }); +}); From 2a1e09f9501f5a7317c53b915b36b97cd18d48dc Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 13:28:16 +0000 Subject: [PATCH 3/5] feat(jco): resolve node:tty through the builtin plugin --- .../lib/wit/builtin/jco-node-0.1.0/tty.wit | 59 +++++++++++++++++++ packages/jco/src/cmd/transpile.ts | 1 + packages/jco/src/node-builtins/index.ts | 2 + packages/jco/src/node-builtins/tty.ts | 17 ++++++ packages/jco/src/node-builtins/types.ts | 2 + packages/jco/src/node-wit.ts | 2 + 6 files changed, 83 insertions(+) create mode 100644 packages/jco/lib/wit/builtin/jco-node-0.1.0/tty.wit create mode 100644 packages/jco/src/node-builtins/tty.ts diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/tty.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/tty.wit new file mode 100644 index 000000000..a0c3afcb1 --- /dev/null +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/tty.wit @@ -0,0 +1,59 @@ +package jco:node@0.1.0; + +/// Terminal access for `node:tty`. No capability is granted by default; the Node provider exposes +/// the terminals of the embedding process, addressed by file descriptor as Node does. +interface tty { + use types.{env-vars}; + + variant errno { + number(s64), + symbolic(string), + } + + /// The libuv failure Node attaches to `ERR_TTY_INIT_FAILED` as `info`. + record error-info { + errno: option, + code: option, + message: option, + syscall: option, + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + info: option, + } + + /// Which side of a terminal a stream uses. + enum direction { + read, + write, + } + + record terminal-size { + columns: u32, + rows: u32, + } + + /// Whether `fd` refers to a terminal. The guest range-checks the descriptor first. + is-tty: func(fd: u32) -> result; + /// Acquire a terminal handle on `fd` for one direction. Fails the way Node's `uv_tty_init` + /// does when `fd` is not a terminal; a handle may be acquired more than once. + open: func(fd: u32, direction: direction) -> result<_, error>; + /// Release one acquisition of a handle. Raw mode is restored when the last read handle goes. + close: func(fd: u32, direction: direction) -> result<_, error>; + /// The terminal's current size; fails when the terminal does not report one. + window-size: func(fd: u32) -> result; + /// Switch the terminal's input between cooked and raw mode. + set-raw-mode: func(fd: u32, enabled: bool) -> result<_, error>; + /// Block until input is available and return up to `max-bytes` of it; an empty list is the + /// end of input. + read: func(fd: u32, max-bytes: u32) -> result, error>; + /// Write all of `data` before returning. + write: func(fd: u32, data: list) -> result<_, error>; + /// The environment `getColorDepth()` and `hasColors()` consult when none is passed. + environment: func() -> result; +} diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index 1f6c22d73..813ebcd30 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -54,6 +54,7 @@ const DEFAULT_NODE_CAPABILITY_MAP = { "jco:node/fs@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs/host", "jco:node/process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host", "jco:node/os@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host", + "jco:node/tty@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", "jco:node/http@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host", "jco:node/inspector@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host", diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 693442abc..073edeaf9 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -16,6 +16,7 @@ import { createSqliteBuiltin } from "./sqlite.js"; import { createReadlineBuiltin } from "./readline.js"; import { createReplBuiltin } from "./repl.js"; import { createStringDecoderBuiltin } from "./string-decoder.js"; +import { createTtyBuiltin } from "./tty.js"; import { createStreamBuiltin } from "./stream.js"; import { createClusterBuiltin } from "./cluster.js"; import { createChildProcessBuiltin } from "./child-process.js"; @@ -68,6 +69,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createReadlineBuiltin, createReplBuiltin, createStringDecoderBuiltin, + createTtyBuiltin, createStreamBuiltin, createClusterBuiltin, createChildProcessBuiltin, diff --git a/packages/jco/src/node-builtins/tty.ts b/packages/jco/src/node-builtins/tty.ts new file mode 100644 index 000000000..65bf28060 --- /dev/null +++ b/packages/jco/src/node-builtins/tty.ts @@ -0,0 +1,17 @@ +import { starReexportAdapter, type BuiltinContext, type BuiltinAdapter, builtin, stdModule } from "./shared.js"; +import { TTY_WIT_REQUIREMENT } from "../node-wit.js"; + +const TTY_SPECIFIER = "node:tty"; + +/** + * `node:tty` addresses the host process's terminals by descriptor, which no WASI interface + * expresses, so the jco-std module imports `jco:node/tty@0.1.0` and the world gains that import. + * The capability is denied until the application maps a provider at transpile time. + */ +export function createTtyBuiltin({ options }: BuiltinContext): BuiltinAdapter { + return builtin( + TTY_SPECIFIER, + () => starReexportAdapter(stdModule(options.ttyModule, "tty"), "tty"), + () => options.onWitRequirement?.(TTY_WIT_REQUIREMENT), + ); +} diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index 87283e7c9..6913ca19b 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -59,6 +59,8 @@ export interface NodeBuiltinOptions { processModule?: string; /** Path to jco-std's versioned `node:string_decoder` module (overridable for tests) */ stringDecoderModule?: string; + /** Path to jco-std's versioned `node:tty` module (overridable for tests) */ + ttyModule?: string; /** Paths to jco-std's capability-free readline modules (overridable for tests). */ readlineModule?: string; readlinePromisesModule?: string; diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 3eb871faa..ff791403e 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -90,6 +90,8 @@ export const SQLITE_WIT_REQUIREMENT = nodeRequirement("node:sqlite", "sqlite"); export const OS_WIT_REQUIREMENT = nodeRequirement("node:os", "os"); +export const TTY_WIT_REQUIREMENT = nodeRequirement("node:tty", "tty", { sharedTypes: true }); + export const FFI_WIT_REQUIREMENT = nodeRequirement("node:ffi", "ffi"); export const INSPECTOR_WIT_REQUIREMENT = nodeRequirement("node:inspector", "inspector", { From 55153b1f4d27020ac188284df1d46132db6b3557 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 13:28:16 +0000 Subject: [PATCH 4/5] test(jco): add node:tty plugin and guest tests --- .../componentize/node-tty/component.js | 99 +++++++++ .../componentize/node-tty/provider.js | 66 ++++++ .../fixtures/componentize/node-tty/run.js | 56 +++++ .../fixtures/componentize/node-tty/tty-pty.py | 59 ++++++ .../componentize/node-tty/wit/component.wit | 5 + packages/jco/test/node/builtins.js | 22 ++ packages/jco/test/node/tty.js | 196 ++++++++++++++++++ 7 files changed, 503 insertions(+) create mode 100644 packages/jco/test/fixtures/componentize/node-tty/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-tty/provider.js create mode 100644 packages/jco/test/fixtures/componentize/node-tty/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-tty/tty-pty.py create mode 100644 packages/jco/test/fixtures/componentize/node-tty/wit/component.wit create mode 100644 packages/jco/test/node/tty.js diff --git a/packages/jco/test/fixtures/componentize/node-tty/component.js b/packages/jco/test/fixtures/componentize/node-tty/component.js new file mode 100644 index 000000000..303f14926 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tty/component.js @@ -0,0 +1,99 @@ +import tty, { ReadStream, WriteStream, isatty } from "node:tty"; +import { createInterface } from "node:readline"; + +/** A descriptor no process has open, so it is never a terminal. */ +const CLOSED_FD = 4096; + +function failure(fn) { + try { + fn(); + return null; + } catch (error) { + return { + name: error.name, + code: error.code, + message: error.message, + errno: error.errno, + syscall: error.syscall, + info: error.info ?? null, + rangeError: error instanceof RangeError, + }; + } +} + +function chain(value) { + const names = []; + for ( + let proto = Object.getPrototypeOf(value); + proto && proto !== Object.prototype; + proto = Object.getPrototypeOf(proto) + ) { + names.push(proto.constructor.name); + } + return names; +} + +export function run(mode) { + const report = { + identity: tty.isatty === isatty && tty.ReadStream === ReadStream && tty.WriteStream === WriteStream, + // Out-of-range descriptors are rejected in the guest without consulting the host. + outOfRange: [-1, 1.5, "1", 2147483648, null].map((fd) => isatty(fd)), + invalidFd: failure(() => new WriteStream(-1)), + }; + if (mode === "denied") { + report.denied = [ + failure(() => isatty(1)), + failure(() => new ReadStream(0)), + failure(() => new WriteStream(1)), + failure(() => WriteStream.prototype.getColorDepth.call(undefined)), + ].map((error) => error?.code); + return JSON.stringify(report); + } + report.isatty = [0, 1, 2, CLOSED_FD].map((fd) => isatty(fd)); + report.notATerminal = failure(() => new WriteStream(CLOSED_FD)); + if (!isatty(1)) { + // Without a terminal the constructors fail the way Node's do on the same descriptor. + report.init = failure(() => new WriteStream(1)); + return JSON.stringify(report); + } + + const output = new WriteStream(1); + const input = new ReadStream(0); + report.size = output.getWindowSize(); + report.isTTY = [input.isTTY, output.isTTY, input.isRaw]; + report.depth = output.getColorDepth({ TERM: "xterm-256color" }); + report.hasColors = [ + output.hasColors(16, { TERM: "xterm" }), + output.hasColors(256, { TERM: "xterm" }), + output.hasColors({ COLORTERM: "truecolor" }), + ]; + report.chain = chain(output); + const events = []; + output.on("resize", () => events.push("resize")); + output._refreshSize(); + output.cursorTo(0); + output.clearLine(0); + output.write("Hello "); + + const rl = createInterface({ input, output }); + report.terminal = rl.terminal; + report.rawDuringQuestion = input.isRaw; + rl.question("Name? ", (answer) => { + report.answer = answer; + rl.close(); + report.rawAfter = input.isRaw; + }); + // A synchronous export cannot wait for the stream scheduler to start flowing, so pull the + // terminal directly: each read blocks until input arrives and emits 'data' before returning. + while (report.answer === undefined && input.read() !== null) { + // keep pulling + } + report.events = events; + output.write(`REPORT ${JSON.stringify(report)}\n`); + // Wait for the runner's acknowledgement before releasing the terminal, so nothing written + // above is lost when a pseudo-terminal's last slave descriptor closes. + input.read(); + input.destroy(); + output.destroy(); + return JSON.stringify(report); +} diff --git a/packages/jco/test/fixtures/componentize/node-tty/provider.js b/packages/jco/test/fixtures/componentize/node-tty/provider.js new file mode 100644 index 000000000..79fe2e00e --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tty/provider.js @@ -0,0 +1,66 @@ +// @ts-check +/** + * A scripted terminal provider for `jco:node/tty@0.1.0`: descriptors 0-2 are a terminal of a + * fixed size, reads deliver the scripted chunks, and everything written is captured. + * + * @param {{ input?: string[]; columns?: number; rows?: number }} [options] + */ +export function createTtyHost({ input = [], columns = 100, rows = 30 } = {}) { + const pending = [...input]; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const state = { output: "", raw: /** @type {boolean[]} */ ([]), calls: /** @type {string[]} */ ([]) }; + const notATerminal = () => { + throw { + name: "SystemError", + message: "TTY initialization failed: uv_tty_init returned EINVAL (invalid argument)", + code: "ERR_TTY_INIT_FAILED", + errno: { tag: "number", val: -22n }, + syscall: "uv_tty_init", + info: { + errno: { tag: "number", val: -22n }, + code: "EINVAL", + message: "invalid argument", + syscall: "uv_tty_init", + }, + }; + }; + /** @satisfies {import("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty").TtyProvider} */ + const host = { + isTty(fd) { + state.calls.push(`isTty ${fd}`); + return fd <= 2; + }, + open(fd, direction) { + state.calls.push(`open ${fd} ${direction}`); + if (fd > 2) { + notATerminal(); + } + }, + close(fd, direction) { + state.calls.push(`close ${fd} ${direction}`); + }, + windowSize(fd) { + state.calls.push(`windowSize ${fd}`); + return { columns, rows }; + }, + setRawMode(fd, enabled) { + state.calls.push(`setRawMode ${fd} ${enabled}`); + state.raw.push(enabled); + }, + read(fd, maxBytes) { + state.calls.push(`read ${fd} ${maxBytes}`); + const chunk = pending.shift(); + return chunk === undefined ? new Uint8Array(0) : encoder.encode(chunk); + }, + write(fd, data) { + state.calls.push(`write ${fd} ${data.byteLength}`); + state.output += decoder.decode(data); + }, + environment() { + state.calls.push("environment"); + return [["TERM", "xterm-256color"]]; + }, + }; + return { host, state }; +} diff --git a/packages/jco/test/fixtures/componentize/node-tty/run.js b/packages/jco/test/fixtures/componentize/node-tty/run.js new file mode 100644 index 000000000..7f89ec6d1 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tty/run.js @@ -0,0 +1,56 @@ +import nativeProcess from "node:process"; +import nodeTty from "node:tty"; +import { pathToFileURL } from "node:url"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { createTtyHost } from "./provider.js"; + +const [modulePath, mode] = nativeProcess.argv.slice(2); +const { instantiate } = await import(pathToFileURL(modulePath)); + +// The default transpile mapping names jco-std's deny host; instantiation supplies the module +// under that name, so the same component runs against every provider here. +const DENY_SPECIFIER = "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host"; +// The sibling workspace build, so the runner needs no published jco-std release. +const std = new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/", import.meta.url); + +function failure(fn) { + try { + fn(); + return null; + } catch (error) { + return { + name: error.name, + code: error.code, + message: error.message, + errno: error.errno, + syscall: error.syscall, + info: error.info ?? null, + rangeError: error instanceof RangeError, + }; + } +} + +let host; +let fake; +if (mode === "node") { + host = await import(new URL("tty-host-node.js", std)); +} else if (mode === "denied") { + host = await import(new URL("tty-host.js", std)); +} else { + fake = createTtyHost({ input: ["Ada\r", "\n"] }); + host = fake.host; +} + +const instance = await instantiate(undefined, { ...new WASIShim().getImportObject(), [DENY_SPECIFIER]: host }); +const guest = JSON.parse(instance.run(mode)); +const result = { + guest, + native: { + isatty: [0, 1, 2, 4096].map((fd) => nodeTty.isatty(fd)), + notATerminal: failure(() => new nodeTty.WriteStream(4096)), + init: failure(() => new nodeTty.WriteStream(1)), + invalidFd: failure(() => new nodeTty.WriteStream(-1)), + }, + terminal: fake?.state, +}; +nativeProcess.stdout.write(`RESULT ${JSON.stringify(result)}\n`); diff --git a/packages/jco/test/fixtures/componentize/node-tty/tty-pty.py b/packages/jco/test/fixtures/componentize/node-tty/tty-pty.py new file mode 100644 index 000000000..163b7e781 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tty/tty-pty.py @@ -0,0 +1,59 @@ +"""Run a command on a pseudo-terminal of a fixed size with scripted interaction. + +usage: tty-pty.py ROWS COLS STEPS COMMAND... + +STEPS is a JSON list of {"expect": text} (wait until the output so far contains text) and +{"send": text} (write text to the terminal). The result is a JSON object on stdout with the +terminal output and the command's exit status. +""" +import json +import os +import pty +import sys + +rows, cols = int(sys.argv[1]), int(sys.argv[2]) +steps = json.loads(sys.argv[3]) +command = sys.argv[4:] + +master, slave = pty.openpty() +import fcntl, struct, termios # noqa: E401 + +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + +pid = os.fork() +if pid == 0: + os.close(master) + os.login_tty(slave) + os.execvp(command[0], command) + +os.close(slave) +output = b"" + + +def read_more(): + global output + try: + chunk = os.read(master, 65536) + except OSError: + return False + if not chunk: + return False + output += chunk + return True + + +for step in steps: + if "expect" in step: + while step["expect"].encode() not in output: + if not read_more(): + break + elif "send" in step: + os.write(master, step["send"].encode()) + +while read_more(): + pass + +_, status = os.waitpid(pid, 0) +sys.stdout.write( + json.dumps({"output": output.decode("utf-8", "replace"), "status": os.waitstatus_to_exitcode(status)}) +) diff --git a/packages/jco/test/fixtures/componentize/node-tty/wit/component.wit b/packages/jco/test/fixtures/componentize/node-tty/wit/component.wit new file mode 100644 index 000000000..aec90ff81 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tty/wit/component.wit @@ -0,0 +1,5 @@ +package jco-fixtures:node-tty; + +world component { + export run: func(mode: string) -> string; +} diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 5eab2838a..848d0a0ce 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -50,6 +50,7 @@ describe("Node builtin adapters", () => { "jco:node/http2@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host", "jco:node/process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host", "jco:node/os@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host", + "jco:node/tty@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host", }); expect( withDefaultNodeCapabilityMap({ @@ -72,6 +73,7 @@ describe("Node builtin adapters", () => { "jco:node/http2@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host", "jco:node/process@0.1.0": "/application/process-host.js", "jco:node/os@0.1.0": "/application/os-host.js", + "jco:node/tty@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host", }); }); @@ -238,6 +240,26 @@ describe("Node builtin adapters", () => { expect(plugin.resolveId("readline/promises")).toBeNull(); }); + test.concurrent("generates a host-backed adapter for node:tty and reports its WIT requirement", () => { + const requirements = []; + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { ttyModule: "test:tty", onWitRequirement: (requirement) => requirements.push(requirement) }, + ); + const id = plugin.resolveId("node:tty"); + expect(id).toBe("\0jco-node-builtin:node:tty"); + expect(requirements).toEqual([nodeWit.TTY_WIT_REQUIREMENT]); + expect(nodeWit.TTY_WIT_REQUIREMENT.dependencySources.map((source) => source.split(/[\\/]/).at(-1))).toEqual([ + "types.wit", + "tty.wit", + ]); + const source = plugin.load(id); + expect(source).toContain('from "test:tty"'); + expect(source).toContain("export default tty"); + expect(source).toContain('export * from "test:tty"'); + expect(plugin.resolveId("tty")).toBeNull(); + }); + test.concurrent("generates a capability-free adapter for node:repl", () => { const requirements = []; const plugin = nodeBuiltinPlugin( diff --git a/packages/jco/test/node/tty.js b/packages/jco/test/node/tty.js new file mode 100644 index 000000000..23e0e920f --- /dev/null +++ b/packages/jco/test/node/tty.js @@ -0,0 +1,196 @@ +// End-to-end coverage for `node:tty`: the world gains `jco:node/tty@0.1.0`, the component runs +// against the deny host, a scripted provider, and jco-std's Node provider -- the last both +// without a terminal (where it fails exactly as Node does) and on a real pseudo-terminal. +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { assert, expect, suite, test } from "vitest"; +import which from "which"; +import { worldMetadataFor } from "../../src/cmd/componentize.js"; +import { TTY_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; +import { componentizeFixture, getTmpDir, setupAsyncTest } from "../helpers.js"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/componentize/node-tty/", import.meta.url)); +const python = which.sync("python3", { nothrow: true }); + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => (stdout += data)); + child.stderr.on("data", (data) => (stderr += data)); + child.on("error", reject); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +/** Parse the `RESULT` line the fixture runner prints, wherever it sits in the captured output. */ +function resultOf(output) { + const start = output.indexOf("RESULT "); + assert.notEqual(start, -1, output); + return JSON.parse(output.slice(start + "RESULT ".length).split(/\r|\n/)[0]); +} + +suite("node:tty", () => { + test.concurrent("installs the typed tty WIT dependency and its shared types idempotently", async () => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:tty;\nworld component {}\n"); + + const result = await injectNodeWitImports(root, undefined, [TTY_WIT_REQUIREMENT]); + expect(result?.imports).toEqual(["jco:node/tty@0.1.0"]); + for (const name of ["types.wit", "tty.wit"]) { + expect(await readFile(join(root, "deps/jco-node-0.1.0", name), "utf8")).toEqual( + await readFile(new URL(`../../../jco-std/wit/node-0.1.0/${name}`, import.meta.url), "utf8"), + ); + } + const ttyWit = await readFile(join(root, "deps/jco-node-0.1.0/tty.wit"), "utf8"); + expect(ttyWit).toContain("interface tty"); + expect(ttyWit).toContain("use types.{env-vars};"); + expect(ttyWit).toContain("set-raw-mode: func("); + // The injected package must parse: WIT shares one namespace per interface. + const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "jco", package: "node", interface: "tty" }), + ); + expect(await injectNodeWitImports(root, undefined, [TTY_WIT_REQUIREMENT])).toBeUndefined(); + expect((await readFile(world, "utf8")).match(/import jco:node\/tty@0\.1\.0;/g)).toHaveLength(1); + }); + + // TODO(unskip): publish and depend on a jco-std release with the `wasi/0.2.x/node/24.x.x/tty` + // export; the workspace copy has it, the published 0.3.x does not, and jco componentizes + // against the published package. + test.skip.each(["quickjs", "starlingmonkey"])( + "componentizes and runs against the deny, scripted, and Node terminal providers (%s)", + async (backend) => { + const { componentPath, fixtureDir, stderr } = await componentizeFixture({ + fixture: "node-tty", + bundle: true, + copy: true, + extraArgs: ["--backend", backend], + }); + assert.include(stderr, "Jco added generated WIT import jco:node/tty@0.1.0"); + assert.include(await readFile(join(fixtureDir, "wit/component.wit"), "utf8"), "import jco:node/tty@0.1.0;"); + + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: `node-tty-${backend}`, path: componentPath, skipInstantiation: true }, + }); + const runner = join(FIXTURE, "run.js"); + try { + const denied = await run(process.execPath, [runner, esModuleOutputPath, "denied"]); + expect(denied.status, denied.stderr).toBe(0); + const { guest: deniedGuest } = resultOf(denied.stdout); + expect(deniedGuest.identity).toBe(true); + expect(deniedGuest.outOfRange).toEqual([false, false, false, false, false]); + expect(deniedGuest.invalidFd).toMatchObject({ code: "ERR_INVALID_FD", rangeError: true }); + expect(deniedGuest.denied).toEqual(Array(4).fill("ERR_JCO_TTY_ADAPTER_REQUIRED")); + + const scripted = await run(process.execPath, [runner, esModuleOutputPath, "scripted"]); + expect(scripted.status, scripted.stderr).toBe(0); + const { guest, terminal } = resultOf(scripted.stdout); + // The bundled EventEmitter core names its class `_EventEmitter`; everything above + // it in the chain is tty's own. + expect(guest.chain.slice(0, 5)).toEqual([ + "WriteStream", + "TerminalOutput", + "Duplex", + "Readable", + "Stream", + ]); + expect(guest.chain[5]).toMatch(/EventEmitter$/); + expect({ ...guest, chain: undefined }).toEqual({ + identity: true, + outOfRange: [false, false, false, false, false], + invalidFd: { + name: "RangeError", + code: "ERR_INVALID_FD", + message: '"fd" must be a positive integer: -1', + rangeError: true, + info: null, + }, + isatty: [true, true, true, false], + notATerminal: { + name: "SystemError", + code: "ERR_TTY_INIT_FAILED", + message: "TTY initialization failed: uv_tty_init returned EINVAL (invalid argument)", + errno: -22, + syscall: "uv_tty_init", + info: { errno: -22, code: "EINVAL", message: "invalid argument", syscall: "uv_tty_init" }, + rangeError: false, + }, + size: [100, 30], + isTTY: [true, true, false], + depth: 8, + hasColors: [true, false, true], + chain: undefined, + terminal: true, + rawDuringQuestion: true, + answer: "Ada", + rawAfter: false, + events: [], + }); + expect(terminal.raw).toEqual([true, false]); + expect(terminal.output).toContain("\x1b[1G\x1b[2KHello "); + expect(terminal.output).toContain("Name? "); + expect(terminal.output).toContain("Ada"); + expect(terminal.output).toContain(`REPORT ${JSON.stringify(guest)}\n`); + expect(terminal.calls).toContain("read 0 65536"); + expect(terminal.calls.slice(-2)).toEqual(["close 0 read", "close 1 write"]); + + // Without a terminal the Node provider answers exactly as Node does on the same + // descriptors: libuv accepts a pipe as a tty handle but refuses a file or a closed + // descriptor, so the outcomes are compared rather than assumed. + const plain = await run(process.execPath, [runner, esModuleOutputPath, "node"]); + expect(plain.status, plain.stderr).toBe(0); + const { guest: plainGuest, native } = resultOf(plain.stdout); + expect(plainGuest.isatty).toEqual(native.isatty); + expect(plainGuest.isatty[1]).toBe(false); + expect(plainGuest.invalidFd).toEqual(native.invalidFd); + expect(plainGuest.notATerminal).toEqual(native.notATerminal); + expect(plainGuest.notATerminal.code).toBe("ERR_TTY_INIT_FAILED"); + expect(plainGuest.init).toEqual(native.init); + + if (!python || process.platform === "win32") { + return; + } + const steps = [{ expect: "Name? " }, { send: "Ada\r" }, { expect: "REPORT " }, { send: "\n" }]; + const pty = await run(python, [ + join(FIXTURE, "tty-pty.py"), + "40", + "120", + JSON.stringify(steps), + process.execPath, + runner, + esModuleOutputPath, + "node", + ]); + expect(pty.status, pty.stderr).toBe(0); + const { output, status } = JSON.parse(pty.stdout); + expect(status, output).toBe(0); + const { guest: ptyGuest, native: ptyNative } = resultOf(output); + expect(ptyGuest.isatty).toEqual([true, true, true, false]); + expect(ptyNative.isatty).toEqual([true, true, true, false]); + expect(ptyGuest.notATerminal).toEqual(ptyNative.notATerminal); + expect(ptyGuest).toMatchObject({ + size: [120, 40], + isTTY: [true, true, false], + depth: 8, + hasColors: [true, false, true], + terminal: true, + rawDuringQuestion: true, + answer: "Ada", + rawAfter: false, + events: [], + }); + expect(output).toContain("Hello "); + expect(output).toContain("Name? "); + expect(output).toContain(`REPORT ${JSON.stringify(ptyGuest)}`); + } finally { + await cleanup(); + } + }, + 600_000, + ); +}); From cca1d408ebf8e3c546909f8b8185f6f3d564ea23 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 13:28:16 +0000 Subject: [PATCH 5/5] docs(std): document node:tty --- docs/src/interop/jco-std.md | 5 ++- docs/src/interop/nodejs-builtins.md | 56 ++++++++++++++++++++++++++++- packages/jco-std/README.md | 44 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/src/interop/jco-std.md b/docs/src/interop/jco-std.md index 93ef6d106..2b6b853e5 100644 --- a/docs/src/interop/jco-std.md +++ b/docs/src/interop/jco-std.md @@ -139,7 +139,10 @@ used by the Hono adapter; assert and Buffer do not add further capabilities. with no additional WIT capability; - `node:repl`, ported from Node 24.20 over that readline port for global-scope evaluation, keyword commands, completion and top-level `await`, with no - additional WIT capability and acorn bundled only when the REPL is imported; and + additional WIT capability and acorn bundled only when the REPL is imported; +- `node:tty`, ported from Node 24.20 over the explicit `jco:node/tty@0.1.0` + capability, which addresses the host process's terminals by descriptor and is + denied by default; and - `node:stream/consumers`, implemented as portable iterable collection over the engine's Blob, typed-array, and text-codec globals; and - the experimental Node 24.20 `node:stream/iter` API, including portable sources, diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index 31a75b11b..be2766821 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -131,6 +131,7 @@ is planned. | `node:process` | Jco typed facade and explicit Node passthrough | Host process state and operations over `jco:node/process@0.1.0`; lazy default properties, named functions and objects. See Process restrictions below. | | `node:sqlite` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite` | SQLite over an explicit typed host capability; denied by default. Synchronous SQL callbacks are unsupported -- see below. | | `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | +| `node:tty` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty` | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default -- see below. | | `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | | `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | | `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | @@ -713,6 +714,59 @@ The interface supports `arch`, `availableParallelism`, `cpus`, `endianness`, `type`, `uptime`, `userInfo`, and `version`, with Node's argument validation and `ERR_SYSTEM_ERROR` reconstruction for failing calls. +### Terminals and host capabilities + +A component has no terminal of its own, and WASI 0.2 can only say whether its +standard streams are terminals. `node:tty` therefore resolves against +`jco:node/tty@0.1.0`, which addresses the embedding process's descriptors as Node +does: `isatty(fd)`, a handle per descriptor and direction, raw mode, the window +size, blocking reads, writes, and the environment used for color detection. When +bundled source imports `node:tty`, Jco adds the import to the selected world and +installs `tty.wit` and the shared `types.wit` under `deps/jco-node-0.1.0`. + +It is **denied by default**: every operation, including `isatty()` on an in-range +descriptor, throws `ERR_JCO_TTY_ADAPTER_REQUIRED` until the application maps a +provider. jco-std ships one for Node: + +```console +jco transpile component.wasm \ + --map 'jco:node/tty@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host/node' +``` + +With it, `new tty.WriteStream(1)` is the embedding process's standard output when +that is a terminal, and fails with Node's own `ERR_TTY_INIT_FAILED` (with `errno`, +`syscall` and `info`) when it is not. `process.stdin` and `process.stdout` remain +unsupported on the process facade, so applications construct the streams from +descriptors explicitly. Because `readline` and the REPL default `terminal` to +`output.isTTY`, these streams are what makes an interactive session work: + +```js +import { ReadStream, WriteStream } from "node:tty"; +import repl from "node:repl"; + +export function start() { + const input = new ReadStream(0); + const output = new WriteStream(1); + repl.start({ prompt: "app> ", input, output, useGlobal: true }); + input.resume(); +} +``` + +The port follows [Node v24.20.0's `lib/tty.js`](https://github.com/nodejs/node/blob/v24.20.0/lib/tty.js) +and `lib/internal/tty.js`. The pinned unenv tty module answers `isatty() === false` +and writes through `console.log`; it is not used. + +#### Boundaries + +| Surface | Behavior | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prototype chain | `WriteStream → Duplex → Readable → Stream → EventEmitter` with an internal terminal stream standing in for `net.Socket`; `instanceof net.Socket` is false without `wasi:sockets`. | +| Stream sides | A `ReadStream` is not writable and a `WriteStream` is not readable; Node's sockets open the descriptor read-write. | +| Reading | Blocks the component. A flowing `ReadStream` pulls one chunk per read and emits `'data'` synchronously between pulls; `pause()` stops after the current chunk. Nothing else runs while the terminal is idle. | +| `'resize'` | Emitted only by `_refreshSize()`; a component receives no `SIGWINCH`. | +| `getColorDepth()` / `hasColors()` | Exact port. The environment defaults to the provider's; the Windows branch answers the 16-color floor because the build-number probe needs `node:os`. | +| Standard descriptors on the Node host | Kept open when a stream is destroyed, as Node keeps its own stdio; descriptors above 2 are closed with the stream. Raw mode is restored when the last read handle goes. | + ### Async hooks and synchronous scopes `AsyncLocalStorage` works within a synchronous scope: `run`, `getStore`, `exit`, `enterWith`, @@ -1543,7 +1597,7 @@ the module or upstream project. | Modules | Why they are not enabled yet | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `node:timers/promises` | A component-aware timer/event-loop integration is needed for delays, cancellation, and abort signals. | -| `node:trace_events`, `node:tty` | The fallbacks preserve useful shapes, but tracing and terminal detection are synthetic or no-op without runtime integration. | +| `node:trace_events` | The fallbacks preserve useful shapes, but tracing is synthetic or no-op without runtime integration. | | `node:url` | There is substantial Node-derived code, but its eager `node:path` dependency adds a WASI environment requirement even for global-only URL use, and its namespace combines modern and legacy APIs that need separate policy. | ### Host-backed or broad subsystems diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index fba58dcd3..f8b1fe177 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -60,6 +60,9 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/stream/consumers` | Portable `node:stream/consumers`, Node 24 | | `wasi/0.2.x/node/24.x.x/stream/iter` | Experimental iterable streams from Node 24.20 | | `wasi/0.2.x/node/24.x.x/repl` | `node:repl` over the readline port; global-scope evaluation only | +| `wasi/0.2.x/node/24.x.x/tty` | `node:tty` guest adapter, Node 24 over an explicit host capability | +| `wasi/0.2.x/node/24.x.x/tty/host` | Deny-by-default host for `jco:node/tty` | +| `wasi/0.2.x/node/24.x.x/tty/host/node` | Opt-in host over the runtime's real `node:tty` and its descriptors | | `wasi/0.2.x/node/24.x.x/child-process/host` | Deny-by-default host for `jco:node/child-process` | | `wasi/0.2.x/node/24.x.x/child-process/host/node` | Opt-in host over the runtime's real `node:child_process` | | `wasi/0.2.x/node/24.x.x/cluster/host` | Deny-by-default host for `jco:node/cluster` | @@ -176,6 +179,10 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl` over the readline port. Evaluation is global-scope only (`useGlobal: true`); the module needs no WIT capability and is the only jco-std module that bundles `acorn`; +- `node:tty`, implemented by + `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty` and the + application-provided `jco:node/tty@0.1.0` capability, giving readline and the + REPL real terminal streams; - `node:module`, implemented by `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module`. Classification, source maps and `require.resolve` are exact; everything that loads throws, @@ -331,6 +338,43 @@ between SpiderMonkey and QuickJS), rewriting top-level `await`, and locating the expression to tab-complete. A component that does not import `node:repl` does not carry acorn. +### Terminals + +The versioned tty module ports Node 24.20.0's `node:tty`: `isatty()`, `ReadStream` +with `setRawMode()`, and `WriteStream` with `columns`/`rows`, `getWindowSize()`, +`getColorDepth()`, `hasColors()`, the cursor helpers and `'resize'`. A component +has no terminal of its own, so the module is host-backed: the streams address the +embedding process's descriptors through `jco:node/tty@0.1.0`, which is denied by +default and mapped explicitly at transpile time. + +```js +import { ReadStream, WriteStream, isatty } from "node:tty"; +import { createInterface } from "node:readline"; + +export function ask() { + if (!isatty(0) || !isatty(1)) { + throw new Error("an interactive terminal is required"); + } + const rl = createInterface({ input: new ReadStream(0), output: new WriteStream(1) }); + rl.question("Name? ", (name) => { + rl.write(`Hello ${name}, your terminal is ${rl.output.columns} columns wide\n`); + rl.close(); + }); +} +``` + +```console +jco transpile component.wasm \ + --map 'jco:node/tty@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host/node' +``` + +The streams are `stream.Duplex` instances rather than `net.Socket`s, a `ReadStream` +is not writable and a `WriteStream` is not readable. Reading blocks the component: +a flowing `ReadStream` pulls one chunk at a time and emits `'data'` synchronously +between pulls, so terminal input is read inside the export that asked for it. +`'resize'` is emitted only by `_refreshSize()`, since a component receives no +`SIGWINCH`. Color detection reads the provider's environment when none is passed. + ### Errors globals Node's [Errors API](https://nodejs.org/docs/latest-v24.x/api/errors.html) is not an