Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/src/interop/jco-std.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ used by the Hono adapter; assert and Buffer do not add further capabilities.
whose semantics an individual-request interface cannot preserve;
- `node:readline` and `node:readline/promises`, ported from Node 24.20 for line
parsing, questions, async iteration and terminal editing over supplied streams,
with no additional WIT capability; and
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
- `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,
Expand Down
68 changes: 68 additions & 0 deletions docs/src/interop/nodejs-builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ is planned.
| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. |
| `node:perf_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/perf-hooks` | Portable timing and observers; native telemetry throws. Runtime requirements are described below. |
| `node:readline`, `node:readline/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/readline` and `/readline/promises` | Node 24.20 line parsing, questions, terminal editing and cursor actions over supplied streams. No WIT capability. |
| `node:repl` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl` | Node 24.20 REPL over the readline port and supplied streams; `useGlobal: true` only, bundles acorn -- see below. No WIT capability. |
| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. |
| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. |
| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. |
Expand Down Expand Up @@ -389,6 +390,73 @@ throw an explicit `ERR_JCO_UNSUPPORTED_NODE_API` for that operation. Cancellatio
accepts supplied AbortSignals; readline does not install missing Abort globals.
QuickJS async entry functions must be declared `async func` in WIT.

### REPL

`node:repl` ports [Node v24.20.0's REPL](https://github.com/nodejs/node/blob/v24.20.0/lib/repl.js)
on top of the readline port: `repl.start()`, `REPLServer`, the `.break`, `.clear`,
`.exit`, `.help`, `.editor` keywords and `defineCommand()`, tab completion,
in-memory history and reverse search, top-level `await`, recoverable multi-line
input, `_` and `_error`, and the `'exit'` and `'reset'` events. The pinned unenv
repl module is stubs and is not used.

Applications keep the ordinary import and supply the streams:

```js
import repl from "node:repl";

export function attach(input, output) {
const server = repl.start({ prompt: "app> ", input, output, useGlobal: true });
server.context.app = { version: "1.0.0" };
server.on("exit", () => output.write("bye\n"));
return server;
}
```

Bundle with `jco componentize app.js --bundle --wit wit -o app.wasm`. The REPL
requires no WIT imports; `input` and `output` decide where the session goes.
Without a `process` global they are required, since there is no stdin or stdout
to fall back to.

#### Global scope only

Node's default `useGlobal: false` runs each line in a separate `vm` context, a
second realm with its own globals. No component engine can create one, so that
option -- given explicitly or omitted -- is refused at construction with
`ERR_JCO_UNSUPPORTED_NODE_API`. With `useGlobal: true` evaluation is an indirect
`eval`, which is exactly `vm.runInThisContext`: `replServer.context` is
`globalThis`, `.clear` is an alias for `.break`, and assigning to the context
exposes values as documented. Node's script scope keeps top-level `let`, `const`
and `class` bindings across lines; an `eval` does not, so the REPL rewrites those
declarations to persist them. The trade is that `const` is not enforced between
lines and a later redeclaration is accepted -- the same trade Node documents for
lines containing `await`. `REPL_MODE_STRICT` is refused for the same reason: a
strict-mode eval cannot bind declarations in the global scope at all.

#### Why acorn

Node's REPL vendors [acorn](https://github.com/acornjs/acorn); this port depends
on the same versions from npm (`acorn@8.17.0`, `acorn-walk@8.3.5`). A parser is
needed for what the engine cannot answer: whether a line is *incomplete* (show the
`...` prompt) or *wrong* (print the error) -- engine `SyntaxError` messages differ
between SpiderMonkey and QuickJS and cannot drive that decision -- rewriting
top-level `await` into an async wrapper, and locating the expression to
tab-complete. acorn also serves as the compile step Node performs with
`vm.Script`, so syntax errors read the same on every engine. It is bundled only
when `node:repl` is imported; a component without the REPL does not carry it.
Bundle size grows by roughly 1.2 MB (QuickJS) to 1.4 MB (StarlingMonkey).

#### Boundaries

| Surface | Behavior |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useGlobal: false` or omitted, `REPL_MODE_STRICT`, `breakEvalOnSigint` | Refused at construction with `ERR_JCO_UNSUPPORTED_NODE_API`. Ctrl+C still arrives as a keypress and emits `'SIGINT'`. |
| `REPLServer()` without `new` (DEP0185) | Throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API` before reading any argument. |
| `preview` | Accepted and ignored, as in a Node built without an inspector. |
| `.save`, `.load`, `setupHistory(filePath)` | Print Node's own failure text (`Failed to save: …`, `Could not open history file`) and continue; a component has no filesystem unless the application supplies one. |
| Core modules in the context | Not auto-loaded: `fs` is a `ReferenceError` unless the application put it on the context. `require` throws `ERR_JCO_UNSUPPORTED_NODE_API`; `require.resolve` answers as Node does. |
| Errors | Synchronous errors print as `Uncaught …` with the evaluated frames only; errors thrown later by asynchronous work are not routed back, since there is no `node:domain`. |
| `writer` | Jco's portable inspector shared with `node:console`: the same values as `util.inspect`, without line breaking, `showProxy`, `showHidden`, `getters` or `sorted`. |

### Child processes and host capabilities

A WebAssembly guest cannot spawn a process itself. When bundled source imports
Expand Down
46 changes: 46 additions & 0 deletions packages/jco-std/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ build NodeJS programs as components.
| `wasi/0.2.x/node/24.x.x/stream` and `/stream/promises` | Classic Node streams over readable-stream 4.7.0, with Node 24 adapters |
| `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/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` |
Expand Down Expand Up @@ -171,6 +172,10 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components
module-level functions unenv leaves unimplemented;
- `node:string_decoder`, implemented guest-locally by
`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder`;
- `node:repl`, implemented by
`@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: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,
Expand Down Expand Up @@ -285,6 +290,47 @@ algorithms. It also retains the legacy `text`, `lastChar`, `lastNeed`, and
`lastTotal` prototype members that remain present in Node 24, although new code
should use the documented constructor, `write()`, and `end()` API.

### REPL

The versioned repl module ports Node 24.20.0's `node:repl` on top of the readline
port: `repl.start()`, `REPLServer`, keyword commands, `defineCommand()`, tab
completion, in-memory history, reverse search, editor mode, top-level `await`,
recoverable multi-line input and the `_`/`_error` conventions. The application
supplies the streams; no WIT capability is required.

```js
import repl from "node:repl";

export function attach(input, output) {
const server = repl.start({ prompt: "app> ", input, output, useGlobal: true });
server.context.app = { version: "1.0.0" };
server.defineCommand("ping", {
help: "Answer pong",
action() {
this.output.write("pong\n");
this.displayPrompt();
},
});
return server;
}
```

Only `useGlobal: true` is supported. No component engine can create a second
realm, so Node's default of a separate context is refused at construction with
`ERR_JCO_UNSUPPORTED_NODE_API`; with the global scope, evaluation is an indirect
`eval`, which is what `vm.runInThisContext` is. Top-level `let`, `const` and
`class` are rewritten so they persist between lines as they do in Node, at the
cost that `const` is not enforced across lines. Strict mode, `breakEvalOnSigint`,
history files, `.save` and `.load` are refused or report Node's own failure text,
and core modules are not auto-loaded into the context.

`acorn` 8.17.0 and `acorn-walk` 8.3.5, the versions Node vendors, are bundled by
this module alone. The REPL needs a parser the engine cannot replace: deciding
whether input is incomplete or wrong (engine `SyntaxError` messages differ
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.

### Errors globals

Node's [Errors API](https://nodejs.org/docs/latest-v24.x/api/errors.html) is not an
Expand Down
7 changes: 7 additions & 0 deletions packages/jco-std/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@
"types": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.d.ts",
"browser": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.js",
"default": "./dist/wasi/0.2.x/node/24.x.x/readline-promises.js"
},
"./wasi/0.2.x/node/24.x.x/repl": {
"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"
}
},
"scripts": {
Expand All @@ -458,6 +463,8 @@
"build:bindings:wasi:tls": "node scripts/generate-tls-bindings.mjs"
},
"dependencies": {
"acorn": "8.17.0",
"acorn-walk": "8.3.5",
"minimatch": "10.2.6",
"readable-stream": "4.7.0"
},
Expand Down
141 changes: 3 additions & 138 deletions packages/jco-std/src/wasi/0.2.x/node/24.x.x/console/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
// Node.js is distributed under the MIT license. See https://github.com/nodejs/node.

import { unsupportedNodeApi } from "../errors/core.js";
import { inspect as inspectValue, type InspectOptions } from "../internal/inspect.js";

export type { InspectOptions };

const customInspect = Symbol.for("nodejs.util.inspect.custom");
const clocks = new WeakMap<object, () => number>();
const consoleMethods = [
"log",
Expand Down Expand Up @@ -37,21 +39,6 @@ export interface WritableStream {
getColorDepth?(): number;
}

export interface InspectOptions {
showHidden?: boolean;
colors?: boolean;
depth?: number | null;
maxArrayLength?: number | null;
maxStringLength?: number | null;
breakLength?: number;
compact?: boolean | number;
customInspect?: boolean;
showProxy?: boolean;
sorted?: boolean | ((left: string, right: string) => number);
getters?: boolean | "get" | "set";
numericSeparator?: boolean;
}

export interface ConsoleOptions {
stdout: WritableStream;
stderr?: WritableStream;
Expand Down Expand Up @@ -84,128 +71,6 @@ function validateStream(value: unknown, name: string): asserts value is Writable
}
}

function quote(value: string): string {
return `'${value
.replaceAll("\\", "\\\\")
.replaceAll("'", "\\'")
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r")
.replaceAll("\t", "\\t")}'`;
}

function color(code: number, value: string, enabled: boolean): string {
return enabled ? `\u001b[${code}m${value}\u001b[39m` : value;
}

function primitive(value: unknown, colors: boolean): string | undefined {
if (value === undefined) {
return color(90, "undefined", colors);
}
if (value === null) {
return colors ? "\u001b[1mnull\u001b[22m" : "null";
}
if (typeof value === "string") {
return color(32, quote(value), colors);
}
if (typeof value === "number") {
return color(33, Object.is(value, -0) ? "-0" : String(value), colors);
}
if (typeof value === "bigint") {
return color(33, `${value}n`, colors);
}
if (typeof value === "boolean") {
return color(33, String(value), colors);
}
if (typeof value === "symbol") {
return color(32, String(value), colors);
}
if (typeof value === "function") {
return color(36, `[Function${value.name ? `: ${value.name}` : ""}]`, colors);
}
return undefined;
}

function inspectValue(
value: unknown,
options: InspectOptions = {},
seen = new Set<object>(),
level = 0,
): string {
const simple = primitive(value, options.colors === true);
if (simple !== undefined) {
return simple;
}

const object = value as object;
if (seen.has(object)) {
return color(36, "[Circular]", options.colors === true);
}
const depth = options.depth === undefined ? 2 : options.depth;
if (depth !== null && level > depth) {
const name = object.constructor?.name ?? "Object";
return color(36, `[${name}]`, options.colors === true);
}

if (options.customInspect !== false) {
const hook = (object as { [customInspect]?: unknown })[customInspect];
if (typeof hook === "function") {
return String(
hook.call(object, depth === null ? null : depth - level, options, inspectValue),
);
}
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
}
if (value instanceof RegExp) {
return String(value);
}
if (value instanceof Error) {
return value.stack ?? `${value.name}: ${value.message}`;
}

seen.add(object);
let result: string;
if (Array.isArray(value)) {
const limit = options.maxArrayLength === null ? value.length : (options.maxArrayLength ?? 100);
const entries = value
.slice(0, limit)
.map((item) => inspectValue(item, options, seen, level + 1));
if (value.length > limit) {
entries.push(`... ${value.length - limit} more item${value.length - limit === 1 ? "" : "s"}`);
}
result = `[ ${entries.join(", ")} ]`;
} else if (value instanceof Map) {
const entries = Array.from(
value,
([key, item]) =>
`${inspectValue(key, options, seen, level + 1)} => ${inspectValue(item, options, seen, level + 1)}`,
);
result = `Map(${value.size}) { ${entries.join(", ")} }`;
} else if (value instanceof Set) {
const entries = Array.from(value, (item) => inspectValue(item, options, seen, level + 1));
result = `Set(${value.size}) { ${entries.join(", ")} }`;
} else if (ArrayBuffer.isView(value)) {
const typed = value as unknown as { readonly length?: number; [index: number]: unknown };
const length = typed.length ?? 0;
const entries = Array.from({ length }, (_, index) =>
inspectValue(typed[index], options, seen, level + 1),
);
result = `${object.constructor?.name ?? "TypedArray"}(${length}) [ ${entries.join(", ")} ]`;
} else {
const entries = Object.keys(object).map((key) => {
const displayKey = /^[A-Za-z_$][\w$]*$/.test(key) ? key : quote(key);
const item = (object as Record<string, unknown>)[key];
return `${displayKey}: ${inspectValue(item, options, seen, level + 1)}`;
});
const prefix =
object.constructor && object.constructor !== Object ? `${object.constructor.name} ` : "";
result = `${prefix}{ ${entries.join(", ")} }`;
}
seen.delete(object);
return result;
}

function json(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
Expand Down
Loading
Loading