diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 43b61b310..a2b550bf6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -194,6 +194,10 @@ jobs: name: build-preview2-shim path: packages/preview2-shim/dist + # Temporarily exercise unpublished jco-std fixes through the workspace dependency. + - name: Build workspace jco-std + run: pnpm --filter '@bytecodealliance/jco-std' run build:ts + - name: Test jco (node@lts) if: matrix.node != 'latest' working-directory: packages/jco diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index b91b2b5c7..f8f7549a1 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -182,9 +182,8 @@ resolve that specifier. Bundled code can use `Buffer` without importing free `Buffer` identifier is referenced. A source graph that never uses it pays no bundle-size or initialization cost. -The component engine already supplies the portable Web globals shared with Node, -so Jco leaves their identities and behavior untouched. With ComponentizeJS 0.22.0's -pinned StarlingMonkey runtime, this includes: +The component engine supplies the portable Web globals shared with Node. With +ComponentizeJS 0.22.0's pinned StarlingMonkey runtime, this includes: - `AbortController`, `AbortSignal`, `atob`, `btoa`, `Blob`, and `File`; - `ByteLengthQueuingStrategy`, `CountQueuingStrategy`, `ReadableStream` and its @@ -194,8 +193,19 @@ pinned StarlingMonkey runtime, this includes: `DOMException`, `Event`, and `EventTarget`; - `fetch`, `FormData`, `Headers`, `Request`, and `Response`; - `Performance`, `performance`, `queueMicrotask`, timeout/interval functions, - `structuredClone`, `TextEncoder`, `TextDecoder`, `URL`, `URLSearchParams`, and - `WebAssembly`. + `structuredClone`, `TextEncoder`, `TextDecoder`, `URL`, and `URLSearchParams`. + +When bundled source references `AbortController` or `AbortSignal`, Jco loads a +compatibility adapter for the legacy StarlingMonkey abort implementation. It +preserves the native constructors and signal objects while correcting `any()`'s +array handling, default reason identity, and `throwIfAborted()`. The adapter +detects the legacy calling convention and leaves conforming engines untouched. + +The current embedded runtime does **not** expose a guest `WebAssembly` API. +Running the component in a Wasm host does not give its JavaScript code the ability +to compile or instantiate another Wasm module. Guest-side Wasm execution may be +supported in the future; the globals test currently asserts that this API is +absent and should gain execution coverage when the engine provides it. Some of these retain StarlingMonkey's existing WASI feature requirements, such as clocks for timers, random for WebCrypto, stdio for console, and HTTP for network @@ -801,6 +811,30 @@ clients and servers. `wasi-http` implements clients and rejects server construction immediately because outgoing-handler cannot listen for arbitrary connections. +For direct servers, instantiate with a provider bound to that component's +callback dispatcher. For example, after transpiling with +`--instantiation async --map jco:node/http@0.1.0=http-host`: + +```js +import { instantiate } from './component.js'; +import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; +import { createHttpHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node'; + +let instance; +const imports = new WASIShim().getImportObject(); +imports['http-host'] = createHttpHost(() => instance.httpCallbacks); +instance = await instantiate(undefined, imports); +// Await application exports that create or control servers. +await instance.start(); +``` + +Create a separate provider for each component instance. The server holds a +callback registration ID; handlers stay in the guest and run through the +exported dispatcher. The provider serializes callback entry and drains accepted +callbacks before close completes. Closing releases the guest registration; +listening again registers the same server's handler again. Direct client-only +applications can continue mapping the Node provider module without this factory. + > [!WARNING] > All modes currently buffer complete request and response bodies. diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 11f0ab5e9..2aeeeec04 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -608,7 +608,10 @@ When the selected world is missing a required import or callback export, Jco edits that world in place, adds generated comments and declarations, installs the corresponding WIT packages under `wit/deps`, and prints a warning. Direct servers use an imported host-owned `server` resource plus an exported -guest-owned request-listener resource. Jco re-bundles a small entry wrapper so +callback dispatcher. Each server passes a guest registration ID to its host; +the guest retains its handler while listening and releases it after close. +Node hosts use `createHttpHost(() => instance.httpCallbacks)` from the opt-in +provider to bind callbacks to one component instance. Jco re-bundles a small entry wrapper so the callback implementation is present on the final component export. Existing declarations and dependency files are preserved, aliases are recognized, and repeated componentization does not add duplicates. Use `--world-name` when the diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 682415739..bbbf74e91 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -28,6 +28,10 @@ ], "type": "module", "exports": { + "./wasi/0.2.x/node/24.x.x/abort-globals": { + "types": "./dist/wasi/0.2.x/node/24.x.x/abort-globals.d.ts", + "default": "./dist/wasi/0.2.x/node/24.x.x/abort-globals.js" + }, "./wasi/0.2.x/node/24.x.x/assert": { "types": "./dist/wasi/0.2.x/node/24.x.x/assert/index.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/assert/index.js", @@ -114,6 +118,11 @@ "browser": "./dist/wasi/0.2.x/node/24.x.x/http/core.js", "default": "./dist/wasi/0.2.x/node/24.x.x/http/core.js" }, + "./wasi/0.2.x/node/24.x.x/http/impl/direct": { + "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/direct.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/direct.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/direct.js" + }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets": { "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js", diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/abort-globals.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/abort-globals.ts new file mode 100644 index 000000000..ed9bde9ae --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/abort-globals.ts @@ -0,0 +1,89 @@ +import { invalidArgType } from "./errors/core.js"; + +/** + * Adapt the legacy AbortSignal implementation embedded in ComponentizeJS 0.22. + * Keep native signal identities and dependency tracking. Engines with the + * standard sequence-taking any() implementation need no changes. + * Upstream fixes: bytecodealliance/StarlingMonkey#310. + */ +function installAbortCompatibility(): void { + const Controller = globalThis.AbortController; + const Signal = globalThis.AbortSignal; + if (!Controller || !Signal || typeof Signal.any !== "function") { + return; + } + const nativeAny = Signal.any; + const aborted = Object.getOwnPropertyDescriptor(Signal.prototype, "aborted")!.get!; + const reason = Object.getOwnPropertyDescriptor(Signal.prototype, "reason")!.get!; + + try { + // The old engine takes variadic signals, not an array. Probe with a real + // signal: passing an array to it misreads the array's internal slots. + // Correct implementations reject this non-sequence without touching it. + const probe = Reflect.apply(nativeAny, Signal, [new Controller().signal]); + if (Reflect.apply(aborted, probe, []) !== false) { + return; + } + } catch { + return; + } + + const nativeAbort = Signal.abort; + const nativeControllerAbort = Controller.prototype.abort; + const methods = { + any(signals: AbortSignal[]): AbortSignal { + if (!Array.isArray(signals)) { + throw invalidArgType("signals", "Array", signals); + } + // Validate every input before native code accesses its reserved slots. + const inputs = Array.from(signals); + for (let index = 0; index < inputs.length; index++) { + try { + Reflect.apply(aborted, inputs[index], []); + } catch { + throw invalidArgType(`signals[${index}]`, "AbortSignal", inputs[index]); + } + } + return inputs.length ? Reflect.apply(nativeAny, Signal, inputs) : new Controller().signal; + }, + abort(value?: unknown): AbortSignal { + // The legacy native method requires an argument, even when undefined. + return Reflect.apply(nativeAbort, Signal, [value]); + }, + throwIfAborted(this: AbortSignal): void { + if (Reflect.apply(aborted, this, [])) { + // The legacy native method sets an exception but reports success. + throw Reflect.apply(reason, this, []); + } + }, + }; + const controllerMethods = { + abort(this: AbortController, value?: unknown): void { + if (value === undefined) { + // Generate the native default reason once, then pass it explicitly so + // the source and all dependent signals receive the exact same object. + const defaults = new Controller(); + Reflect.apply(nativeControllerAbort, defaults, []); + value = Reflect.apply(reason, defaults.signal, []); + } + Reflect.apply(nativeControllerAbort, this, [value]); + }, + }; + Object.defineProperties(Signal, { + any: { ...Object.getOwnPropertyDescriptor(Signal, "any"), value: methods.any }, + abort: { ...Object.getOwnPropertyDescriptor(Signal, "abort"), value: methods.abort }, + }); + Object.defineProperty(Signal.prototype, "throwIfAborted", { + ...Object.getOwnPropertyDescriptor(Signal.prototype, "throwIfAborted"), + value: methods.throwIfAborted, + }); + Object.defineProperty(Controller.prototype, "abort", { + ...Object.getOwnPropertyDescriptor(Controller.prototype, "abort"), + value: controllerMethods.abort, + }); +} + +installAbortCompatibility(); + +export const AbortController = globalThis.AbortController; +export const AbortSignal = globalThis.AbortSignal; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dns/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dns/core.ts index bb968c006..94047c12b 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dns/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dns/core.ts @@ -8,6 +8,8 @@ * share state between callback and promise facades, and make cancellation explicit. */ import { dnsError, invalidArgType, invalidArgValue, unsupported } from "./errors.js"; +import { callHost } from "../internal/host-error.js"; +import type { HostImports } from "../internal/wit-types.js"; import type { AnyRecord, CaaRecord, @@ -105,11 +107,8 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function hostResult(response: DnsResult): T { - if (response.tag === "err") { - throw dnsError(response.val); - } - return response.val; +function hostResult(operation: () => T | DnsResult): T { + return callHost(operation, dnsError); } function hostFamily(value: DnsFamily): DnsHostFamily { @@ -332,7 +331,7 @@ function resolveOperation(rrtype: string): ResolveOperation { } function resolveHost( - host: DnsHost, + host: HostImports, operation: ResolveOperation, hostname: string, ttl: boolean, @@ -341,37 +340,37 @@ function resolveHost( const configuration = hostResolver(resolver); switch (operation) { case "resolve4": { - const records = hostResult(host.resolve4(hostname, ttl, configuration)); + const records = hostResult(() => host.resolve4(hostname, ttl, configuration)); return ttl ? records : records.map((record) => record.address); } case "resolve6": { - const records = hostResult(host.resolve6(hostname, ttl, configuration)); + const records = hostResult(() => host.resolve6(hostname, ttl, configuration)); return ttl ? records : records.map((record) => record.address); } case "resolveAny": - return hostResult(host.resolveAny(hostname, configuration)).map(anyRecord); + return hostResult(() => host.resolveAny(hostname, configuration)).map(anyRecord); case "resolveCaa": - return hostResult(host.resolveCaa(hostname, configuration)); + return hostResult(() => host.resolveCaa(hostname, configuration)); case "resolveCname": - return hostResult(host.resolveCname(hostname, configuration)); + return hostResult(() => host.resolveCname(hostname, configuration)); case "resolveMx": - return hostResult(host.resolveMx(hostname, configuration)); + return hostResult(() => host.resolveMx(hostname, configuration)); case "resolveNaptr": - return hostResult(host.resolveNaptr(hostname, configuration)); + return hostResult(() => host.resolveNaptr(hostname, configuration)); case "resolveNs": - return hostResult(host.resolveNs(hostname, configuration)); + return hostResult(() => host.resolveNs(hostname, configuration)); case "resolvePtr": - return hostResult(host.resolvePtr(hostname, configuration)); + return hostResult(() => host.resolvePtr(hostname, configuration)); case "resolveSoa": - return hostResult(host.resolveSoa(hostname, configuration)); + return hostResult(() => host.resolveSoa(hostname, configuration)); case "resolveSrv": - return hostResult(host.resolveSrv(hostname, configuration)); + return hostResult(() => host.resolveSrv(hostname, configuration)); case "resolveTlsa": - return hostResult(host.resolveTlsa(hostname, configuration)).map(tlsaRecord); + return hostResult(() => host.resolveTlsa(hostname, configuration)).map(tlsaRecord); case "resolveTxt": - return hostResult(host.resolveTxt(hostname, configuration)); + return hostResult(() => host.resolveTxt(hostname, configuration)); case "reverse": - return hostResult(host.reverse(hostname, configuration)); + return hostResult(() => host.reverse(hostname, configuration)); } } @@ -496,19 +495,19 @@ export interface DnsModules { }; } -export function createDns(host: DnsHost): DnsModules { +export function createDns(host: HostImports): DnsModules { let defaultOrder: DnsResultOrder = "verbatim"; let defaultServers: string[] | undefined; function servers(): string[] { - return (defaultServers ??= hostResult(host.getServers())).slice(); + return (defaultServers ??= hostResult(() => host.getServers())).slice(); } function setServers(serversValue: string[]): void { if (!Array.isArray(serversValue)) { throw invalidArgType("servers", "Array"); } - const validated = hostResult(host.validateServers(serversValue)); + const validated = hostResult(() => host.validateServers(serversValue)); defaultServers = validated.slice(); } @@ -526,7 +525,7 @@ export function createDns(host: DnsHost): DnsModules { hostname: string, options: ParsedLookupOptions, ): LookupAddress | LookupAddress[] { - const addresses = hostResult( + const addresses = hostResult(() => host.lookup(hostname, { family: hostFamily(options.family), hints: options.hints, @@ -584,7 +583,7 @@ export function createDns(host: DnsHost): DnsModules { if (!Array.isArray(value)) { throw invalidArgType("servers", "Array"); } - this.configuration.servers = hostResult(host.validateServers(value)); + this.configuration.servers = hostResult(() => host.validateServers(value)); } setLocalAddress(ipv4 = "0.0.0.0", ipv6 = "::0"): void { @@ -839,7 +838,7 @@ export function createDns(host: DnsHost): DnsModules { let result: { hostname: string; service: string } | undefined; let error: DnsError | undefined; try { - result = hostResult(host.lookupService(address, numericPort)); + result = hostResult(() => host.lookupService(address, numericPort)); } catch (caught) { error = caught instanceof Error ? (caught as DnsError) : new Error(String(caught)); } @@ -909,7 +908,7 @@ export function createDns(host: DnsHost): DnsModules { if (!Number.isInteger(numericPort) || numericPort < 0 || numericPort > 65535) { throw invalidArgValue("port", port); } - return promiseCall(() => hostResult(host.lookupService(address, numericPort))); + return promiseCall(() => hostResult(() => host.lookupService(address, numericPort))); }, resolve: promiseResolver.resolve.bind(promiseResolver), resolve4: promiseResolver.resolve4.bind(promiseResolver), diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs/core.ts index c30cd135d..0e760437e 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/fs/core.ts @@ -19,7 +19,8 @@ import { systemError, unsupportedNodeApi, } from "../errors/core.js"; -import { decodeErrno } from "../internal/host-error.js"; +import { callHost, decodeErrno } from "../internal/host-error.js"; +import type { HostImports } from "../internal/wit-types.js"; import { Dir, Dirent, Stats } from "./classes.js"; import type { @@ -155,10 +156,14 @@ function encodingFrom( } function decodeData(value: unknown, encoding: BufferEncoding | "buffer" | null): StringOrBytes { - if (!(value instanceof Uint8Array)) { + // Component bindings may create the byte array in a different JS realm. + if ( + !ArrayBuffer.isView(value) || + Object.prototype.toString.call(value) !== "[object Uint8Array]" + ) { throw new TypeError("invalid filesystem byte response"); } - const buffer = Buffer.from(value); + const buffer = Buffer.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); return encoding && encoding !== "buffer" ? buffer.toString(encoding) : buffer; } @@ -248,21 +253,19 @@ function removeOptions(options: Record): FsRemoveOptions { }; } -function unwrap(result: FsResult): T { - if (result.tag === "ok") { - return result.val; - } - const value = result.val; - const error = systemError({ - message: value.message, - code: value.code ?? "UNKNOWN", - errno: decodeErrno(value.errno), - syscall: value.syscall, - path: value.path, - dest: value.dest, +function unwrap(operation: () => T | FsResult): T { + return callHost(operation, (value) => { + const error = systemError({ + message: value.message, + code: value.code ?? "UNKNOWN", + errno: decodeErrno(value.errno), + syscall: value.syscall, + path: value.path, + dest: value.dest, + }); + error.name = value.name; + return error; }); - error.name = value.name; - throw error; } export class FileHandle implements AsyncDisposable { @@ -484,14 +487,14 @@ export class FileHandle implements AsyncDisposable { } export class FsCore { - readonly #host: FsHost; + readonly #host: HostImports; - constructor(host: FsHost) { + constructor(host: HostImports) { this.#host = host; } accessSync(value: PathLike, mode = 0): void { - unwrap(this.#host.access(path(value), mode)); + unwrap(() => this.#host.access(path(value), mode)); } appendFileSync( @@ -505,25 +508,25 @@ export class FsCore { typeof opts.encoding === "string" && opts.encoding !== "buffer" ? (opts.encoding as BufferEncoding) : "utf8"; - unwrap( + unwrap(() => this.#host.appendFile(pathOrFd(file), encodeData(data, encoding), writeFileOptions(opts)), ); } chmodSync(value: PathLike, mode: Mode): void { - unwrap(this.#host.chmod(path(value), hostMode(mode))); + unwrap(() => this.#host.chmod(path(value), hostMode(mode))); } chownSync(value: PathLike, uid: number, gid: number): void { - unwrap(this.#host.chown(path(value), integer(uid, "uid"), integer(gid, "gid"))); + unwrap(() => this.#host.chown(path(value), integer(uid, "uid"), integer(gid, "gid"))); } closeSync(descriptor: number): void { - unwrap(this.#host.close(fd(descriptor))); + unwrap(() => this.#host.close(fd(descriptor))); } copyFileSync(source: PathLike, destination: PathLike, mode = 0): void { - unwrap(this.#host.copyFile(path(source, "src"), path(destination, "dest"), mode)); + unwrap(() => this.#host.copyFile(path(source, "src"), path(destination, "dest"), mode)); } cpSync(source: PathLike, destination: PathLike, options?: CopyOptions): void { @@ -539,12 +542,12 @@ export class FsCore { recursive: opts.recursive === true, verbatimSymlinks: opts.verbatimSymlinks === true, }; - unwrap(this.#host.cp(path(source, "src"), path(destination, "dest"), hostOptions)); + unwrap(() => this.#host.cp(path(source, "src"), path(destination, "dest"), hostOptions)); } existsSync(value: PathLike): boolean { try { - return unwrap(this.#host.exists(path(value))); + return unwrap(() => this.#host.exists(path(value))); } catch (error) { if (isRecord(error) && error.code === "ERR_JCO_FS_ADAPTER_REQUIRED") { throw error; @@ -554,19 +557,19 @@ export class FsCore { } fchmodSync(descriptor: number, mode: Mode): void { - unwrap(this.#host.fchmod(fd(descriptor), hostMode(mode))); + unwrap(() => this.#host.fchmod(fd(descriptor), hostMode(mode))); } fchownSync(descriptor: number, uid: number, gid: number): void { - unwrap(this.#host.fchown(fd(descriptor), integer(uid, "uid"), integer(gid, "gid"))); + unwrap(() => this.#host.fchown(fd(descriptor), integer(uid, "uid"), integer(gid, "gid"))); } fdatasyncSync(descriptor: number): void { - unwrap(this.#host.fdatasync(fd(descriptor))); + unwrap(() => this.#host.fdatasync(fd(descriptor))); } fstatSync(descriptor: number, options?: StatOptions): Stats { - const result = stats(unwrap(this.#host.fstat(fd(descriptor), statOptions(options)))); + const result = stats(unwrap(() => this.#host.fstat(fd(descriptor), statOptions(options)))); if (!result) { throw new TypeError("missing filesystem stats response"); } @@ -574,15 +577,17 @@ export class FsCore { } fsyncSync(descriptor: number): void { - unwrap(this.#host.fsync(fd(descriptor))); + unwrap(() => this.#host.fsync(fd(descriptor))); } ftruncateSync(descriptor: number, length = 0): void { - unwrap(this.#host.ftruncate(fd(descriptor), integer(length, "len"))); + unwrap(() => this.#host.ftruncate(fd(descriptor), integer(length, "len"))); } futimesSync(descriptor: number, atime: TimeLike, mtime: TimeLike): void { - unwrap(this.#host.futimes(fd(descriptor), toUnixTimestamp(atime), toUnixTimestamp(mtime))); + unwrap(() => + this.#host.futimes(fd(descriptor), toUnixTimestamp(atime), toUnixTimestamp(mtime)), + ); } globSync( @@ -607,25 +612,25 @@ export class FsCore { exclude, withFileTypes: opts.withFileTypes === true, }; - return unwrap( + return unwrap(() => this.#host.glob(typeof pattern === "string" ? [pattern] : [...pattern], hostOptions), ).map((entry) => (entry.tag === "dirent" ? Dirent.fromHost(entry.val) : entry.val)); } lchownSync(value: PathLike, uid: number, gid: number): void { - unwrap(this.#host.lchown(path(value), integer(uid, "uid"), integer(gid, "gid"))); + unwrap(() => this.#host.lchown(path(value), integer(uid, "uid"), integer(gid, "gid"))); } linkSync(existingPath: PathLike, newPath: PathLike): void { - unwrap(this.#host.link(path(existingPath, "existingPath"), path(newPath, "newPath"))); + unwrap(() => this.#host.link(path(existingPath, "existingPath"), path(newPath, "newPath"))); } lstatSync(value: PathLike, options?: StatOptions): Stats | undefined { - return stats(unwrap(this.#host.lstat(path(value), statOptions(options)))); + return stats(unwrap(() => this.#host.lstat(path(value), statOptions(options)))); } lutimesSync(value: PathLike, atime: TimeLike, mtime: TimeLike): void { - unwrap(this.#host.lutimes(path(value), toUnixTimestamp(atime), toUnixTimestamp(mtime))); + unwrap(() => this.#host.lutimes(path(value), toUnixTimestamp(atime), toUnixTimestamp(mtime))); } mkdirSync(value: PathLike, options?: Mode | MakeDirectoryOptions | null): string | undefined { @@ -640,7 +645,7 @@ export class FsCore { ? hostMode(opts.mode) : undefined, }; - return unwrap(this.#host.mkdir(path(value), hostOptions)); + return unwrap(() => this.#host.mkdir(path(value), hostOptions)); } mkdtempSync( @@ -651,7 +656,7 @@ export class FsCore { throw invalidArgType("prefix", "string", prefix); } const encoding = encodingFrom(options); - const result = unwrap(this.#host.mkdtemp(prefix)); + const result = unwrap(() => this.#host.mkdtemp(prefix)); return encoding === "buffer" ? Buffer.from(result) : result; } @@ -668,7 +673,7 @@ export class FsCore { } openSync(value: PathLike, flags: OpenMode = "r", mode: Mode = 0o666): number { - return unwrap(this.#host.open(path(value), hostOpenMode(flags), hostMode(mode))); + return unwrap(() => this.#host.open(path(value), hostOpenMode(flags), hostMode(mode))); } opendirSync(value: PathLike, options?: OpenDirOptions): Dir { @@ -691,7 +696,7 @@ export class FsCore { const opts = typeof options === "string" ? { encoding: options } : optionsRecord(options); checkSignal(opts.signal); const encoding = encodingFrom(typeof options === "string" ? options : (opts as FlagOptions)); - const result = unwrap(this.#host.readFile(pathOrFd(file), readFileOptions(opts))); + const result = unwrap(() => this.#host.readFile(pathOrFd(file), readFileOptions(opts))); return decodeData(result, encoding); } @@ -705,7 +710,7 @@ export class FsCore { recursive: opts.recursive === true, withFileTypes: opts.withFileTypes === true, }; - const result = unwrap(this.#host.readdir(path(value), hostOptions)); + const result = unwrap(() => this.#host.readdir(path(value), hostOptions)); return result.map((entry) => { if (entry.tag === "dirent") { const dirent = Dirent.fromHost(entry.val); @@ -733,7 +738,7 @@ export class FsCore { options?: BufferEncoding | { encoding?: BufferEncoding | "buffer" } | null, ): StringOrBytes { const encoding = encodingFrom(options); - const result = unwrap(this.#host.readlink(path(value))); + const result = unwrap(() => this.#host.readlink(path(value))); return encoding === "buffer" ? Buffer.from(result) : result; } @@ -742,16 +747,16 @@ export class FsCore { options?: BufferEncoding | { encoding?: BufferEncoding | "buffer" } | null, ): StringOrBytes { const encoding = encodingFrom(options); - const result = unwrap(this.#host.realpath(path(value))); + const result = unwrap(() => this.#host.realpath(path(value))); return encoding === "buffer" ? Buffer.from(result) : result; } renameSync(oldPath: PathLike, newPath: PathLike): void { - unwrap(this.#host.rename(path(oldPath, "oldPath"), path(newPath, "newPath"))); + unwrap(() => this.#host.rename(path(oldPath, "oldPath"), path(newPath, "newPath"))); } rmSync(value: PathLike, options?: RemoveOptions): void { - unwrap(this.#host.rm(path(value), removeOptions(optionsRecord(options)))); + unwrap(() => this.#host.rm(path(value), removeOptions(optionsRecord(options)))); } rmdirSync(value: PathLike, options?: RemoveOptions): void { @@ -762,32 +767,32 @@ export class FsCore { "fs.rm(path, { recursive: true })", ); } - unwrap(this.#host.rmdir(path(value), removeOptions(opts))); + unwrap(() => this.#host.rmdir(path(value), removeOptions(opts))); } statSync(value: PathLike, options?: StatOptions): Stats | undefined { - return stats(unwrap(this.#host.stat(path(value), statOptions(options)))); + return stats(unwrap(() => this.#host.stat(path(value), statOptions(options)))); } statfsSync(value: PathLike, options?: { bigint?: boolean }): Record { - const result: FsStatFs = unwrap(this.#host.statfs(path(value), options?.bigint === true)); + const result: FsStatFs = unwrap(() => this.#host.statfs(path(value), options?.bigint === true)); return Object.fromEntries(Object.entries(result).map(([name, value]) => [name, value.val])); } symlinkSync(target: PathLike, value: PathLike, type?: "dir" | "file" | "junction" | null): void { - unwrap(this.#host.symlink(path(target, "target"), path(value), type ?? undefined)); + unwrap(() => this.#host.symlink(path(target, "target"), path(value), type ?? undefined)); } truncateSync(value: PathLike, length = 0): void { - unwrap(this.#host.truncate(path(value), integer(length, "len"))); + unwrap(() => this.#host.truncate(path(value), integer(length, "len"))); } unlinkSync(value: PathLike): void { - unwrap(this.#host.unlink(path(value))); + unwrap(() => this.#host.unlink(path(value))); } utimesSync(value: PathLike, atime: TimeLike, mtime: TimeLike): void { - unwrap(this.#host.utimes(path(value), toUnixTimestamp(atime), toUnixTimestamp(mtime))); + unwrap(() => this.#host.utimes(path(value), toUnixTimestamp(atime), toUnixTimestamp(mtime))); } writeFileSync( @@ -801,7 +806,7 @@ export class FsCore { typeof opts.encoding === "string" && opts.encoding !== "buffer" ? (opts.encoding as BufferEncoding) : "utf8"; - unwrap( + unwrap(() => this.#host.writeFile(pathOrFd(file), encodeData(data, encoding), writeFileOptions(opts)), ); } @@ -831,7 +836,7 @@ export class FsCore { if (offset + count > target.byteLength) { throw outOfRange("length", `<= ${target.byteLength - offset}`, count); } - const result = unwrap(this.#host.read(fd(descriptor), count, position(opts.position))); + const result = unwrap(() => this.#host.read(fd(descriptor), count, position(opts.position))); target.set(result.data, offset); return result.bytesRead; } @@ -862,7 +867,7 @@ export class FsCore { value, typeof lengthOrEncoding === "string" ? lengthOrEncoding : "utf8", ); - return unwrap( + return unwrap(() => this.#host.write( fd(descriptor), data, @@ -884,7 +889,7 @@ export class FsCore { if (offset + count > source.byteLength) { throw outOfRange("length", `<= ${source.byteLength - offset}`, count); } - return unwrap( + return unwrap(() => this.#host.write( fd(descriptor), source.subarray(offset, offset + count), @@ -902,7 +907,7 @@ export class FsCore { throw invalidArgType("buffers", "Array", buffers); } const targets = buffers.map((buffer) => bytes(buffer)); - const result = unwrap( + const result = unwrap(() => this.#host.readv( fd(descriptor), targets.map((buffer) => buffer.byteLength), @@ -921,7 +926,7 @@ export class FsCore { if (!Array.isArray(buffers)) { throw invalidArgType("buffers", "Array", buffers); } - return unwrap( + return unwrap(() => this.#host.writev( fd(descriptor), buffers.map((buffer) => bytes(buffer)), @@ -944,7 +949,7 @@ export class FsCore { } } -export function createFsCore(host: FsHost): FsCore { +export function createFsCore(host: HostImports): FsCore { return new FsCore(host); } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts index 1f57171b5..51cb56a1e 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts @@ -7,6 +7,11 @@ * one buffered, typed WIT request/response exchange. */ import * as nodeHttp from "node:http"; +import { + CallbackResource, + createCallbackQueue, + retireCallbacks, +} from "./internal/callback-resource.js"; import { fieldsToRawHeaders, @@ -17,6 +22,9 @@ import type { DirectHttpListenOptions, DirectHttpRequest, DirectHttpRequestListener, + DirectHttpCallbacks, + DirectHttpIncomingRequest, + DirectHttpOutgoingResponse, DirectHttpResponse, DirectHttpResult, DirectHttpServerAddress, @@ -134,54 +142,73 @@ function serverAddress( } class NodeHttpServer { - readonly #listener: DirectHttpRequestListener; + readonly #pending = new Set>(); readonly #server: nodeHttp.Server; - constructor(options: DirectHttpServerOptions, listener: DirectHttpRequestListener) { - this.#listener = listener; - this.#server = nodeHttp.createServer(nodeServerOptions(options), async (request, response) => { - try { - const chunks: Uint8Array[] = []; - for await (const chunk of request) { - chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk); - } - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const body = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - const result = await listener.handle({ - method: request.method ?? "GET", - url: request.url ?? "/", - httpVersion: request.httpVersion, - headers: rawHeadersToFields(request.rawHeaders), - body, - remoteAddress: request.socket.remoteAddress, - remotePort: request.socket.remotePort, - }); - if (result.tag === "err") { - throw Object.assign(new Error(result.val.message), result.val); - } - response.writeHead( - result.val.statusCode, - result.val.statusMessage, - fieldsToRawHeaders(result.val.headers), - ); - response.end(result.val.body); - } catch (error) { - if (!response.headersSent) { - response.statusCode = 500; - response.setHeader("content-type", "text/plain; charset=utf-8"); - response.end(error instanceof Error ? error.message : String(error)); - } else { - response.destroy(error instanceof Error ? error : new Error(String(error))); - } - } + constructor( + options: DirectHttpServerOptions, + handle: (request: DirectHttpIncomingRequest) => Promise, + ) { + this.#server = nodeHttp.createServer(nodeServerOptions(options), (request, response) => { + const pending = this.#handle(handle, request, response); + this.#pending.add(pending); + const complete = () => { + this.#pending.delete(pending); + }; + void pending.then(complete, complete); }); } + async #handle( + handle: (request: DirectHttpIncomingRequest) => Promise, + request: nodeHttp.IncomingMessage, + response: nodeHttp.ServerResponse, + ): Promise { + try { + const chunks: Uint8Array[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk); + } + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const result = await handle({ + method: request.method ?? "GET", + url: request.url ?? "/", + httpVersion: request.httpVersion, + headers: rawHeadersToFields(request.rawHeaders), + body, + remoteAddress: request.socket.remoteAddress, + remotePort: request.socket.remotePort, + }); + response.writeHead( + result.statusCode, + result.statusMessage, + fieldsToRawHeaders(result.headers), + ); + response.end(result.body); + } catch (caught) { + const value = + typeof caught === "object" && caught !== null && "payload" in caught + ? caught.payload + : caught; + const serialized = serializeNodeError(value); + const error = + value instanceof Error ? value : Object.assign(new Error(serialized.message), serialized); + if (!response.headersSent) { + response.statusCode = 500; + response.setHeader("content-type", "text/plain; charset=utf-8"); + response.end(error.message); + } else { + response.destroy(error); + } + } + } + async listen(options: DirectHttpListenOptions): AsyncResult { try { await new Promise((resolve, reject) => { @@ -226,14 +253,14 @@ class NodeHttpServer { async close(): AsyncResult { const wasListening = this.#server.listening; - if (!wasListening) { - return { tag: "ok", val: false }; - } try { - await new Promise((resolve, reject) => { - this.#server.close((error) => (error ? reject(error) : resolve())); - }); - return { tag: "ok", val: true }; + if (wasListening) { + await new Promise((resolve, reject) => { + this.#server.close((error) => (error ? reject(error) : resolve())); + }); + } + await Promise.all(this.#pending); + return { tag: "ok", val: wasListening }; } catch (error) { return { tag: "err", val: serializeNodeError(error) }; } @@ -283,10 +310,48 @@ class NodeHttpServer { [Symbol.dispose](): void { this.#server.close(); - this.#listener[Symbol.dispose](); + this.#server.closeAllConnections(); + } +} + +/** Bind one host provider to one component's exported callback resources. */ +export function createHttpHost(callbacks: () => DirectHttpCallbacks) { + const enqueue = createCallbackQueue(); + class Server extends NodeHttpServer { + readonly #listener: CallbackResource; + + constructor(options: DirectHttpServerOptions, listener: number) { + const resource = new CallbackResource( + () => callbacks().takeRequestListener(listener), + "ERR_JCO_HTTP_CALLBACK_NOT_FOUND", + ); + super(options, (incoming) => enqueue(async () => (await resource.get()).handle(incoming))); + this.#listener = resource; + } + + override async close(): AsyncResult { + const result = await super.close(); + if (result.tag === "ok") { + retireCallbacks(enqueue, this.#listener); + } + return result; + } + + override [Symbol.dispose](): void { + super[Symbol.dispose](); + // A resource destructor must not synchronously re-enter the guest. + void this.close(); + } } + return { request, Server: Server as unknown as DirectHttpServerConstructor }; } -export const Server = NodeHttpServer as unknown as DirectHttpServerConstructor; +// Client-only mappings may still import this module directly. Servers require +// an instance-bound provider so callback IDs cannot cross component instances. +export const Server = class { + constructor() { + throw new Error("HTTP servers require createHttpHost(() => instance.httpCallbacks)"); + } +} as unknown as DirectHttpServerConstructor; -export default { request, Server }; +export default { request, Server, createHttpHost }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts index 176cfaaa0..c8261c1e5 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts @@ -1,11 +1,12 @@ import * as host from "jco:node/http@0.1.0"; import { createHttp } from "./http/core.js"; -import { createDirectHttpImplementation, httpCallbacks } from "./http/impl/direct.js"; +import { createDirectHttpImplementation } from "./http/impl/direct.js"; -const http = createHttp(createDirectHttpImplementation(host)); +const implementation = createDirectHttpImplementation(host); +const http = createHttp(implementation); -export { httpCallbacks }; +export const httpCallbacks = implementation.httpCallbacks; export const Agent = http.Agent; export const ClientRequest = http.ClientRequest; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/errors.ts index e45aa6884..ce2e653f6 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/errors.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/errors.ts @@ -114,3 +114,21 @@ export function fromImplementationError(value: HttpErrorData | DirectHttpError): error.port = value.port; return error; } + +/** Read a WASI error code from either a raw payload or a ComponentError wrapper. */ +export function wasiErrorCode(error: unknown): string | undefined { + const value = + typeof error === "object" && error !== null && "payload" in error ? error.payload : error; + if (typeof value === "string") { + return value; + } + if ( + typeof value === "object" && + value !== null && + "tag" in value && + typeof value.tag === "string" + ) { + return value.tag; + } + return undefined; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts index 7d54d625c..90f6cf9ad 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts @@ -1,46 +1,19 @@ +import type { HostImports } from "../../internal/wit-types.js"; +import { callHost } from "../../internal/host-error.js"; +import { serializeNodeError } from "../../internal/http-host.js"; +import { codedError } from "../../errors/core.js"; import { fromImplementationError } from "../errors.js"; import type { - DirectHttpHost, DirectHttpRequestListener, + DirectHttpHost, DirectHttpServerAddress, HttpImplementation, + HttpListenOptions, + HttpServerOptions, HttpRequestHandler, HttpServerAddress, } from "../types.js"; -class RequestListener implements DirectHttpRequestListener { - readonly #handler: HttpRequestHandler; - - constructor(handler: HttpRequestHandler) { - this.#handler = handler; - } - - async handle(request: Parameters[0]) { - try { - return { tag: "ok" as const, val: await this.#handler(request) }; - } catch (error) { - const value = - typeof error === "object" && error !== null ? (error as Record) : {}; - return { - tag: "err" as const, - val: { - name: typeof value.name === "string" ? value.name : "Error", - message: typeof value.message === "string" ? value.message : String(error), - code: typeof value.code === "string" ? value.code : undefined, - syscall: typeof value.syscall === "string" ? value.syscall : undefined, - hostname: typeof value.hostname === "string" ? value.hostname : undefined, - address: typeof value.address === "string" ? value.address : undefined, - port: typeof value.port === "number" ? value.port : undefined, - }, - }; - } - } - - [Symbol.dispose](): void {} -} - -export const httpCallbacks = { RequestListener }; - function directAddress(address: DirectHttpServerAddress | undefined): HttpServerAddress | null { return address === undefined ? null @@ -53,55 +26,78 @@ function directAddress(address: DirectHttpServerAddress | undefined): HttpServer : address.val; } -export function createDirectHttpImplementation(host: DirectHttpHost): HttpImplementation { +class RequestListener implements DirectHttpRequestListener { + constructor(readonly handler: HttpRequestHandler) {} + + async handle(request: Parameters[0]) { + try { + return await this.handler(request); + } catch (error) { + throw serializeNodeError(error); + } + } + + [Symbol.dispose](): void {} +} + +export function createDirectHttpImplementation(host: HostImports) { + // Each implementation (and bundled guest instance) owns its registrations. + const listeners = new Map(); + let nextListener = 1; return { - request(options) { - const result = host.request(options); - if (result.tag === "err") { - throw fromImplementationError(result.val); - } - return result.val; + httpCallbacks: { + RequestListener, + takeRequestListener(id: number) { + const listener = listeners.get(id); + listeners.delete(id); + return listener; + }, }, - createServer(options, handler) { - const server = new host.Server(options, new RequestListener(handler)); + request(options: Parameters[0]) { + return callHost(() => host.request(options), fromImplementationError); + }, + + createServer(options: HttpServerOptions, handler: HttpRequestHandler) { + if (nextListener > 0xffff_ffff) { + throw codedError( + new Error("HTTP callback registrations exhausted"), + "ERR_JCO_HTTP_CALLBACK_LIMIT", + ); + } + const listener = nextListener++; + const server = new host.Server(options, listener); return { - listen(listenOptions) { - const result = server.listen(listenOptions); - if (result.tag === "err") { - throw fromImplementationError(result.val); + listen(listenOptions: HttpListenOptions) { + listeners.set(listener, new RequestListener(handler)); + try { + return directAddress( + callHost(() => server.listen(listenOptions), fromImplementationError), + )!; + } catch (error) { + listeners.delete(listener); + throw error; } - return directAddress(result.val)!; }, close() { - const result = server.close(); - if (result.tag === "err") { - throw fromImplementationError(result.val); - } - return result.val; + // The host drains accepted callbacks before close returns. On error, + // retain the registration because the server may still be active. + const wasListening = callHost(() => server.close(), fromImplementationError); + listeners.delete(listener); + return wasListening; }, closeAllConnections() { - const result = server.closeAllConnections(); - if (result.tag === "err") { - throw fromImplementationError(result.val); - } + callHost(() => server.closeAllConnections(), fromImplementationError); }, closeIdleConnections() { - const result = server.closeIdleConnections(); - if (result.tag === "err") { - throw fromImplementationError(result.val); - } + callHost(() => server.closeIdleConnections(), fromImplementationError); }, getConnections() { - const result = server.getConnections(); - if (result.tag === "err") { - throw fromImplementationError(result.val); - } - return Number(result.val); + return Number(callHost(() => server.getConnections(), fromImplementationError)); }, address() { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts index 32514db6e..df9a45b52 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts @@ -1,6 +1,6 @@ import { concatBytes } from "../body.js"; import { STATUS_CODES } from "../constants.js"; -import { fromImplementationError } from "../errors.js"; +import { fromImplementationError, unsupported, wasiErrorCode } from "../errors.js"; import type { HttpHeaderField, HttpImplementation } from "../types.js"; export type WasiHttpMethod = @@ -131,17 +131,6 @@ function error(errorCode: WasiHttpErrorCode, syscall: string): Error { }); } -function streamErrorCode(value: unknown): string | undefined { - if (typeof value === "string") { - return value; - } - if (typeof value === "object" && value !== null && "tag" in value) { - const tag = (value as { tag?: unknown }).tag; - return typeof tag === "string" ? tag : undefined; - } - return undefined; -} - function method(value: string): WasiHttpMethod { const standard = new Set([ "get", @@ -222,7 +211,7 @@ function readBody(stream: WasiHttpInputStream): Uint8Array { try { chunks.push(stream.blockingRead(65_536n)); } catch (caught) { - if (streamErrorCode(caught) !== "closed") { + if (wasiErrorCode(caught) !== "closed") { throw caught; } return concatBytes(chunks); @@ -241,8 +230,23 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm request(request) { try { + const host = request.headers.find(({ name }) => name.toLowerCase() === "host"); + if ( + host && + new TextDecoder().decode(host.value).toLowerCase() !== request.authority.toLowerCase() + ) { + unsupported( + "http.request", + "wasi:http cannot override the Host header separately from the request authority", + ); + } const fields = provider.types.Fields.fromList( - request.headers.map(({ name, value }): [string, Uint8Array] => [name, value]), + // WASI carries Host in the authority and owns connection management. + request.headers + .filter( + ({ name }) => !["host", "connection", "keep-alive"].includes(name.toLowerCase()), + ) + .map(({ name, value }): [string, Uint8Array] => [name, value]), ); const outgoing = new provider.types.OutgoingRequest(fields); outgoing.setMethod(method(request.method)); @@ -300,7 +304,7 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm if (caught instanceof Error && "code" in caught) { throw caught; } - throw error({ tag: streamErrorCode(caught) ?? "internal-error" }, "request"); + throw error({ tag: wasiErrorCode(caught) ?? "internal-error" }, "request"); } }, }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts index 38d452eb9..f2ea2d178 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts @@ -1,5 +1,5 @@ import { concatBytes } from "../body.js"; -import { fromImplementationError, invalidArgValue, unsupported } from "../errors.js"; +import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../errors.js"; import { parseHttp1Request, parseHttp1Response, @@ -98,16 +98,7 @@ export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): vo resource?.[Symbol.dispose]?.(); } -export function errorCode(error: unknown): string | undefined { - if (typeof error === "string") { - return error; - } - if (typeof error === "object" && error !== null && "tag" in error) { - const tag = (error as { tag?: unknown }).tag; - return typeof tag === "string" ? tag : undefined; - } - return undefined; -} +export const errorCode = wasiErrorCode; export function socketError(error: unknown, syscall: string, hostname?: string): Error { const code = errorCode(error) ?? "unknown"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts index 8c948ab77..0b450e1e1 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts @@ -1,3 +1,4 @@ +import type { HostImports } from "../internal/wit-types.js"; import type { HostErrno, HostErrorBase } from "../internal/wit-types.js"; export type HttpHeaderValue = string | number | readonly string[]; @@ -246,12 +247,17 @@ export type DirectHttpIncomingRequest = HttpIncomingRequestData; export type DirectHttpOutgoingResponse = HttpOutgoingResponseData; +/** Exported resource methods use the JS return/throw convention. */ export interface DirectHttpRequestListener extends Disposable { handle( request: DirectHttpIncomingRequest, - ): - | DirectHttpResult - | Promise>; + ): DirectHttpOutgoingResponse | Promise; +} + +export interface DirectHttpCallbacks { + takeRequestListener( + id: number, + ): DirectHttpRequestListener | undefined | Promise; } export interface DirectHttpServer extends Disposable { @@ -266,7 +272,7 @@ export interface DirectHttpServer extends Disposable { } export interface DirectHttpServerConstructor { - new (options: DirectHttpServerOptions, listener: DirectHttpRequestListener): DirectHttpServer; + new (options: DirectHttpServerOptions, listener: number): HostImports; } export interface DirectHttpHost { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts index f2e4d8e36..0abb5a748 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts @@ -8,9 +8,15 @@ */ import { Buffer } from "node:buffer"; import * as nodeHttp2 from "node:http2"; +import { + CallbackResource, + createCallbackQueue, + retireCallbacks, +} from "./internal/callback-resource.js"; import { rawHeadersToFields, serializeNodeError } from "./internal/http-host.js"; import type { + DirectHttp2Callbacks, DirectHttp2ClientOptions, DirectHttp2RequestOptions, DirectHttp2Result, @@ -301,19 +307,16 @@ class NodeHttp2ClientSession { } class NodeHttp2Server { - readonly #errorListener: DirectHttp2ServerErrorListener; - readonly #listener: DirectHttp2StreamListener; + readonly #pending = new Set>(); readonly #server: nodeHttp2.Http2Server | nodeHttp2.Http2SecureServer; readonly #sessionIds = new WeakMap(); #nextSessionId = 1; constructor( options: DirectHttp2ServerOptions, - listener: DirectHttp2StreamListener, - errorListener: DirectHttp2ServerErrorListener, + handle: DirectHttp2StreamListener["handle"], + onError: DirectHttp2ServerErrorListener["handle"], ) { - this.#errorListener = errorListener; - this.#listener = listener; const common = { settings: nodeSettings(options.settings), allowHTTP1: options.allowHttp1, @@ -329,7 +332,10 @@ class NodeHttp2Server { this.#server.on("session", (session) => { this.#sessionIds.set(session, this.#nextSessionId++); }); - this.#server.on("error", (error) => errorListener.handle(serializeNodeError(error))); + // Listen failures are returned by listen(); session errors arrive independently. + this.#server.on("sessionError", (error) => { + this.#track(Promise.resolve().then(() => onError(serializeNodeError(error)))); + }); const onStream = async ( stream: nodeHttp2.ServerHttp2Stream, _headers: nodeHttp2.IncomingHttpHeaders, @@ -349,7 +355,7 @@ class NodeHttp2Server { offset += chunk.byteLength; } const session = stream.session; - const result = await listener.handle({ + const result = await handle({ sessionId: session ? (this.#sessionIds.get(session) ?? 0) : 0, id: stream.id ?? 0, headers: rawHeadersToFields(rawHeaders), @@ -357,21 +363,38 @@ class NodeHttp2Server { remoteAddress: session?.socket.remoteAddress, remotePort: session?.socket.remotePort, }); - if (result.tag === "err") { - throw Object.assign(new Error(result.val.message), result.val); + stream.respond(headerObject(result.headers)); + stream.end(result.body); + } catch (caught) { + const value = + typeof caught === "object" && caught !== null && "payload" in caught + ? caught.payload + : caught; + const serialized = serializeNodeError(value); + const error = + value instanceof Error ? value : Object.assign(new Error(serialized.message), serialized); + if (stream.destroyed) { + return; } - stream.respond(headerObject(result.val.headers)); - stream.end(result.val.body); - } catch (error) { if (!stream.headersSent) { stream.respond({ ":status": 500, "content-type": "text/plain; charset=utf-8" }); - stream.end(error instanceof Error ? error.message : String(error)); + stream.end(error.message); } else { - stream.destroy(error instanceof Error ? error : new Error(String(error))); + stream.destroy(error); } } }; - this.#server.on("stream", onStream); + this.#server.on("stream", (...args: Parameters) => + this.#track(onStream(...args)), + ); + } + + #track(pending: Promise): void { + this.#pending.add(pending); + void pending.then( + () => this.#pending.delete(pending), + () => this.#pending.delete(pending), + ); } async listen( @@ -416,14 +439,14 @@ class NodeHttp2Server { async close(): AsyncResult { const wasListening = this.#server.listening; - if (!wasListening) { - return { tag: "ok", val: false }; - } try { - await new Promise((resolve, reject) => { - this.#server.close((error) => (error ? reject(error) : resolve())); - }); - return { tag: "ok", val: true }; + if (wasListening) { + await new Promise((resolve, reject) => { + this.#server.close((error) => (error ? reject(error) : resolve())); + }); + } + await Promise.all(this.#pending); + return { tag: "ok", val: wasListening }; } catch (error) { return { tag: "err", val: serializeNodeError(error) }; } @@ -453,13 +476,58 @@ class NodeHttp2Server { [Symbol.dispose](): void { this.#server.close(); - this.#errorListener[Symbol.dispose](); - this.#listener[Symbol.dispose](); } } export const ClientSession = NodeHttp2ClientSession as unknown as import("./http2/types.js").DirectHttp2ClientSessionConstructor; -export const Server = NodeHttp2Server as unknown as DirectHttp2ServerConstructor; +export const ClientStream = NodeHttp2ClientStream; + +/** Bind callback resource redemption and invocation to one component instance. */ +export function createHttp2Host(callbacks: () => DirectHttp2Callbacks) { + const enqueue = createCallbackQueue(); + class Server extends NodeHttp2Server { + readonly #listener: CallbackResource; + readonly #errorListener: CallbackResource; + + constructor(options: DirectHttp2ServerOptions, listener: number, errorListener: number) { + const stream = new CallbackResource( + () => callbacks().takeStreamListener(listener), + "ERR_JCO_HTTP2_CALLBACK_NOT_FOUND", + ); + const error = new CallbackResource( + () => callbacks().takeServerErrorListener(errorListener), + "ERR_JCO_HTTP2_CALLBACK_NOT_FOUND", + ); + super( + options, + (incoming) => enqueue(async () => (await stream.get()).handle(incoming)), + (reason) => enqueue(async () => (await error.get()).handle(reason)), + ); + this.#listener = stream; + this.#errorListener = error; + } + + override async close(): AsyncResult { + const result = await super.close(); + if (result.tag === "ok") { + retireCallbacks(enqueue, this.#listener, this.#errorListener); + } + return result; + } + + override [Symbol.dispose](): void { + super[Symbol.dispose](); + void this.close(); + } + } + return { ClientSession, ClientStream, Server: Server as unknown as DirectHttp2ServerConstructor }; +} + +export const Server = class { + constructor() { + throw new Error("HTTP/2 servers require createHttp2Host(() => instance.http2Callbacks)"); + } +} as unknown as DirectHttp2ServerConstructor; -export default { ClientSession, Server }; +export default { ClientSession, ClientStream, Server, createHttp2Host }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts index a05e88e2f..46471f3fe 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts @@ -1,11 +1,12 @@ import * as host from "jco:node/http2@0.1.0"; import { createHttp2 } from "./http2/core.js"; -import { createDirectHttp2Implementation, http2Callbacks } from "./http2/impl/direct/index.js"; +import { createDirectHttp2Implementation } from "./http2/impl/direct/index.js"; -const http2 = createHttp2(createDirectHttp2Implementation(host)); +const implementation = createDirectHttp2Implementation(host); +const http2 = createHttp2(implementation); -export { http2Callbacks }; +export const http2Callbacks = implementation.http2Callbacks; export const connect = http2.connect; export const constants = http2.constants; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts index 97dbff993..2cf4ab1b5 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts @@ -19,7 +19,7 @@ export function createDirectHttp2Client( }); return { ready() { - const info = unwrap(session.ready()); + const info = unwrap(() => session.ready()); return { ...info, localSettings: fromDirectSettings(info.localSettings), @@ -27,23 +27,23 @@ export function createDirectHttp2Client( }; }, request(headers, requestOptions) { - const stream = unwrap(session.request(headers, requestOptions)); + const stream = unwrap(() => session.request(headers, requestOptions)); return { - write: (chunk) => unwrap(stream.write(chunk)), - finish: () => unwrap(stream.finish()), - close: (code) => unwrap(stream.close(code)), + write: (chunk) => unwrap(() => stream.write(chunk)), + finish: () => unwrap(() => stream.finish()), + close: (code) => unwrap(() => stream.close(code)), id: () => stream.id(), state: () => stream.state(), }; }, - close: () => unwrap(session.close()), - destroy: (code) => unwrap(session.destroy(code)), + close: () => unwrap(() => session.close()), + destroy: (code) => unwrap(() => session.destroy(code)), settings(value) { - return fromDirectSettings(unwrap(session.settings(toDirectSettings(value)))); + return fromDirectSettings(unwrap(() => session.settings(toDirectSettings(value)))); }, - ping: (payload) => unwrap(session.ping(payload)), + ping: (payload) => unwrap(() => session.ping(payload)), goaway: (code, lastStreamId, opaqueData) => - unwrap(session.goaway(code, lastStreamId, opaqueData)), + unwrap(() => session.goaway(code, lastStreamId, opaqueData)), ref: () => session.ref(), unref: () => session.unref(), }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts index 2b3a75ffc..93918d5ca 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts @@ -1,13 +1,15 @@ import type { DirectHttp2Host, Http2Implementation } from "../../types.js"; import { createDirectHttp2Client } from "./client.js"; -import { createDirectHttp2Server } from "./server.js"; +import { createDirectHttp2Server, createHttp2CallbackRegistry } from "./server.js"; -export { http2Callbacks } from "./server.js"; - -export function createDirectHttp2Implementation(host: DirectHttp2Host): Http2Implementation { +export function createDirectHttp2Implementation(host: DirectHttp2Host): Http2Implementation & { + http2Callbacks: ReturnType["exports"]; +} { + const registry = createHttp2CallbackRegistry(); return { + http2Callbacks: registry.exports, connect: (authority, options) => createDirectHttp2Client(host, authority, options), createServer: (secure, options, handler, onError) => - createDirectHttp2Server(host, secure, options, handler, onError), + createDirectHttp2Server(host, registry, secure, options, handler, onError), }; } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts index 346c09014..1a79b3342 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts @@ -1,4 +1,5 @@ -import { fromImplementationError } from "../../errors.js"; +import { serializeNodeError } from "../../../internal/http-host.js"; +import { codedError, fromImplementationError } from "../../errors.js"; import { toDirectSettings } from "../../settings.js"; import type { DirectHttp2Host, @@ -10,7 +11,7 @@ import type { } from "../../types.js"; import { tlsBytes, unwrap } from "./shared.js"; -class StreamListener implements DirectHttp2StreamListener { +export class StreamListener implements DirectHttp2StreamListener { readonly #handler: Http2StreamHandler; constructor(handler: Http2StreamHandler) { @@ -19,29 +20,16 @@ class StreamListener implements DirectHttp2StreamListener { async handle(stream: Parameters[0]) { try { - return { tag: "ok" as const, val: await this.#handler(stream) }; + return await this.#handler(stream); } catch (error) { - const value = - typeof error === "object" && error !== null ? (error as Record) : {}; - return { - tag: "err" as const, - val: { - name: typeof value.name === "string" ? value.name : "Error", - message: typeof value.message === "string" ? value.message : String(error), - code: typeof value.code === "string" ? value.code : undefined, - syscall: typeof value.syscall === "string" ? value.syscall : undefined, - hostname: typeof value.hostname === "string" ? value.hostname : undefined, - address: typeof value.address === "string" ? value.address : undefined, - port: typeof value.port === "number" ? value.port : undefined, - }, - }; + throw serializeNodeError(error); } } [Symbol.dispose](): void {} } -class ServerErrorListener implements DirectHttp2ServerErrorListener { +export class ServerErrorListener implements DirectHttp2ServerErrorListener { readonly #handler: (error: Error) => void; constructor(handler: (error: Error) => void) { @@ -55,15 +43,60 @@ class ServerErrorListener implements DirectHttp2ServerErrorListener { [Symbol.dispose](): void {} } -export const http2Callbacks = { ServerErrorListener, StreamListener }; +export function createHttp2CallbackRegistry() { + let nextId = 1; + const streams = new Map(); + const errors = new Map(); + return { + allocate() { + if (nextId > 0xffff_fffe) { + throw codedError( + "Error", + "ERR_JCO_HTTP2_CALLBACK_LIMIT", + "HTTP/2 callback registrations exhausted", + ); + } + return [nextId++, nextId++] as const; + }, + register( + listener: number, + errorListener: number, + handler: Http2StreamHandler, + onError: (error: Error) => void, + ) { + streams.set(listener, new StreamListener(handler)); + errors.set(errorListener, new ServerErrorListener(onError)); + }, + release(listener: number, errorListener: number) { + streams.delete(listener); + errors.delete(errorListener); + }, + exports: { + StreamListener, + ServerErrorListener, + takeStreamListener(id: number) { + const listener = streams.get(id); + streams.delete(id); + return listener; + }, + takeServerErrorListener(id: number) { + const listener = errors.get(id); + errors.delete(id); + return listener; + }, + }, + }; +} export function createDirectHttp2Server( host: DirectHttp2Host, + registry: ReturnType, secure: boolean, options: Http2ServerOptions, handler: Http2StreamHandler, onError: (error: Error) => void, ): Http2ServerImplementation { + const [listener, errorListener] = registry.allocate(); const server = new host.Server( { secure, @@ -73,8 +106,8 @@ export function createDirectHttp2Server( allowHttp1: options.allowHTTP1, strictFieldWhitespaceValidation: options.strictFieldWhitespaceValidation, }, - new StreamListener(handler), - new ServerErrorListener(onError), + listener, + errorListener, ); const address = (value: ReturnType) => value === undefined @@ -87,10 +120,22 @@ export function createDirectHttp2Server( } : value.val; return { - listen: (listenOptions) => address(unwrap(server.listen(listenOptions)))!, - close: () => unwrap(server.close()), + listen(listenOptions) { + registry.register(listener, errorListener, handler, onError); + try { + return address(unwrap(() => server.listen(listenOptions)))!; + } catch (error) { + registry.release(listener, errorListener); + throw error; + } + }, + close() { + const result = unwrap(() => server.close()); + registry.release(listener, errorListener); + return result; + }, address: () => address(server.address()), - updateSettings: (settings) => unwrap(server.updateSettings(toDirectSettings(settings))), + updateSettings: (settings) => unwrap(() => server.updateSettings(toDirectSettings(settings))), ref: () => server.ref(), unref: () => server.unref(), }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/shared.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/shared.ts index b851ce3fb..a00992839 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/shared.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/shared.ts @@ -1,12 +1,10 @@ +import { callHost } from "../../../internal/host-error.js"; import { bodyBytes } from "../../../http/body.js"; import { fromImplementationError } from "../../errors.js"; import type { DirectHttp2Result, Http2TlsMaterial } from "../../types.js"; -export function unwrap(result: DirectHttp2Result): T { - if (result.tag === "err") { - throw fromImplementationError(result.val); - } - return result.val; +export function unwrap(operation: () => T | DirectHttp2Result): T { + return callHost(operation, fromImplementationError); } export function tlsBytes(value: Http2TlsMaterial | undefined): Uint8Array | undefined { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts index 959995b4c..0d92bc5dd 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts @@ -255,13 +255,23 @@ export interface DirectHttp2ClientSessionConstructor { export interface DirectHttp2StreamListener extends Disposable { handle( stream: Http2IncomingStreamData, - ): - | DirectHttp2Result - | Promise>; + ): Http2OutgoingResponseData | Promise; } export interface DirectHttp2ServerErrorListener extends Disposable { - handle(reason: DirectHttp2Error): void; + handle(reason: DirectHttp2Error): void | Promise; +} + +export interface DirectHttp2Callbacks { + takeStreamListener( + id: number, + ): DirectHttp2StreamListener | undefined | Promise; + takeServerErrorListener( + id: number, + ): + | DirectHttp2ServerErrorListener + | undefined + | Promise; } export interface DirectHttp2Server extends Disposable { @@ -278,8 +288,8 @@ export interface DirectHttp2Server extends Disposable { export interface DirectHttp2ServerConstructor { new ( options: DirectHttp2ServerOptions, - listener: DirectHttp2StreamListener, - errorListener: DirectHttp2ServerErrorListener, + listener: number, + errorListener: number, ): DirectHttp2Server; } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/callback-resource.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/callback-resource.ts new file mode 100644 index 000000000..3d6e62ffc --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/callback-resource.ts @@ -0,0 +1,56 @@ +/** Serialize entry into one component's callback exports, including resource drops. */ +export function createCallbackQueue() { + let pending = Promise.resolve(); + return function enqueue(call: () => T | Promise): Promise { + const result = pending.then(call); + pending = result.then( + () => undefined, + () => undefined, + ); + return result; + }; +} + +/** Redeem a guest registration once and retain its exported resource until close. */ +export class CallbackResource { + #resource: T | undefined; + + constructor( + readonly take: () => T | undefined | Promise, + readonly missingCode: string, + ) {} + + // The provider must call get and the retired destructor through its component's callback queue. + async get(): Promise { + this.#resource ??= await this.take(); + if (!this.#resource) { + throw Object.assign(new Error("Callback registration is not active"), { + code: this.missingCode, + }); + } + return this.#resource; + } + + retire(): () => Promise { + const resource = this.#resource; + this.#resource = undefined; + return async () => { + await resource?.[Symbol.dispose](); + }; + } +} + +/** Let an imported close return to the guest before entering an exported destructor. */ +export function retireCallbacks( + enqueue: ReturnType, + ...resources: CallbackResource[] +): void { + const drops = resources.map((resource) => resource.retire()); + setTimeout(() => { + void enqueue(async () => { + for (const drop of drops) { + await drop(); + } + }); + }, 0); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts index a2fa31102..885956e27 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/deny-host.ts @@ -3,15 +3,14 @@ * * Declaring or injecting a `jco:node/*` WIT import must never grant host access on its own, so * every host-backed builtin ships a provider whose every operation refuses. This module owns the - * three refusal shapes so each `-host.ts` is only its member list; which shape a builtin uses + * refusal helpers so each `-host.ts` is only its member list; which shape a builtin uses * is dictated by its WIT contract, not by preference: * * - `denyThrow` -- for interfaces whose functions do not return `result`. The provider throws a * coded `Error`, which jco surfaces to the guest as an exception carrying * `ERR_JCO__ADAPTER_REQUIRED` (child-process, cluster, console, dns, fs, http). - * - `denyResult` -- for interfaces whose functions return `result` and whose guest side - * rebuilds errors from the record. The provider *returns* the `err` case rather than throwing, - * so the guest reports the refusal through the same path as any other host failure (os). + * - Interfaces returning `result` throw serialized error records, which the + * bindings lower into the `err` case; the guest reconstructs the Node error (os). * - `denyVariant` -- for interfaces whose functions return `result` with an * explicit `denied(string)` case. The provider throws the raw variant, which jco lowers back to * the same tagged object guest-side, and the guest maps `denied` to its adapter-required code @@ -23,8 +22,6 @@ import { codedError, type ErrorCode } from "../errors/core.js"; -import type { HostResult } from "./wit-types.js"; - /** The standard refusal message for a builtin whose host adapter has not been mapped. */ export function adapterRequiredMessage(specifier: string): string { return `${specifier} requires an application-provided host adapter`; @@ -37,11 +34,6 @@ export function denyThrow(code: ErrorCode, message: string): (...args: unknown[] }; } -/** A provider operation that returns the `err` case of a WIT `result` on every call. */ -export function denyResult(error: E): (...args: unknown[]) => HostResult { - return (): HostResult => ({ tag: "err", val: error }); -} - /** A provider operation that throws the WIT `variant error` `denied` case on every call. */ export function denyVariant(witInterface: string): (...args: unknown[]) => never { return () => { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/host-error.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/host-error.ts index 461563c53..431f672c0 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/host-error.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/host-error.ts @@ -51,6 +51,39 @@ export function serializeHostError(error: unknown): HostErrorBase { }; } +/** Read either representation of a synchronous WIT result without losing its error fields. */ +export function callHost( + operation: () => T | HostResult, + makeError: (error: E) => Error, +): T { + let result: T | HostResult; + try { + result = operation(); + } catch (error) { + const record = errorRecord(error); + const payload = errorRecord(record.payload ?? error); + // Bindings throw either the WIT error record or a ComponentError carrying it. + // Ordinary JS errors and runtime traps must retain their identity. + if ( + (!(error instanceof Error) || record.payload !== undefined) && + typeof payload.name === "string" && + typeof payload.message === "string" + ) { + throw makeError(payload as unknown as E); + } + throw error; + } + if (result !== null && typeof result === "object" && "tag" in result) { + if (result.tag === "err") { + throw makeError((result as { tag: "err"; val: E }).val); + } + if (result.tag === "ok") { + return (result as { tag: "ok"; val: T }).val; + } + } + return result as T; +} + /** Run a synchronous host operation, capturing a thrown error as a serialized `result`. */ export function capture( operation: () => T, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wit-types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wit-types.ts index 88f0fbb9b..567cebfc0 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wit-types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wit-types.ts @@ -10,6 +10,13 @@ /** A WIT `result` as jco lowers it. */ export type HostResult = { tag: "ok"; val: T } | { tag: "err"; val: E }; +/** A provider may use tagged results or the JS bindings' return/throw convention. */ +export type HostImports = { + [K in keyof H]: H[K] extends (...args: infer A) => infer R + ? (...args: A) => R | (R extends { tag: "ok"; val: infer T } ? T : never) + : H[K]; +}; + /** A WIT `variant errno { number(s64), symbolic(string) }`. */ export type HostErrno = { tag: "number"; val: bigint } | { tag: "symbolic"; val: string }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host-node.ts index a3967768d..14b794866 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host-node.ts @@ -13,7 +13,10 @@ import { serializeOsStaticProperties, serializeUserInfo, } from "./os/host-utils.js"; -import type { OsHost } from "./os/types.js"; +import type { OsHost as TaggedOsHost } from "./os/types.js"; +import type { HostImports } from "./internal/wit-types.js"; + +type OsHost = HostImports; export const getStaticProperties: OsHost["getStaticProperties"] = () => captureOsCall(() => serializeOsStaticProperties(nodeOs.EOL, nodeOs.devNull, nodeOs.constants)); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host.ts index 157b0f3a8..ef8c733a8 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-host.ts @@ -1,26 +1,28 @@ -import { adapterRequiredMessage, denyResult } from "./internal/deny-host.js"; +import { adapterRequiredMessage } from "./internal/deny-host.js"; import { POSIX_STATIC_PROPERTIES } from "./os/constants.js"; -import type { OsError, OsHost } from "./os/types.js"; +import type { OsError, OsHost as TaggedOsHost } from "./os/types.js"; +import type { HostImports } from "./internal/wit-types.js"; + +type OsHost = HostImports; /** - * Every `jco:node/os` operation returns `result`, so the refusal is returned as the - * `err` case rather than thrown: the guest rebuilds it through the same path as any host failure. + * 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 = denyResult({ - name: "Error", - message: adapterRequiredMessage("node:os"), - code: "ERR_JCO_OS_ADAPTER_REQUIRED", -}); +const denied = (): never => { + throw { + name: "Error", + message: adapterRequiredMessage("node:os"), + code: "ERR_JCO_OS_ADAPTER_REQUIRED", + } satisfies OsError; +}; /** * Static POSIX/WASI module values reveal no machine state and allow importing * the deny-by-default module. Every inspecting or mutating operation below is * denied until the application maps an explicit provider. */ -export const getStaticProperties: OsHost["getStaticProperties"] = () => ({ - tag: "ok", - val: POSIX_STATIC_PROPERTIES, -}); +export const getStaticProperties: OsHost["getStaticProperties"] = () => POSIX_STATIC_PROPERTIES; export const arch: OsHost["arch"] = denied; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-interface.d.ts index e7499c246..55ce5d226 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-interface.d.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os-interface.d.ts @@ -4,29 +4,28 @@ import type { OsHostLoadAverage, OsHostNetworkInterface, OsHostUserInfo, - OsResult, OsStaticProperties, Platform, } from "./os/types.js"; -export function getStaticProperties(): OsResult; -export function arch(): OsResult; -export function availableParallelism(): OsResult; -export function cpus(): OsResult; -export function endianness(): OsResult<"be" | "le">; -export function freemem(): OsResult; -export function getPriority(pid: number): OsResult; -export function homedir(): OsResult; -export function hostname(): OsResult; -export function loadavg(): OsResult; -export function machine(): OsResult; -export function networkInterfaces(): OsResult; -export function platform(): OsResult; -export function release(): OsResult; -export function setPriority(pid: number, priority: number): OsResult; -export function tmpdir(): OsResult; -export function totalmem(): OsResult; -export function type(): OsResult; -export function uptime(): OsResult; -export function userInfo(encoding?: string): OsResult; -export function version(): OsResult; +export function getStaticProperties(): OsStaticProperties; +export function arch(): Architecture; +export function availableParallelism(): number; +export function cpus(): OsHostCpuInfo[]; +export function endianness(): "be" | "le"; +export function freemem(): bigint; +export function getPriority(pid: number): number; +export function homedir(): string; +export function hostname(): string; +export function loadavg(): OsHostLoadAverage; +export function machine(): string; +export function networkInterfaces(): OsHostNetworkInterface[]; +export function platform(): Platform; +export function release(): string; +export function setPriority(pid: number, priority: number): void; +export function tmpdir(): string; +export function totalmem(): bigint; +export function type(): string; +export function uptime(): number; +export function userInfo(encoding?: string): OsHostUserInfo; +export function version(): string; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os.ts index 64e544756..4ee67d98a 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os.ts @@ -5,7 +5,6 @@ import { endianness as hostEndianness, freemem as hostFreemem, getPriority as hostGetPriority, - getStaticProperties as hostGetStaticProperties, homedir as hostHomedir, hostname as hostHostname, loadavg as hostLoadavg, @@ -23,9 +22,12 @@ import { } from "jco:node/os@0.1.0"; import { createOs } from "./os/core.js"; +import { POSIX_STATIC_PROPERTIES } from "./os/constants.js"; const os = createOs({ - getStaticProperties: hostGetStaticProperties, + // Module values describe the POSIX/WASI guest. Host imports cannot run during + // Wizer initialization; machine information is read by the functions at runtime. + getStaticProperties: () => POSIX_STATIC_PROPERTIES, arch: hostArch, availableParallelism: hostAvailableParallelism, cpus: hostCpus, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/core.ts index 8fccbd952..2c8301b29 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/core.ts @@ -9,7 +9,8 @@ import { Buffer } from "node:buffer"; import { invalidArgType, outOfRange, systemError } from "../errors.js"; -import { decodeErrno } from "../internal/host-error.js"; +import { callHost, decodeErrno } from "../internal/host-error.js"; +import type { HostImports } from "../internal/wit-types.js"; import type { Architecture, CpuInfo, @@ -79,11 +80,8 @@ function providerError(data: OsError): Error { return error; } -function unwrap(result: OsResult): T { - if (result.tag === "err") { - throw providerError(result.val); - } - return result.val; +function unwrap(operation: () => T | OsResult): T { + return callHost(operation, providerError); } function constantsRecord(entries: OsConstantEntry[]): Record { @@ -122,8 +120,8 @@ function userInfoValue(value: OsHostUserInfoValue): string | Buffer { } /** Build a Node-shaped OS module from a synchronous typed host provider. */ -export function createOs(host: OsHost): OsModule { - const staticProperties = unwrap(host.getStaticProperties()); +export function createOs(host: HostImports): OsModule { + const staticProperties = unwrap(() => host.getStaticProperties()); const constants: OsConstants = Object.assign(Object.create(null), { UV_UDP_REUSEADDR: staticProperties.constants.uvUdpReuseaddr, dlopen: constantsRecord(staticProperties.constants.dlopen), @@ -133,15 +131,15 @@ export function createOs(host: OsHost): OsModule { }); const arch = primitive(function arch(): Architecture { - return unwrap(host.arch()); + return unwrap(() => host.arch()); }); const availableParallelism = primitive(function availableParallelism(): number { - return unwrap(host.availableParallelism()); + return unwrap(() => host.availableParallelism()); }); function cpus(): CpuInfo[] { - return unwrap(host.cpus()).map((cpu) => ({ + return unwrap(() => host.cpus()).map((cpu) => ({ model: cpu.model, speed: cpu.speed, times: { @@ -155,37 +153,37 @@ export function createOs(host: OsHost): OsModule { } const endianness = primitive(function endianness(): "BE" | "LE" { - return unwrap(host.endianness()) === "be" ? "BE" : "LE"; + return unwrap(() => host.endianness()) === "be" ? "BE" : "LE"; }); const freemem = primitive(function freemem(): number { - return Number(unwrap(host.freemem())); + return Number(unwrap(() => host.freemem())); }); function getPriority(pid?: number): number { - return unwrap(host.getPriority(pid === undefined ? 0 : validateInt32(pid, "pid"))); + return unwrap(() => host.getPriority(pid === undefined ? 0 : validateInt32(pid, "pid"))); } const homedir = primitive(function homedir(): string { - return unwrap(host.homedir()); + return unwrap(() => host.homedir()); }); const hostname = primitive(function hostname(): string { - return unwrap(host.hostname()); + return unwrap(() => host.hostname()); }); function loadavg(): number[] { - const average = unwrap(host.loadavg()); + const average = unwrap(() => host.loadavg()); return [average.one, average.five, average.fifteen]; } const machine = primitive(function machine(): string { - return unwrap(host.machine()); + return unwrap(() => host.machine()); }); function networkInterfaces(): NetworkInterfaces { const result: NetworkInterfaces = {}; - for (const value of unwrap(host.networkInterfaces())) { + for (const value of unwrap(() => host.networkInterfaces())) { let address: NetworkInterfaceInfo; if (value.family === "ipv4") { address = { @@ -216,11 +214,11 @@ export function createOs(host: OsHost): OsModule { } const platform = primitive(function platform(): Platform { - return unwrap(host.platform()); + return unwrap(() => host.platform()); }); const release = primitive(function release(): string { - return unwrap(host.release()); + return unwrap(() => host.release()); }); function setPriority(priority: number): void; @@ -233,23 +231,23 @@ export function createOs(host: OsHost): OsModule { -20, 19, ); - unwrap(host.setPriority(pid, selectedPriority)); + unwrap(() => host.setPriority(pid, selectedPriority)); } const tmpdir = primitive(function tmpdir(): string { - return unwrap(host.tmpdir()); + return unwrap(() => host.tmpdir()); }); const totalmem = primitive(function totalmem(): number { - return Number(unwrap(host.totalmem())); + return Number(unwrap(() => host.totalmem())); }); const type = primitive(function type(): string { - return unwrap(host.type()); + return unwrap(() => host.type()); }); const uptime = primitive(function uptime(): number { - return unwrap(host.uptime()); + return unwrap(() => host.uptime()); }); function userInfo(options?: UserInfoOptionsWithStringEncoding): UserInfo; @@ -261,7 +259,7 @@ export function createOs(host: OsHost): OsModule { const candidate = (options as { encoding?: unknown }).encoding; encoding = typeof candidate === "string" ? candidate : undefined; } - const value = unwrap(host.userInfo(encoding)); + const value = unwrap(() => host.userInfo(encoding)); return { username: userInfoValue(value.username), uid: Number(value.uid), @@ -272,7 +270,7 @@ export function createOs(host: OsHost): OsModule { } const version = primitive(function version(): string { - return unwrap(host.version()); + return unwrap(() => host.version()); }); const os: OsModule = { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/host-utils.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/host-utils.ts index 20696ed29..d23e338d8 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/host-utils.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/os/host-utils.ts @@ -9,12 +9,10 @@ import type { OsHostNetworkInterface, OsHostUserInfo, OsHostUserInfoValue, - OsResult, OsStaticProperties, UserInfo, } from "./types.js"; import { - capture, encodeErrno, errorRecord, serializeHostError, @@ -40,8 +38,12 @@ export function serializeOsError(error: unknown): OsError { } /** Run a synchronous provider operation and preserve its structured error. */ -export function captureOsCall(operation: () => T): OsResult { - return capture(operation, serializeOsError); +export function captureOsCall(operation: () => T): T { + try { + return operation(); + } catch (error) { + throw serializeOsError(error); + } } function constantEntries(values: object): OsConstantEntry[] { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/implementation.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/implementation.ts index 9fed03f4c..e5134af8e 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/implementation.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/implementation.ts @@ -24,7 +24,7 @@ // replaced by explicit WASI providers, and Node's bundled minimatch is an // ordinary package dependency. This file is mechanically converted to // TypeScript while keeping the upstream algorithms and control flow intact. -import { Minimatch } from "minimatch"; +import { createMatcher } from "./matcher.cjs"; import { invalidArgType } from "../errors.js"; import type { FormatInputPathObject, PathModule, PathProviders } from "../path.js"; @@ -83,7 +83,7 @@ function validateObject(value: unknown, name: string): asserts value is FormatIn } } -const patternCache = new Map(); +const patternCache = new Map>(); function matchGlobPattern(path: unknown, pattern: unknown, windows: boolean): boolean { validateString(path, "path"); @@ -91,7 +91,7 @@ function matchGlobPattern(path: unknown, pattern: unknown, windows: boolean): bo const key = `${windows ? "win32" : "posix"}:${pattern}`; let matcher = patternCache.get(key); if (!matcher) { - matcher = new Minimatch(pattern, { + matcher = createMatcher(pattern, { nocase: false, windowsPathsNoEscape: true, nonegate: true, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/matcher.cjs b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/matcher.cjs new file mode 100644 index 000000000..404340cf3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/path/matcher.cjs @@ -0,0 +1,15 @@ +// brace-expansion initializes escape tokens with Math.random(). Keep its load +// inside the call so bundlers defer that initialization until matchesGlob runs, +// when WASI random imports are available, instead of during Wizer initialization. +// CommonJS preserves synchronous loading for Node's synchronous matchesGlob API. + +/** + * @param {string} pattern + * @param {import("minimatch").MinimatchOptions} options + * @returns {import("minimatch").Minimatch} + */ +exports.createMatcher = function createMatcher(pattern, options) { + // oxlint-disable-next-line typescript/no-require-imports -- defer synchronous dependency initialization + const { Minimatch } = require("minimatch"); + return new Minimatch(pattern, options); +}; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/abort/globals.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/abort/globals.ts new file mode 100644 index 000000000..65d7f369d --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/abort/globals.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; + +const original = { + Controller: globalThis.AbortController, + Signal: globalThis.AbortSignal, + any: AbortSignal.any, + abort: AbortSignal.abort, + controllerAbort: AbortController.prototype.abort, + throwIfAborted: AbortSignal.prototype.throwIfAborted, +}; +const globals = await import("../../../../../../src/wasi/0.2.x/node/24.x.x/abort-globals.js"); + +test("preserves conforming Node constructors and methods", () => { + expect(globals.AbortController).toBe(original.Controller); + expect(globals.AbortSignal).toBe(original.Signal); + expect(AbortSignal.any).toBe(original.any); + expect(AbortSignal.abort).toBe(original.abort); + expect(AbortController.prototype.abort).toBe(original.controllerAbort); + expect(AbortSignal.prototype.throwIfAborted).toBe(original.throwIfAborted); + const controller = new globals.AbortController(); + const combined = globals.AbortSignal.any([controller.signal]); + controller.abort(); + expect(combined.reason).toBe(controller.signal.reason); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dns/host-results.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dns/host-results.ts new file mode 100644 index 000000000..3eaa14803 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dns/host-results.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createDns } from "../../../../../../src/wasi/0.2.x/node/24.x.x/dns/core.js"; +import type { DnsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/dns/types.js"; +import type { HostImports } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wit-types.js"; +import { fakeDns } from "./helpers/index.js"; + +function provider(host: DnsHost, mode: string): HostImports { + if (mode === "tagged") { + return host; + } + return Object.fromEntries( + Object.entries(host).map(([name, operation]) => [ + name, + (...args: unknown[]) => { + const result = operation(...args); + if (result.tag === "err") { + throw mode === "ComponentError" + ? Object.assign(new Error("WIT error"), { payload: result.val }) + : result.val; + } + return result.val; + }, + ]), + ) as HostImports; +} + +describe.each(["tagged", "unwrapped", "ComponentError"])("node:dns host results: %s", (mode) => { + test("accepts server lists, lookup records, and resolver results", async () => { + const { host } = fakeDns(); + const { callback, promises } = createDns(provider(host, mode)); + const servers = callback.getServers(); + servers.push("198.51.100.53"); + expect(callback.getServers()).toEqual(["192.0.2.53"]); + callback.setServers(["198.51.100.53"]); + expect(callback.getServers()).toEqual(["198.51.100.53"]); + await expect(promises.lookup("example.test")).resolves.toEqual({ + address: "192.0.2.1", + family: 4, + }); + await expect(promises.lookupService("127.0.0.1", 80)).resolves.toEqual({ + hostname: "localhost", + service: "http", + }); + await expect(promises.resolve4("example.test", { ttl: true })).resolves.toEqual([ + { address: "192.0.2.1", ttl: 60 }, + ]); + await expect(promises.resolveTxt("example.test")).resolves.toEqual([]); + }); + + test("preserves error fields for synchronous, callback, and promise APIs", async () => { + const { host } = fakeDns(); + const error = { + name: "Error", + message: "lookup failed", + code: "ENOTFOUND", + errno: { tag: "symbolic" as const, val: "ENOTFOUND" }, + syscall: "getaddrinfo", + hostname: "missing.test", + }; + host.getServers.mockReturnValue({ tag: "err", val: error }); + host.lookup.mockReturnValue({ tag: "err", val: error }); + const { callback, promises } = createDns(provider(host, mode)); + const expected = expect.objectContaining({ ...error, errno: "ENOTFOUND" }); + expect(() => callback.getServers()).toThrow(expected); + const done = vi.fn(); + callback.lookup("missing.test", done); + expect(done).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(done.mock.calls[0][0]).toEqual(expected); + await expect(promises.lookup("missing.test")).rejects.toThrow(expected); + }); +}); + +test("node:dns preserves non-WIT provider errors", () => { + const { host } = fakeDns(); + const error = new WebAssembly.RuntimeError("unreachable"); + host.getServers.mockImplementation(() => { + throw error; + }); + expect(() => createDns(host).callback.getServers()).toThrow(error); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/host-results.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/host-results.ts new file mode 100644 index 000000000..5884b0d95 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/fs/host-results.ts @@ -0,0 +1,98 @@ +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; + +import { describe, expect, test } from "vitest"; + +import * as nodeHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/fs-host-node.js"; +import { createFsCore } from "../../../../../../src/wasi/0.2.x/node/24.x.x/fs/core.js"; +import type { FsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/fs/types.js"; +import type { HostImports } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wit-types.js"; +import { withFsFixture } from "../helpers/fs.js"; + +function unwrappedHost(componentError: boolean): HostImports { + return Object.fromEntries( + Object.entries(nodeHost) + .filter(([, value]) => typeof value === "function") + .map(([name, operation]) => [ + name, + (...args: unknown[]) => { + const result = (operation as (...args: unknown[]) => { tag: string; val: unknown })( + ...args, + ); + if (result.tag === "err") { + throw componentError + ? Object.assign(new Error("WIT error"), { payload: result.val }) + : result.val; + } + return result.val; + }, + ]), + ) as HostImports; +} + +describe.each([ + ["tagged", nodeHost], + ["unwrapped with thrown records", unwrappedHost(false)], + ["unwrapped with ComponentError payloads", unwrappedHost(true)], +] as const)("node:fs host results: %s", (_name, host) => { + const core = createFsCore(host); + + test("accepts void, boolean, byte-list, numeric, and record successes", async () => { + await withFsFixture((root) => { + const directory = join(root, "directory"); + const file = join(directory, "file.txt"); + expect(core.mkdirSync(directory)).toBeUndefined(); + expect(core.existsSync(file)).toBe(false); + core.writeFileSync(file, "hello"); + expect(core.readFileSync(file, "utf8")).toBe("hello"); + expect(core.statSync(file)?.isFile()).toBe(true); + const descriptor = core.openSync(file, "r"); + try { + expect(core.readSync(descriptor, new Uint8Array(1), 0, 1, 5)).toBe(0); + } finally { + core.closeSync(descriptor); + } + expect(core.readdirSync(directory)).toEqual(["file.txt"]); + expect(core.statSync(join(root, "absent"), { throwIfNoEntry: false })).toBeUndefined(); + }); + }); + + test("reconstructs Node error fields from the WIT error record", async () => { + await withFsFixture((root) => { + const missing = join(root, "missing"); + expect(() => core.readFileSync(missing)).toThrow( + expect.objectContaining({ + name: "Error", + code: "ENOENT", + errno: expect.any(Number), + syscall: "open", + path: missing, + }), + ); + }); + }); +}); + +test("node:fs leaves non-WIT exceptions and traps unchanged", () => { + for (const error of [ + new TypeError("provider bug"), + new WebAssembly.RuntimeError("unreachable"), + ]) { + const core = createFsCore({ + ...nodeHost, + mkdir: () => { + throw error; + }, + }); + expect(() => core.mkdirSync("unused")).toThrow(error); + } +}); + +test("node:fs reads byte arrays created in another realm", () => { + const data = runInNewContext("new Uint8Array([0, 104, 105, 0]).subarray(1, 3)") as Uint8Array; + const core = createFsCore({ ...nodeHost, readFile: () => data }); + expect(core.readFileSync("unused", "utf8")).toBe("hi"); + const copy = core.readFileSync("unused") as Uint8Array; + data[0] = 0; + expect(Array.from(copy)).toEqual([104, 105]); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts index df2ee2e80..13bcbf7d1 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts @@ -16,7 +16,6 @@ import { serializeHttp1Response, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/http1.js"; import type { - DirectHttpRequestListener, DirectHttpServer, DirectHttpServerOptions, HttpImplementationResponse, @@ -40,11 +39,11 @@ function clientResponse(): HttpImplementationResponse { } function directHarness(): HttpConformanceHarness { - let listener: DirectHttpRequestListener | undefined; + let listener: number | undefined; const implementation = createDirectHttpImplementation({ request: () => ({ tag: "ok", val: clientResponse() }), Server: class implements DirectHttpServer { - constructor(_options: DirectHttpServerOptions, requestListener: DirectHttpRequestListener) { + constructor(_options: DirectHttpServerOptions, requestListener: number) { listener = requestListener; } @@ -91,11 +90,7 @@ function directHarness(): HttpConformanceHarness { return { implementation, async dispatchServerRequest(request) { - const result = await listener!.handle(request); - if (result.tag === "err") { - throw Object.assign(new Error(result.val.message), result.val); - } - return result.val; + return implementation.httpCallbacks.takeRequestListener(listener!)!.handle(request); }, }; } diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/direct.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/direct.ts index 7ec4a28dd..c4377d371 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/direct.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/direct.ts @@ -5,7 +5,6 @@ import { createDirectHttpImplementation } from "../../../../../../src/wasi/0.2.x import type { DirectHttpHost, DirectHttpRequest, - DirectHttpRequestListener, DirectHttpServer, DirectHttpServerOptions, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; @@ -15,41 +14,224 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); describe("node:http direct implementation", () => { - test("passes a client request through the direct host interface", () => { - let received: DirectHttpRequest | undefined; - const expected = response("direct response"); + test("scopes registrations to an implementation and releases them on failed listen and close", async () => { + const incoming = { + method: "GET", + url: "/", + httpVersion: "1.1", + headers: [], + body: new Uint8Array(), + }; + let failListen = false; + let failClose = false; + let failConstruction = false; + const ids: number[] = []; + const host: DirectHttpHost = { + request() { + throw new Error("not used"); + }, + Server: class { + constructor(_options: DirectHttpServerOptions, id: number) { + ids.push(id); + if (failConstruction) { + throw new Error("construction failed"); + } + } + listen() { + if (failListen) { + throw new Error("listen failed"); + } + return { tag: "tcp" as const, val: { address: "127.0.0.1", family: "IPv4", port: 8080 } }; + } + close() { + if (failClose) { + throw new Error("close failed"); + } + return true; + } + closeAllConnections() {} + closeIdleConnections() {} + getConnections() { + return 0n; + } + address() { + return undefined; + } + ref() {} + unref() {} + [Symbol.dispose]() {} + }, + }; + const first = createDirectHttpImplementation(host); + const second = createDirectHttpImplementation(host); + let count = 0; + const a = first.createServer!({}, () => response(String(++count))); + const b = second.createServer!({}, () => response("second")); + expect(ids).toEqual([1, 1]); + const inactive = (implementation: typeof first, id = 1) => + expect(implementation.httpCallbacks.takeRequestListener(id)).toBeUndefined(); + await inactive(first); + failListen = true; + expect(() => a.listen({})).toThrow("listen failed"); + await inactive(first); + failListen = false; + a.listen({}); + b.listen({}); + const firstListener = first.httpCallbacks.takeRequestListener(1)!; + const secondListener = second.httpCallbacks.takeRequestListener(1)!; + await inactive(first); + await inactive(second); + expect(decoder.decode((await firstListener.handle(incoming)).body)).toBe("1"); + expect(decoder.decode((await secondListener.handle(incoming)).body)).toBe("second"); + failClose = true; + expect(() => a.close()).toThrow("close failed"); + expect(decoder.decode((await firstListener.handle(incoming)).body)).toBe("2"); + failClose = false; + a.close(); + a.close(); + await inactive(first); + expect(decoder.decode((await secondListener.handle(incoming)).body)).toBe("second"); + a.listen({}); + expect( + decoder.decode((await first.httpCallbacks.takeRequestListener(1)!.handle(incoming)).body), + ).toBe("3"); + a.close(); + b.close(); + await inactive(second); + failConstruction = true; + expect(() => first.createServer!({}, () => response("unused"))).toThrow("construction failed"); + await inactive(first, 2); + }); + + test("exports callback failures using the WIT return/throw convention", async () => { + let id: number; const implementation = createDirectHttpImplementation({ - request(options) { - received = options; - return { tag: "ok", val: expected }; + request() { + throw new Error("unused"); }, Server: class { - constructor() { - throw new Error("not used"); + constructor(_options: DirectHttpServerOptions, listener: number) { + id = listener; + } + listen() { + return { tag: "tcp", val: { address: "127.0.0.1", family: "IPv4", port: 8080 } }; + } + close() { + return true; } } as never, }); - const request: DirectHttpRequest = { - method: "POST", - scheme: "http", - authority: "example.com", - pathWithQuery: "/resource", - headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], - body: encoder.encode("payload"), - }; + const server = implementation.createServer!({}, async () => { + throw Object.assign(new Error("guest failure"), { code: "EACCES", errno: -13 }); + }); + server.listen({}); + await expect( + implementation.httpCallbacks.takeRequestListener(id!)!.handle({ + method: "GET", + url: "/", + httpVersion: "1.1", + headers: [], + body: new Uint8Array(), + }), + ).rejects.toMatchObject({ + name: "Error", + message: "guest failure", + code: "EACCES", + errno: { tag: "number", val: -13n }, + }); + server.close(); + }); - expect(implementation.request(request)).toBe(expected); - expect(received).toEqual(request); + test("accepts unwrapped server results and reconstructs thrown WIT errors", () => { + const error = { name: "Error", message: "listen denied", code: "EACCES" }; + const implementation = createDirectHttpImplementation({ + request() { + throw Object.assign(new Error("WIT error"), { payload: error }); + }, + Server: class { + listen() { + return { tag: "tcp" as const, val: { address: "127.0.0.1", family: "IPv4", port: 8080 } }; + } + close() { + return true; + } + closeAllConnections() {} + closeIdleConnections() { + throw error; + } + getConnections() { + return 2n; + } + address() { + return undefined; + } + ref() {} + unref() {} + [Symbol.dispose]() {} + }, + }); + const server = implementation.createServer!({}, async () => { + throw new Error("not called"); + }); + expect(server.listen({ port: 8080 })).toEqual({ + address: "127.0.0.1", + family: "IPv4", + port: 8080, + }); + expect(server.getConnections()).toBe(2); + expect(server.close()).toBe(true); + expect(server.closeAllConnections()).toBeUndefined(); + expect(() => server.closeIdleConnections()).toThrow(expect.objectContaining(error)); + expect(() => + implementation.request({ + method: "GET", + scheme: "http", + authority: "example.com", + pathWithQuery: "/", + headers: [], + body: new Uint8Array(), + }), + ).toThrow(expect.objectContaining(error)); }); - test("passes a guest request listener resource to the host Server resource", async () => { - let listener: DirectHttpRequestListener | undefined; + test.each(["tagged", "unwrapped"])( + "passes a client request through the %s direct host interface", + (representation) => { + let received: DirectHttpRequest | undefined; + const expected = response("direct response"); + const implementation = createDirectHttpImplementation({ + request(options) { + received = options; + return representation === "tagged" ? { tag: "ok" as const, val: expected } : expected; + }, + Server: class { + constructor() { + throw new Error("not used"); + } + } as never, + }); + const request: DirectHttpRequest = { + method: "POST", + scheme: "http", + authority: "example.com", + pathWithQuery: "/resource", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode("payload"), + }; + + expect(implementation.request(request)).toBe(expected); + expect(received).toEqual(request); + }, + ); + + test("dispatches a guest handler through its registered callback ID", async () => { + let listener: number | undefined; const host: DirectHttpHost = { request: () => { throw new Error("not used"); }, Server: class Server implements DirectHttpServer { - constructor(_options: DirectHttpServerOptions, requestListener: DirectHttpRequestListener) { + constructor(_options: DirectHttpServerOptions, requestListener: number) { listener = requestListener; } @@ -93,7 +275,8 @@ describe("node:http direct implementation", () => { [Symbol.dispose](): void {} }, }; - const http = createHttp(createDirectHttpImplementation(host)); + const implementation = createDirectHttpImplementation(host); + const http = createHttp(implementation); const server = http.createServer(async (request, response) => { request.setEncoding("utf8"); let body = ""; @@ -105,22 +288,16 @@ describe("node:http direct implementation", () => { }); server.listen(8080, "127.0.0.1"); - const result = await listener!.handle({ + const result = await implementation.httpCallbacks.takeRequestListener(listener!)!.handle({ method: "PUT", url: "/resource", httpVersion: "1.1", headers: [], body: encoder.encode("payload"), }); - expect(result).toMatchObject({ - tag: "ok", - val: { statusCode: 204, statusMessage: "No Content" }, - }); - if (result.tag === "ok") { - expect(result.val.headers).toEqual([ - { name: "X-Guest", value: encoder.encode("PUT payload") }, - ]); - expect(decoder.decode(result.val.body)).toBe(""); - } + expect(result).toMatchObject({ statusCode: 204, statusMessage: "No Content" }); + expect(result.headers).toEqual([{ name: "X-Guest", value: encoder.encode("PUT payload") }]); + expect(decoder.decode(result.body)).toBe(""); + server.close(); }); }); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/host.ts index 72ec87c76..688924425 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/host.ts @@ -1,9 +1,14 @@ import nodeHttp from "node:http"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; -import { Server, request } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; +import { + createHttpHost, + request, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; import type { + DirectHttpIncomingRequest, + DirectHttpOutgoingResponse, DirectHttpResult, DirectHttpServerAddress, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; @@ -43,25 +48,32 @@ async function listen(server: nodeHttp.Server): Promise { return address.port; } +function callbacks( + handle: (id: number, incoming: DirectHttpIncomingRequest) => Promise, +) { + const dispose = vi.fn(); + const takeRequestListener = vi.fn((id: number) => ({ + handle: (incoming: DirectHttpIncomingRequest) => handle(id, incoming), + [Symbol.dispose]: dispose, + })); + return { takeRequestListener, dispose }; +} + describe("node:http direct Node host", () => { - test("serves requests through a guest callback resource", async () => { - const server = new Server( - {}, - { - handle: async (incoming) => ({ - tag: "ok", - val: { - statusCode: 202, - statusMessage: "Accepted", - headers: [{ name: "Content-Type", value: new TextEncoder().encode("text/plain") }], - body: new TextEncoder().encode( - `${incoming.method} ${incoming.url} ${new TextDecoder().decode(incoming.body)}`, - ), - }, - }), - [Symbol.dispose]: () => undefined, - }, - ); + test("serves requests through an instance-bound guest callback resource", async () => { + const registry = callbacks(async (listener, incoming) => { + expect(listener).toBe(1); + return { + statusCode: 202, + statusMessage: "Accepted", + headers: [{ name: "Content-Type", value: new TextEncoder().encode("text/plain") }], + body: new TextEncoder().encode( + `${incoming.method} ${incoming.url} ${new TextDecoder().decode(incoming.body)}`, + ), + }; + }); + const { Server } = createHttpHost(() => registry); + const server = new Server({}, 1); const started = (await server.listen({ port: 0, host: "127.0.0.1", @@ -90,6 +102,103 @@ describe("node:http direct Node host", () => { expect(new TextDecoder().decode(result.val.body)).toBe("POST /resource hello"); } await server.close(); + expect(registry.takeRequestListener).toHaveBeenCalledTimes(1); + await expect.poll(() => registry.dispose.mock.calls.length).toBe(1); + }); + + test("serializes callbacks, recovers from WIT errors, and drains callbacks after sockets close", async () => { + let active = 0; + let peak = 0; + let calls = 0; + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const registry = callbacks(async (id, incoming) => { + expect(id).toBe(7); + active++; + peak = Math.max(peak, active); + calls++; + try { + if (incoming.url === "/error") { + throw Object.assign(new Error("component error"), { + payload: { name: "Error", message: "guest failed", code: "EIO" }, + }); + } + if (incoming.url === "/wait") { + enter(); + await gate; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + return { + statusCode: 200, + statusMessage: "OK", + headers: [], + body: new TextEncoder().encode(incoming.url), + }; + } finally { + active--; + } + }); + const { Server } = createHttpHost(() => registry); + const server = new Server({}, 7); + try { + const address = (await server.listen({ + port: 0, + host: "127.0.0.1", + })) as DirectHttpResult; + if (address.tag !== "ok" || address.val.tag !== "tcp") { + throw new Error("expected TCP address"); + } + const authority = `127.0.0.1:${address.val.val.port}`; + const send = (pathWithQuery: string) => + request({ + method: "GET", + scheme: "http", + authority, + pathWithQuery, + headers: [{ name: "Host", value: new TextEncoder().encode(authority) }], + body: new Uint8Array(), + }); + const results = await Promise.all([send("/error"), send("/one"), send("/two")]); + expect(results[0]).toMatchObject({ + tag: "ok", + val: { + statusCode: 500, + body: new TextEncoder().encode("guest failed"), + }, + }); + expect(results.slice(1)).toMatchObject([ + { tag: "ok", val: { statusCode: 200 } }, + { tag: "ok", val: { statusCode: 200 } }, + ]); + expect(calls).toBe(3); + expect(peak).toBe(1); + const waiting = send("/wait"); + await entered; + server.closeAllConnections(); + let closed = false; + const closing = Promise.resolve(server.close()).then((result) => { + closed = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + release(); + expect(await closing).toEqual({ tag: "ok", val: true }); + await waiting; + expect(active).toBe(0); + expect(calls).toBe(4); + } finally { + release(); + server.closeAllConnections(); + await server.close(); + server[Symbol.dispose](); + } }); test("performs the guest-boundary-shaped request through real node:http", async () => { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-http.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-http.ts index 4e09c875b..f7dd8622c 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-http.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-http.ts @@ -16,164 +16,192 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); describe("node:http wasi:http implementation", () => { - test.concurrent("translates a buffered exchange and disposes child streams before their bodies", () => { - const events: string[] = []; - let requestMethod: unknown; - let requestAuthority: string | undefined; - let requestPath: string | undefined; - let requestBody = new Uint8Array(); - - class Fields implements WasiHttpFields { - constructor(readonly values: Array<[string, Uint8Array]>) {} - - entries(): Array<[string, Uint8Array]> { - return this.values; - } + test("refuses a separate Host override instead of silently changing the request", () => { + const implementation = createWasiHttpImplementation({} as WasiHttpProvider); + expect(() => + implementation.request({ + method: "GET", + scheme: "http", + authority: "example.com", + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode("other.example") }], + body: new Uint8Array(), + }), + ).toThrow(expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" })); + }); - [Symbol.dispose](): void { - events.push("fields disposed"); - } - } + test.concurrent.each(["raw", "wrapped"])( + "translates a buffered exchange and disposes child streams with %s errors", + (representation) => { + const events: string[] = []; + let requestMethod: unknown; + let requestAuthority: string | undefined; + let requestPath: string | undefined; + let requestBody = new Uint8Array(); - const outgoingBody: WasiHttpOutgoingBody = { - write() { - return { - blockingWriteAndFlush(contents) { - requestBody = contents.slice(); - }, - [Symbol.dispose]() { - events.push("output disposed"); - }, - }; - }, - [Symbol.dispose]() { - events.push("outgoing body disposed"); - }, - }; + class Fields implements WasiHttpFields { + constructor(readonly values: Array<[string, Uint8Array]>) {} - class OutgoingRequest implements WasiHttpOutgoingRequest { - constructor(readonly fields: WasiHttpFields) {} + entries(): Array<[string, Uint8Array]> { + return this.values; + } - body(): WasiHttpOutgoingBody { - return outgoingBody; + [Symbol.dispose](): void { + events.push("fields disposed"); + } } - setMethod(method: unknown): void { - requestMethod = method; - } + const outgoingBody: WasiHttpOutgoingBody = { + write() { + return { + blockingWriteAndFlush(contents) { + requestBody = contents.slice(); + }, + [Symbol.dispose]() { + events.push("output disposed"); + }, + }; + }, + [Symbol.dispose]() { + events.push("outgoing body disposed"); + }, + }; - setScheme(): void {} + class OutgoingRequest implements WasiHttpOutgoingRequest { + constructor(readonly fields: WasiHttpFields) { + expect(fields.entries().map(([name]) => name)).toEqual(["X-Test"]); + } - setAuthority(authority: string | undefined): void { - requestAuthority = authority; - } + body(): WasiHttpOutgoingBody { + return outgoingBody; + } + + setMethod(method: unknown): void { + requestMethod = method; + } + + setScheme(): void {} + + setAuthority(authority: string | undefined): void { + requestAuthority = authority; + } - setPathWithQuery(path: string | undefined): void { - requestPath = path; + setPathWithQuery(path: string | undefined): void { + requestPath = path; + } + + [Symbol.dispose](): void { + events.push("request disposed"); + } } - [Symbol.dispose](): void { - events.push("request disposed"); + class RequestOptions implements WasiHttpRequestOptions { + setConnectTimeout(): void {} + + setFirstByteTimeout(): void {} + + setBetweenBytesTimeout(): void {} } - } - - class RequestOptions implements WasiHttpRequestOptions { - setConnectTimeout(): void {} - - setFirstByteTimeout(): void {} - - setBetweenBytesTimeout(): void {} - } - - const incomingBody: WasiHttpIncomingBody = { - stream() { - let complete = false; - return { - blockingRead() { - if (complete) { - throw { tag: "closed" }; - } - complete = true; - return encoder.encode("world"); - }, - [Symbol.dispose]() { - events.push("input disposed"); - }, - }; - }, - [Symbol.dispose]() { - events.push("incoming body disposed"); - }, - }; - const incoming: WasiHttpIncomingResponse = { - status: () => 201, - headers: () => new Fields([["X-Reply", encoder.encode("yes")]]), - consume: () => incomingBody, - [Symbol.dispose]() { - events.push("response disposed"); - }, - }; - let pending = true; - const provider: WasiHttpProvider = { - outgoingHandler: { - handle() { + + const incomingBody: WasiHttpIncomingBody = { + stream() { + let complete = false; return { - subscribe: () => ({ block: () => events.push("future blocked") }), - get() { - if (pending) { - pending = false; - return undefined; + blockingRead() { + if (complete) { + throw representation === "wrapped" + ? Object.assign(new Error("closed"), { payload: { tag: "closed" } }) + : { tag: "closed" }; } - return { tag: "ok", val: { tag: "ok", val: incoming } }; + complete = true; + return encoder.encode("world"); }, [Symbol.dispose]() { - events.push("future disposed"); + events.push("input disposed"); }, }; }, - }, - types: { - Fields: { fromList: (entries) => new Fields(entries) }, - IncomingBody: { - finish() { - events.push("incoming body finished"); + [Symbol.dispose]() { + events.push("incoming body disposed"); + }, + }; + const incoming: WasiHttpIncomingResponse = { + status: () => 201, + headers: () => new Fields([["X-Reply", encoder.encode("yes")]]), + consume: () => incomingBody, + [Symbol.dispose]() { + events.push("response disposed"); + }, + }; + let pending = true; + const provider: WasiHttpProvider = { + outgoingHandler: { + handle() { + return { + subscribe: () => ({ block: () => events.push("future blocked") }), + get() { + if (pending) { + pending = false; + return undefined; + } + return { tag: "ok", val: { tag: "ok", val: incoming } }; + }, + [Symbol.dispose]() { + events.push("future disposed"); + }, + }; }, }, - OutgoingBody: { - finish() { - events.push("outgoing body finished"); + types: { + Fields: { fromList: (entries) => new Fields(entries) }, + IncomingBody: { + finish() { + events.push("incoming body finished"); + }, }, + OutgoingBody: { + finish() { + events.push("outgoing body finished"); + }, + }, + OutgoingRequest, + RequestOptions, }, - OutgoingRequest, - RequestOptions, - }, - }; - - const response = createWasiHttpImplementation(provider).request({ - method: "POST", - scheme: "http", - authority: "example.com", - pathWithQuery: "/submit", - headers: [["X-Test", "yes"]].map(([name, value]) => ({ - name, - value: encoder.encode(value), - })), - body: encoder.encode("hello"), - connectTimeoutMs: 100, - }); - - expect(requestMethod).toEqual({ tag: "post" }); - expect(requestAuthority).toBe("example.com"); - expect(requestPath).toBe("/submit"); - expect(decoder.decode(requestBody)).toBe("hello"); - expect(response.statusCode).toBe(201); - expect(decoder.decode(response.body)).toBe("world"); - expect(events.indexOf("output disposed")).toBeLessThan( - events.indexOf("outgoing body finished"), - ); - expect(events.indexOf("input disposed")).toBeLessThan(events.indexOf("incoming body finished")); - expect(events).toContain("future blocked"); - }); + }; + + const response = createWasiHttpImplementation(provider).request({ + method: "POST", + scheme: "http", + authority: "example.com", + pathWithQuery: "/submit", + headers: [ + ["X-Test", "yes"], + ["Host", "example.com"], + ["Connection", "close"], + ["Keep-Alive", "timeout=5"], + ].map(([name, value]) => ({ + name, + value: encoder.encode(value), + })), + body: encoder.encode("hello"), + connectTimeoutMs: 100, + }); + + expect(requestMethod).toEqual({ tag: "post" }); + expect(requestAuthority).toBe("example.com"); + expect(requestPath).toBe("/submit"); + expect(decoder.decode(requestBody)).toBe("hello"); + expect(response.statusCode).toBe(201); + expect(decoder.decode(response.body)).toBe("world"); + expect(events.indexOf("output disposed")).toBeLessThan( + events.indexOf("outgoing body finished"), + ); + expect(events.indexOf("input disposed")).toBeLessThan( + events.indexOf("incoming body finished"), + ); + expect(events).toContain("future blocked"); + }, + ); test.concurrent("maps wasi:http failures to Node-style errors", () => { const provider = { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts index b53f877a2..1a7eb321f 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts @@ -91,77 +91,82 @@ describe("node:http wasi:sockets implementation", () => { server.close(); }); - test("resolves, connects, writes HTTP/1.1, and reads a response", () => { - const writes: Uint8Array[] = []; - let shutdown: string | undefined; - let connectBlocked = 0; - const input: WasiInputStream = { - blockingRead() { - return encoder.encode("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: yes\r\n\r\nok"); - }, - }; - const output: WasiOutputStream = { - blockingWriteAndFlush(contents) { - writes.push(contents.slice()); - }, - }; - const socket: WasiTcpSocket = { - startConnect(_network, address) { - expect(address).toEqual({ - tag: "ipv4", - val: { address: [192, 0, 2, 1], port: 8080 }, - }); - }, - finishConnect() { - if (connectBlocked++ === 0) { - throw { tag: "would-block" }; - } - return [input, output]; - }, - subscribe() { - return { block: () => undefined }; - }, - shutdown(direction) { - shutdown = direction; - }, - }; - const provider: WasiSocketsProvider = { - instanceNetwork: { instanceNetwork: () => ({}) }, - ipNameLookup: { - resolveAddresses() { - let yielded = false; - return { - resolveNextAddress() { - if (yielded) { - return undefined; - } - yielded = true; - return { tag: "ipv4", val: [192, 0, 2, 1] }; - }, - subscribe: () => ({ block: () => undefined }), - }; + test.each(["raw", "wrapped"])( + "resolves, connects, writes HTTP/1.1, and reads a response with %s errors", + (representation) => { + const writes: Uint8Array[] = []; + let shutdown: string | undefined; + let connectBlocked = 0; + const input: WasiInputStream = { + blockingRead() { + return encoder.encode("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: yes\r\n\r\nok"); }, - }, - tcpCreateSocket: { createTcpSocket: () => socket }, - }; - const response = createWasiSocketsHttpImplementation(provider).request({ - method: "GET", - scheme: "http", - authority: "example.com:8080", - pathWithQuery: "/", - headers: [{ name: "Host", value: encoder.encode("example.com:8080") }], - body: new Uint8Array(), - }); + }; + const output: WasiOutputStream = { + blockingWriteAndFlush(contents) { + writes.push(contents.slice()); + }, + }; + const socket: WasiTcpSocket = { + startConnect(_network, address) { + expect(address).toEqual({ + tag: "ipv4", + val: { address: [192, 0, 2, 1], port: 8080 }, + }); + }, + finishConnect() { + if (connectBlocked++ === 0) { + throw representation === "wrapped" + ? Object.assign(new Error("would-block"), { payload: "would-block" }) + : { tag: "would-block" }; + } + return [input, output]; + }, + subscribe() { + return { block: () => undefined }; + }, + shutdown(direction) { + shutdown = direction; + }, + }; + const provider: WasiSocketsProvider = { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { + resolveAddresses() { + let yielded = false; + return { + resolveNextAddress() { + if (yielded) { + return undefined; + } + yielded = true; + return { tag: "ipv4", val: [192, 0, 2, 1] }; + }, + subscribe: () => ({ block: () => undefined }), + }; + }, + }, + tcpCreateSocket: { createTcpSocket: () => socket }, + }; + const response = createWasiSocketsHttpImplementation(provider).request({ + method: "GET", + scheme: "http", + authority: "example.com:8080", + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode("example.com:8080") }], + body: new Uint8Array(), + }); - expect(connectBlocked).toBe(2); - expect(decoder.decode(writes[0])).toContain("GET / HTTP/1.1\r\n"); - expect(decoder.decode(response.body)).toBe("ok"); - expect(response.headers).toEqual([ - { name: "Content-Length", value: encoder.encode("2") }, - { name: "X-Test", value: encoder.encode("yes") }, - ]); - expect(shutdown).toBe("both"); - }); + expect(connectBlocked).toBe(2); + expect(decoder.decode(writes[0])).toContain("GET / HTTP/1.1\r\n"); + expect(decoder.decode(response.body)).toBe("ok"); + expect(response.headers).toEqual([ + { name: "Content-Length", value: encoder.encode("2") }, + { name: "X-Test", value: encoder.encode("yes") }, + ]); + expect(shutdown).toBe("both"); + }, + ); test("maps resolver failures to Node-style errors", () => { const provider: WasiSocketsProvider = { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts index ff5d62c0c..1e027c260 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts @@ -16,7 +16,6 @@ import { } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/hpack.js"; import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; import type { - DirectHttp2ServerErrorListener, DirectHttp2Settings, DirectHttp2StreamListener, Http2IncomingStreamData, @@ -27,67 +26,62 @@ const encoder = new TextEncoder(); function directHarness() { let listener: DirectHttp2StreamListener | undefined; + let id: number; + const implementation = createDirectHttp2Implementation({ + ClientSession: class { + ready = () => ({ + tag: "ok" as const, + val: { + alpnProtocol: "h2c", + encrypted: false, + localSettings: { customSettings: [] }, + remoteSettings: { customSettings: [] }, + }, + }); + request = () => ({ + tag: "ok" as const, + val: { + write: () => ({ tag: "ok" as const, val: true }), + finish: () => ({ + tag: "ok" as const, + val: { headers: [], trailers: [], body: encoder.encode("response") }, + }), + close: () => ({ tag: "ok" as const, val: undefined }), + id: () => 1, + state: () => ({}), + [Symbol.dispose](): void {}, + }, + }); + close = () => ({ tag: "ok" as const, val: undefined }); + destroy = () => ({ tag: "ok" as const, val: undefined }); + settings = (value: DirectHttp2Settings) => ({ tag: "ok" as const, val: value }); + ping = (payload: Uint8Array) => ({ tag: "ok" as const, val: { durationMs: 1, payload } }); + goaway = () => ({ tag: "ok" as const, val: undefined }); + ref(): void {} + unref(): void {} + [Symbol.dispose](): void {} + }, + Server: class { + constructor(_options: unknown, value: number, _errorListener: number) { + id = value; + } + listen = () => ({ + tag: "ok" as const, + val: { tag: "tcp" as const, val: { address: "127.0.0.1", family: "IPv4", port: 8080 } }, + }); + close = () => ({ tag: "ok" as const, val: true }); + address = () => undefined; + updateSettings = () => ({ tag: "ok" as const, val: undefined }); + ref(): void {} + unref(): void {} + [Symbol.dispose](): void {} + }, + }); return { - implementation: createDirectHttp2Implementation({ - ClientSession: class { - ready = () => ({ - tag: "ok" as const, - val: { - alpnProtocol: "h2c", - encrypted: false, - localSettings: { customSettings: [] }, - remoteSettings: { customSettings: [] }, - }, - }); - request = () => ({ - tag: "ok" as const, - val: { - write: () => ({ tag: "ok" as const, val: true }), - finish: () => ({ - tag: "ok" as const, - val: { headers: [], trailers: [], body: encoder.encode("response") }, - }), - close: () => ({ tag: "ok" as const, val: undefined }), - id: () => 1, - state: () => ({}), - [Symbol.dispose](): void {}, - }, - }); - close = () => ({ tag: "ok" as const, val: undefined }); - destroy = () => ({ tag: "ok" as const, val: undefined }); - settings = (value: DirectHttp2Settings) => ({ tag: "ok" as const, val: value }); - ping = (payload: Uint8Array) => ({ tag: "ok" as const, val: { durationMs: 1, payload } }); - goaway = () => ({ tag: "ok" as const, val: undefined }); - ref(): void {} - unref(): void {} - [Symbol.dispose](): void {} - }, - Server: class { - constructor( - _options: unknown, - value: DirectHttp2StreamListener, - _errorListener: DirectHttp2ServerErrorListener, - ) { - listener = value; - } - listen = () => ({ - tag: "ok" as const, - val: { tag: "tcp" as const, val: { address: "127.0.0.1", family: "IPv4", port: 8080 } }, - }); - close = () => ({ tag: "ok" as const, val: true }); - address = () => undefined; - updateSettings = () => ({ tag: "ok" as const, val: undefined }); - ref(): void {} - unref(): void {} - [Symbol.dispose](): void {} - }, - }), + implementation, async dispatch(stream: Http2IncomingStreamData) { - const result = await listener!.handle(stream); - if (result.tag === "err") { - throw new Error(result.val.message); - } - return result.val; + listener ??= implementation.http2Callbacks.takeStreamListener(id); + return listener!.handle(stream); }, }; } diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts index fdac90730..ddfd0cf02 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; import { createDirectHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.js"; import type { + DirectHttp2Callbacks, DirectHttp2ClientOptions, DirectHttp2RequestOptions, DirectHttp2ServerErrorListener, @@ -16,10 +17,16 @@ const encoder = new TextEncoder(); const emptySettings: DirectHttp2Settings = { customSettings: [] }; function fakeHost() { + let callbacks: DirectHttp2Callbacks; + let streamId: number; + let errorId: number; let errorListener: DirectHttp2ServerErrorListener | undefined; let listener: DirectHttp2StreamListener | undefined; let settings = emptySettings; return { + attach(value: DirectHttp2Callbacks) { + callbacks = value; + }, host: { ClientSession: class { constructor(_authority: string, options: DirectHttp2ClientOptions) { @@ -101,11 +108,11 @@ function fakeHost() { Server: class { constructor( _options: DirectHttp2ServerOptions, - streamListener: DirectHttp2StreamListener, - serverErrorListener: DirectHttp2ServerErrorListener, + streamListener: number, + serverErrorListener: number, ) { - errorListener = serverErrorListener; - listener = streamListener; + errorId = serverErrorListener; + streamId = streamListener; } listen() { @@ -135,18 +142,105 @@ function fakeHost() { }, }, async dispatch(stream: Http2IncomingStreamData) { + listener ??= await callbacks.takeStreamListener(streamId); return listener!.handle(stream); }, - emitServerError(message: string) { - errorListener!.handle({ name: "Error", message, code: "EHTTP2TEST" }); + async emitServerError(message: string) { + errorListener ??= await callbacks.takeServerErrorListener(errorId); + await errorListener!.handle({ name: "Error", message, code: "EHTTP2TEST" }); }, }; } describe("direct node:http2 implementation", () => { + test("redeems resources once and releases pending registrations on failed listen and close", async () => { + let failListen = true; + let failClose = false; + const ids: number[][] = []; + const host = { + ...fakeHost().host, + Server: class { + constructor(_options: DirectHttp2ServerOptions, stream: number, error: number) { + ids.push([stream, error]); + } + listen() { + if (failListen) { + throw new Error("listen failed"); + } + return { + tag: "ok" as const, + val: { tag: "tcp" as const, val: { address: "127.0.0.1", family: "IPv4", port: 8000 } }, + }; + } + close() { + if (failClose) { + throw new Error("close failed"); + } + return { tag: "ok" as const, val: true }; + } + address() { + return undefined; + } + updateSettings() { + return { tag: "ok" as const, val: undefined }; + } + ref() {} + unref() {} + [Symbol.dispose]() {} + }, + }; + const first = createDirectHttp2Implementation(host); + const second = createDirectHttp2Implementation(host); + const handler = async () => { + throw Object.assign(new Error("callback failed"), { code: "EHTTP2TEST" }); + }; + const a = first.createServer!(false, {}, handler, () => {}); + const b = second.createServer!(true, {}, handler, () => {}); + expect(ids).toEqual([ + [1, 2], + [1, 2], + ]); + const empty = (impl: typeof first) => { + expect(impl.http2Callbacks.takeStreamListener(1)).toBeUndefined(); + expect(impl.http2Callbacks.takeServerErrorListener(2)).toBeUndefined(); + }; + empty(first); + expect(() => a.listen({})).toThrow("listen failed"); + empty(first); + failListen = false; + a.listen({}); + b.listen({}); + const listener = first.http2Callbacks.takeStreamListener(1)!; + expect(first.http2Callbacks.takeStreamListener(1)).toBeUndefined(); + expect(first.http2Callbacks.takeStreamListener(2)).toBeUndefined(); + expect(first.http2Callbacks.takeServerErrorListener(1)).toBeUndefined(); + await expect( + listener.handle({ sessionId: 1, id: 1, headers: [], body: new Uint8Array() }), + ).rejects.toMatchObject({ + message: "callback failed", + code: "EHTTP2TEST", + }); + failClose = true; + expect(() => a.close()).toThrow("close failed"); + expect(first.http2Callbacks.takeServerErrorListener(2)).toBeDefined(); + failClose = false; + a.close(); + empty(first); + // Closing one implementation cannot remove another component's same-numbered resources. + expect(second.http2Callbacks.takeStreamListener(1)).toBeDefined(); + a.listen({}); + expect(first.http2Callbacks.takeStreamListener(1)).toBeDefined(); + a.close(); + b.close(); + empty(first); + empty(second); + }); + test("round trips client headers, data, settings, ping, and lifecycle", async () => { const harness = fakeHost(); - const http2 = createHttp2(createDirectHttp2Implementation(harness.host)); + const implementation = createDirectHttp2Implementation(harness.host); + harness.attach(implementation.http2Callbacks); + const http2 = createHttp2(implementation); const session = http2.connect("http://example.com", { settings: { enablePush: false } }); await new Promise((resolve) => session.once("connect", resolve)); expect(session.alpnProtocol).toBe("h2c"); @@ -187,7 +281,9 @@ describe("direct node:http2 implementation", () => { test("round trips stream and compatibility server callbacks", async () => { const harness = fakeHost(); - const http2 = createHttp2(createDirectHttp2Implementation(harness.host)); + const implementation = createDirectHttp2Implementation(harness.host); + harness.attach(implementation.http2Callbacks); + const http2 = createHttp2(implementation); const server = http2.createServer(); server.on("stream", (stream: { respond(headers: object): void; end(body: string): void }) => { stream.respond({ ":status": 202, "x-handler": "stream" }); @@ -203,13 +299,10 @@ describe("direct node:http2 implementation", () => { ], body: encoder.encode("request"), }); - expect(result.tag).toBe("ok"); - if (result.tag === "ok") { - expect(new TextDecoder().decode(result.val.body)).toBe("accepted"); - } + expect(new TextDecoder().decode(result.body)).toBe("accepted"); expect(server.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 8000 }); const sessionError = new Promise((resolve) => server.once("sessionError", resolve)); - harness.emitServerError("provider failure"); + await harness.emitServerError("provider failure"); await expect(sessionError).resolves.toMatchObject({ message: "provider failure", code: "EHTTP2TEST", @@ -221,7 +314,9 @@ describe("direct node:http2 implementation", () => { test("rejects unsupported options before constructing resources", () => { const harness = fakeHost(); - const http2 = createHttp2(createDirectHttp2Implementation(harness.host)); + const implementation = createDirectHttp2Implementation(harness.host); + harness.attach(implementation.http2Callbacks); + const http2 = createHttp2(implementation); expect(() => http2.connect("http://example.com", { createConnection() {} })).toThrow( expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), ); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts index 688b132ff..2e0515c72 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts @@ -1,11 +1,12 @@ import { readFile } from "node:fs/promises"; import * as nodeHttp2 from "node:http2"; +import { connect } from "node:net"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { ClientSession, - Server, + createHttp2Host, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2-host-node.js"; import type { DirectHttp2ClientSession, @@ -37,6 +38,31 @@ async function nativeRequest(authority: string, secure = false): Promise } describe("Node HTTP/2 host provider", () => { + test("redeems the error resource for a native server error and drops it after close", async () => { + const errorListener = { handle: vi.fn(), [Symbol.dispose]: vi.fn() }; + const takeStreamListener = vi.fn(() => undefined); + const takeServerErrorListener = vi.fn(() => errorListener); + const { Server } = createHttp2Host(() => ({ takeStreamListener, takeServerErrorListener })); + const server = new Server({ secure: false, settings: emptySettings }, 1, 2); + closeables.push(() => server[Symbol.dispose]()); + const address = await server.listen({ port: 0, host: "127.0.0.1" }); + if (address.tag !== "ok" || address.val.tag !== "tcp") { + throw new Error("missing address"); + } + const socket = connect(address.val.val.port, "127.0.0.1"); + closeables.push(() => socket.destroy()); + socket.resume(); + socket.end("invalid HTTP/2 preface\r\n\r\n"); + await expect.poll(() => errorListener.handle.mock.calls.length).toBe(1); + expect(errorListener.handle).toHaveBeenCalledWith( + expect.objectContaining({ code: "ERR_HTTP2_ERROR" }), + ); + expect(takeServerErrorListener).toHaveBeenCalledExactlyOnceWith(2); + expect(takeStreamListener).not.toHaveBeenCalled(); + await server.close(); + await expect.poll(() => errorListener[Symbol.dispose].mock.calls.length).toBe(1); + }); + test("uses a real h2c client session and stream", async () => { const server = nodeHttp2.createServer(); closeables.push(() => new Promise((resolve) => server.close(() => resolve()))); @@ -142,16 +168,13 @@ describe("Node HTTP/2 host provider", () => { : [undefined, undefined]; const listener: DirectHttp2StreamListener = { handle: async (stream) => ({ - tag: "ok", - val: { - headers: [ - { name: ":status", value: encoder.encode("202") }, - { name: "x-path", value: stream.headers.find(({ name }) => name === ":path")!.value }, - ], - body: encoder.encode(`callback:${decoder.decode(stream.body)}`), - }, + headers: [ + { name: ":status", value: encoder.encode("202") }, + { name: "x-path", value: stream.headers.find(({ name }) => name === ":path")!.value }, + ], + body: encoder.encode(`callback:${decoder.decode(stream.body)}`), }), - [Symbol.dispose](): void {}, + [Symbol.dispose]: vi.fn(), }; const errorListener: DirectHttp2ServerErrorListener = { handle(error): void { @@ -159,10 +182,19 @@ describe("Node HTTP/2 host provider", () => { }, [Symbol.dispose](): void {}, }; + const takeStreamListener = vi.fn((id: number) => { + expect(id).toBe(1); + return listener; + }); + const takeServerErrorListener = vi.fn((id: number) => { + expect(id).toBe(2); + return errorListener; + }); + const { Server } = createHttp2Host(() => ({ takeStreamListener, takeServerErrorListener })); const server = new Server( { secure, key, cert, settings: emptySettings }, - listener, - errorListener, + 1, + 2, ) as DirectHttp2Server; closeables.push(() => server[Symbol.dispose]()); const listened = await server.listen({ port: 0, host: "127.0.0.1" }); @@ -175,6 +207,9 @@ describe("Node HTTP/2 host provider", () => { nativeRequest(`${protocol}://127.0.0.1:${listened.val.val.port}`, secure), ).resolves.toBe("callback:request"); await expect(server.close()).resolves.toEqual({ tag: "ok", val: true }); + expect(takeStreamListener).toHaveBeenCalledTimes(1); + await expect.poll(() => vi.mocked(listener[Symbol.dispose]).mock.calls.length).toBe(1); + expect(takeServerErrorListener).not.toHaveBeenCalled(); }); test("maps connection failures into structured Node errors", async () => { diff --git a/packages/jco-std/wit/node-0.1.0/http.wit b/packages/jco-std/wit/node-0.1.0/http.wit index 484a06af3..2d20a4fbe 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -1,4 +1,4 @@ -/// Guest-owned callbacks used by the direct Node-compatible HTTP implementation. +/// Guest-owned callback resources used by the direct Node-compatible HTTP implementation. interface http-callbacks { variant errno { number(s64), @@ -41,12 +41,13 @@ interface http-callbacks { resource request-listener { handle: func(request: incoming-request) -> result; } + + /// Redeem a registration once. The host owns the returned listener until the server closes. + take-request-listener: func(id: u32) -> option; } /// Typed host boundary for Node-compatible HTTP clients and servers. interface http { - use http-callbacks.{request-listener}; - variant errno { number(s64), symbolic(string), @@ -128,7 +129,7 @@ interface http { request: func(options: request-options) -> result; resource server { - constructor(options: server-options, listener: own); + constructor(options: server-options, listener: u32); listen: func(options: listen-options) -> result; close: func() -> result; close-all-connections: func() -> result<_, error>; diff --git a/packages/jco-std/wit/node-0.1.0/http2.wit b/packages/jco-std/wit/node-0.1.0/http2.wit index ae4c215f3..3fc4db7c8 100644 --- a/packages/jco-std/wit/node-0.1.0/http2.wit +++ b/packages/jco-std/wit/node-0.1.0/http2.wit @@ -42,12 +42,14 @@ interface http2-callbacks { resource server-error-listener { handle: func(reason: error); } + + /// Redeem each registration once; the host owns the resources until the server closes. + take-stream-listener: func(id: u32) -> option; + take-server-error-listener: func(id: u32) -> option; } /// Typed host boundary for Node-compatible HTTP/2 clients and servers. interface http2 { - use http2-callbacks.{server-error-listener, stream-listener}; - variant errno { number(s64), symbolic(string), @@ -176,8 +178,8 @@ interface http2 { resource server { constructor( options: server-options, - listener: own, - error-listener: own, + listener: u32, + error-listener: u32, ); listen: func(options: listen-options) -> result; close: func() -> result; diff --git a/packages/jco-std/wit/node-0.1.0/os.wit b/packages/jco-std/wit/node-0.1.0/os.wit index f784e7f94..3598fd4e4 100644 --- a/packages/jco-std/wit/node-0.1.0/os.wit +++ b/packages/jco-std/wit/node-0.1.0/os.wit @@ -62,9 +62,9 @@ interface os { } record load-average { - one: float64, - five: float64, - fifteen: float64, + one: f64, + five: f64, + fifteen: f64, } enum network-family { @@ -88,7 +88,7 @@ interface os { bytes(list), } - record user-info { + record user-info-record { username: user-info-value, uid: s64, gid: s64, @@ -113,8 +113,8 @@ interface os { set-priority: func(pid: s32, priority: s32) -> result<_, error>; tmpdir: func() -> result; totalmem: func() -> result; - type: func() -> result; - uptime: func() -> result; - user-info: func(encoding: option) -> result; + %type: func() -> result; + uptime: func() -> result; + user-info: func(encoding: option) -> result; version: func() -> result; } diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit index 32a1fce00..3e17b780e 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit @@ -1,6 +1,6 @@ package jco:node@0.1.0; -/// Guest-owned callbacks used by the direct Node-compatible HTTP implementation. +/// Guest-owned callback resources used by the direct Node-compatible HTTP implementation. interface http-callbacks { variant errno { number(s64), @@ -43,12 +43,13 @@ interface http-callbacks { resource request-listener { handle: func(request: incoming-request) -> result; } + + /// Redeem a registration once. The host owns the returned listener until the server closes. + take-request-listener: func(id: u32) -> option; } /// Typed host boundary for Node-compatible HTTP clients and servers. interface http { - use http-callbacks.{request-listener}; - variant errno { number(s64), symbolic(string), @@ -130,7 +131,7 @@ interface http { request: func(options: request-options) -> result; resource server { - constructor(options: server-options, listener: own); + constructor(options: server-options, listener: u32); listen: func(options: listen-options) -> result; close: func() -> result; close-all-connections: func() -> result<_, error>; diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit index ae4c215f3..3fc4db7c8 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit @@ -42,12 +42,14 @@ interface http2-callbacks { resource server-error-listener { handle: func(reason: error); } + + /// Redeem each registration once; the host owns the resources until the server closes. + take-stream-listener: func(id: u32) -> option; + take-server-error-listener: func(id: u32) -> option; } /// Typed host boundary for Node-compatible HTTP/2 clients and servers. interface http2 { - use http2-callbacks.{server-error-listener, stream-listener}; - variant errno { number(s64), symbolic(string), @@ -176,8 +178,8 @@ interface http2 { resource server { constructor( options: server-options, - listener: own, - error-listener: own, + listener: u32, + error-listener: u32, ); listen: func(options: listen-options) -> result; close: func() -> result; diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/os.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/os.wit index f784e7f94..3598fd4e4 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/os.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/os.wit @@ -62,9 +62,9 @@ interface os { } record load-average { - one: float64, - five: float64, - fifteen: float64, + one: f64, + five: f64, + fifteen: f64, } enum network-family { @@ -88,7 +88,7 @@ interface os { bytes(list), } - record user-info { + record user-info-record { username: user-info-value, uid: s64, gid: s64, @@ -113,8 +113,8 @@ interface os { set-priority: func(pid: s32, priority: s32) -> result<_, error>; tmpdir: func() -> result; totalmem: func() -> result; - type: func() -> result; - uptime: func() -> result; - user-info: func(encoding: option) -> result; + %type: func() -> result; + uptime: func() -> result; + user-info: func(encoding: option) -> result; version: func() -> result; } diff --git a/packages/jco/package.json b/packages/jco/package.json index 428edab0b..1b5242cba 100644 --- a/packages/jco/package.json +++ b/packages/jco/package.json @@ -66,8 +66,8 @@ "dependencies": { "@bytecodealliance/componentize-js": "^0.22.0", "@bytecodealliance/componentize-js-0-19-3": "npm:@bytecodealliance/componentize-js@^0.19.3", - "@bytecodealliance/jco-std": "^0.2.1", - "@bytecodealliance/jco-transpile": "^0.12.1", + "@bytecodealliance/jco-std": "workspace:*", + "@bytecodealliance/jco-transpile": "^0.13.0", "@bytecodealliance/preview2-shim": "^0.24.1", "binaryen": "^130.0.0", "commander": "^14", diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index abebaf915..1a4e20b1b 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -8,7 +8,25 @@ declare const __vite_ssr_import_meta__: ImportMeta; declare const globalCreateRequire: typeof import("node:module").createRequire; const DNS_CAPABILITY = "jco:node/dns@0.1.0"; -const DNS_ASYNC_IMPORT = `${DNS_CAPABILITY}#*`; +// Async selectors support exact function names, not interface-scoped wildcards. +const DNS_ASYNC_IMPORTS = [ + "lookup", + "lookup-service", + "resolve4", + "resolve6", + "resolve-any", + "resolve-caa", + "resolve-cname", + "resolve-mx", + "resolve-naptr", + "resolve-ns", + "resolve-ptr", + "resolve-soa", + "resolve-srv", + "resolve-tlsa", + "resolve-txt", + "reverse", +].map((name) => `${DNS_CAPABILITY}#${name}`); const HTTP_CAPABILITY = "jco:node/http@0.1.0"; const HTTP_ASYNC_IMPORTS = [ `${HTTP_CAPABILITY}#request`, @@ -57,7 +75,9 @@ export function withDefaultNodeCapabilities(opts: TranspileOpts): TranspileOpts // The Node DNS provider returns a promise. JSPI suspends its synchronous // Preview 2 WIT import, and every possibly-transitive export is promising. opts.asyncMode = "jspi"; - opts.asyncImports = appendUnique(opts.asyncImports, DNS_ASYNC_IMPORT); + for (const asyncImport of DNS_ASYNC_IMPORTS) { + opts.asyncImports = appendUnique(opts.asyncImports, asyncImport); + } opts.asyncExports = appendUnique(opts.asyncExports, "*"); } const hasAsyncHttpProvider = diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 0cd59ff0d..925d5f8e1 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -65,6 +65,8 @@ const INSPECTOR_CALLBACKS_MODULE = `${VIRTUAL_PREFIX}inspector-callbacks`; const HTTP_CALLBACKS_MODULE = `${VIRTUAL_PREFIX}http-callbacks`; const HTTP2_CALLBACKS_MODULE = `${VIRTUAL_PREFIX}http2-callbacks`; const UNENV_BUFFER_CORE = `${VIRTUAL_PREFIX}unenv-buffer-core`; +const ABORT_GLOBALS_SPECIFIER = "jco:node-abort-globals"; +const ABORT_GLOBALS_MODULE = `${VIRTUAL_PREFIX}abort-globals`; const ERROR_GLOBALS_SPECIFIER = "jco:node-error-globals"; const ERROR_GLOBALS_MODULE = `${VIRTUAL_PREFIX}error-globals`; @@ -87,6 +89,8 @@ export interface NodeErrorGlobalsOptions { } export interface NodeGlobalsOptions extends NodeErrorGlobalsOptions { + /** Path to the native Abort globals compatibility adapter (overridable for tests). */ + abortGlobalsModule?: string; /** Path to Jco's audited `node:buffer` adapter (overridable for tests). */ bufferModule?: string; } @@ -107,12 +111,15 @@ export function nodeErrorGlobals( /** * Rolldown injection map for Node globals backed by Jco implementations. * - * Web globals already supplied by the component engine are intentionally absent. + * Web globals are supplied by the engine; Abort globals have a compatibility adapter + * for engines with the legacy variadic AbortSignal.any implementation. * Rolldown includes these adapters only when their free identifiers survive bundling. */ export function nodeGlobals(options: NodeGlobalsOptions = {}): Record { return { ...nodeErrorGlobals(options), + AbortController: [options.abortGlobalsModule ?? ABORT_GLOBALS_SPECIFIER, "AbortController"], + AbortSignal: [options.abortGlobalsModule ?? ABORT_GLOBALS_SPECIFIER, "AbortSignal"], Buffer: [options.bufferModule ?? "node:buffer", "Buffer"], }; } @@ -273,6 +280,8 @@ export interface NodeBuiltinOptions { moduleModule?: string; /** Path to jco-std's versioned `node:diagnostics_channel` module (overridable for tests) */ diagnosticsChannelModule?: string; + /** Path to the native Abort globals compatibility adapter (overridable for tests). */ + abortGlobalsModule?: string; /** Path to jco-std's versioned Errors globals module (overridable for tests) */ errorsModule?: string; /** Path to jco-std's versioned `node:events` module (overridable for tests) */ @@ -1006,6 +1015,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui const fsModule = () => stdModule(options.fsModule, "fs"); const fsPromisesModule = () => stdModule(options.fsPromisesModule, "fs/promises"); const errorsModule = () => stdModule(options.errorsModule, "errors"); + const abortGlobalsModule = () => stdModule(options.abortGlobalsModule, "abort-globals"); const dnsModule = (specifier: string) => specifier === "node:dns/promises" ? stdModule(options.dnsPromisesModule, "dns/promises") @@ -1037,6 +1047,9 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (id.startsWith(VIRTUAL_PREFIX)) { return id; } + if (id === ABORT_GLOBALS_SPECIFIER) { + return ABORT_GLOBALS_MODULE; + } if (id === ERROR_GLOBALS_SPECIFIER) { return ERROR_GLOBALS_MODULE; } @@ -1172,6 +1185,9 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (id === UNENV_BUFFER_CORE) { return unenvBufferCore(options); } + if (id === ABORT_GLOBALS_MODULE) { + return `export * from ${JSON.stringify(abortGlobalsModule())};`; + } if (id === ERROR_GLOBALS_MODULE) { return `export * from ${JSON.stringify(errorsModule())};`; } diff --git a/packages/jco/test/api.js b/packages/jco/test/api.js index 2f6e145ed..936724b46 100644 --- a/packages/jco/test/api.js +++ b/packages/jco/test/api.js @@ -28,7 +28,9 @@ const isWindows = platform === "win32"; // - (2026/06/17) increased due to updated binaryen // - (2026/07/06) increased due to updated jco-transpile (terser -> oxc-minify) // - (2026/08/11) increased due to transpile fixes -const FLAVORFUL_WASM_TRANSPILED_CODE_CHAR_LIMIT = 190_000; +// - (2026/09/08) jco-transpile 0.13 adds scheduler/cancellation, borrow tracking, +// and trap handling: 181,694 -> 194,146 bytes with the same Binaryen and minifier. +const FLAVORFUL_WASM_TRANSPILED_CODE_CHAR_LIMIT = 200_000; suite("API", () => { let flavorfulWasmBytes; @@ -150,15 +152,16 @@ suite("API", () => { } const meta = await metadataShow(newComponent); + // wit-component 0.258 folds the start shim into the fixup module. assert.deepStrictEqual(meta[0].metaType, { tag: "component", - val: 5, + val: 4, }); assert.deepStrictEqual(meta[1].producers, [ [ "processed-by", [ - ["wit-component", "0.254.0"], + ["wit-component", "0.258.0"], ["dummy-gen", "test"], ], ], @@ -191,16 +194,17 @@ suite("API", () => { } const meta = await metadataShow(newComponent); + // wit-component 0.258 folds the start shim into the fixup module. assert.deepStrictEqual(meta[0].metaType, { tag: "component", - val: 5, + val: 4, }); assert.deepStrictEqual(meta[1].producers, [ [ "processed-by", [ // NOTE: this is the current version *in the released jco-transpile* jco uses - ["wit-component", "0.254.0"], + ["wit-component", "0.258.0"], ["dummy-gen", "test"], ], ], diff --git a/packages/jco/test/cli.js b/packages/jco/test/cli.js index 68b5f8d5d..e90ad2812 100644 --- a/packages/jco/test/cli.js +++ b/packages/jco/test/cli.js @@ -553,22 +553,16 @@ suite("CLI", () => { const { stdout, stderr } = await exec(jcoPath, "metadata-show", outFile, "--json"); assert.strictEqual(stderr, ""); const meta = JSON.parse(stdout); - // NOTE: the check below is depends on *how many* modules *and* components are - // generated by wit-component (as used by the wasm-tools rust dep in this project) - // and componentize-js. - // - // As such, this is subject to optimizations or changes in operation of - // upstream functionality and may change with upstream releases -- for example - // the addition of a "glue" or redirection-heavy module/component + // wit-component 0.258 folds the start shim into the fixup module. assert.deepStrictEqual(meta[0].metaType, { tag: "component", - val: 5, + val: 4, }); assert.deepStrictEqual(meta[1].producers, [ [ "processed-by", [ - ["wit-component", "0.254.0"], + ["wit-component", "0.258.0"], ["dummy-gen", "test"], ], ], diff --git a/packages/jco/test/common.js b/packages/jco/test/common.js index ee9b9699c..9ef035ee1 100644 --- a/packages/jco/test/common.js +++ b/packages/jco/test/common.js @@ -11,6 +11,10 @@ export const LINTER_PATH = fileURLToPath(new URL("../../../node_modules/oxlint/b export const AsyncFunction = (async () => {}).constructor; +// Node 22's experimental JSPI exposes the older Suspender API, which cannot +// instantiate bindings that use promising/Suspending, even with its JSPI flag. +export const hasJspi = typeof WebAssembly.promising === "function" && typeof WebAssembly.Suspending === "function"; + /** Path to Jco JS script */ export const JCO_JS_PATH = fileURLToPath(new URL("../dist/jco.js", import.meta.url)); diff --git a/packages/jco/test/fixtures/componentize/helpers/wasi-sockets.js b/packages/jco/test/fixtures/componentize/helpers/wasi-sockets.js new file mode 100644 index 000000000..bb7b9dad2 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/helpers/wasi-sockets.js @@ -0,0 +1,13 @@ +// Adapt the canonical Preview 2 resources to ComponentizeJS's WIT projections. +export function withWasiSockets(imports) { + Object.assign(imports["wasi:sockets/instance-network"], imports["wasi:sockets/network"]); + Object.assign(imports["wasi:sockets/ip-name-lookup"], imports["wasi:sockets/network"]); + Object.assign(imports["wasi:sockets/tcp-create-socket"], imports["wasi:sockets/tcp"]); + // The StarlingMonkey fixture adds a method to the otherwise methodless Network + // resource to make ComponentizeJS 0.22 generate bindings for it. + imports["wasi:sockets/network"].Network.prototype.noop ??= () => {}; + // Required by StarlingMonkey's 0.2.10 WIT, removed in preview2-shim's 0.2.12. + // These fixtures never call it. + imports["wasi:sockets/network"].networkErrorCode ??= () => undefined; + return imports; +} diff --git a/packages/jco/test/fixtures/componentize/node-console/run.js b/packages/jco/test/fixtures/componentize/node-console/run.js index 9b3793879..96c2af8ef 100644 --- a/packages/jco/test/fixtures/componentize/node-console/run.js +++ b/packages/jco/test/fixtures/componentize/node-console/run.js @@ -4,5 +4,8 @@ import { pathToFileURL } from "node:url"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; const { instantiate } = await import(pathToFileURL(argv[2])); -const instance = await instantiate(undefined, new WASIShim().getImportObject()); +// Explicit instantiation requires providing the mapped console capability. +const imports = new WASIShim().getImportObject(); +imports[argv[3]] = await import(argv[3]); +const instance = await instantiate(undefined, imports); stdout.write(`RESULT:${JSON.stringify(instance.run())}\n`); diff --git a/packages/jco/test/fixtures/componentize/node-globals/source.js b/packages/jco/test/fixtures/componentize/node-globals/source.js index 235e91597..8bbd8165d 100644 --- a/packages/jco/test/fixtures/componentize/node-globals/source.js +++ b/packages/jco/test/fixtures/componentize/node-globals/source.js @@ -64,7 +64,92 @@ async function testTimers() { return { cancelledTimeoutRan, intervalRan, microtaskRan }; } +async function testAbortSignals() { + const reasons = ["stopped", 42, null, undefined, { cancelled: true }, Symbol("reason"), NaN]; + let reasonIdentity = true; + let thrownIdentity = true; + let dependencyOrder = true; + let oneEvent = true; + for (const reason of reasons) { + const controller = new AbortController(); + let combined; + // Native dependencies must abort even before earlier source listeners run. + controller.signal.addEventListener("abort", () => { + dependencyOrder &&= combined.aborted; + }); + combined = AbortSignal.any([controller.signal, controller.signal]); + let events = 0; + combined.addEventListener("abort", () => events++); + combined.throwIfAborted(); + controller.abort(reason); + const expected = controller.signal.reason; + reasonIdentity &&= Object.is(combined.reason, expected); + let didThrow = false; + try { + combined.throwIfAborted(); + } catch (caught) { + didThrow = true; + thrownIdentity &&= Object.is(caught, expected); + } + thrownIdentity &&= didThrow; + controller.abort("second abort must be ignored"); + oneEvent &&= events === 1 && Object.is(combined.reason, expected); + } + + const firstReason = { first: true }; + const first = AbortSignal.abort(firstReason); + const alreadyAborted = AbortSignal.any([first, AbortSignal.abort("second")]); + const controller = new AbortController(); + const nested = AbortSignal.any([AbortSignal.any([controller.signal])]); + controller.abort(firstReason); + + let invalidInputs = true; + for (const signals of [undefined, null, {}, [null], [{}], [first, {}]]) { + let rejected = false; + try { + AbortSignal.any(signals); + } catch (error) { + rejected = error instanceof TypeError && error.code === "ERR_INVALID_ARG_TYPE"; + } + invalidInputs &&= rejected; + } + let invalidReceiver = false; + try { + AbortSignal.prototype.throwIfAborted.call({ aborted: false }); + } catch (error) { + invalidReceiver = error instanceof TypeError; + } + const timeout = AbortSignal.timeout(0); + const combinedTimeout = AbortSignal.any([timeout]); + await new Promise((resolve) => setTimeout(resolve, 0)); + const defaultAbort = AbortSignal.abort(); + return { + reasonIdentity, + thrownIdentity, + dependencyOrder, + oneEvent, + alreadyAborted: alreadyAborted.aborted && alreadyAborted.reason === firstReason, + nested: nested.aborted && nested.reason === firstReason, + empty: !AbortSignal.any([]).aborted, + defaultReason: + defaultAbort.aborted && + defaultAbort.reason instanceof DOMException && + defaultAbort.reason.name === "AbortError", + timeout: + combinedTimeout.aborted && + combinedTimeout.reason === timeout.reason && + timeout.reason.name === "TimeoutError", + invalidInputs, + invalidReceiver, + nativeIdentity: + AbortController === globalThis.AbortController && + AbortSignal === globalThis.AbortSignal && + combinedTimeout instanceof AbortSignal, + }; +} + export async function run() { + const abortCases = await testAbortSignals(); const abortController = new AbortController(); const combinedSignal = AbortSignal.any([abortController.signal]); abortController.abort("stopped"); @@ -166,13 +251,8 @@ export async function run() { const timers = await testTimers(); const after = performance.now(); - const wasmBytes = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, - 0x00, 0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b, - ]); - const wasm = await WebAssembly.instantiate(wasmBytes); - return JSON.stringify({ + abortCases, abort: combinedSignal instanceof AbortSignal && combinedSignal.aborted && abortReason === "stopped", base64: atob(btoa("component")) === "component", blob: blob instanceof Blob && (await blob.text()) === "blob value", @@ -234,7 +314,9 @@ export async function run() { url.searchParams instanceof URLSearchParams && url.searchParams.get("second") === "two words" && standaloneParams.toString() === "value=two+words", - wasm: wasm.instance.exports.run() === 42, + // The pinned engine has no guest WebAssembly API. When it gains one, + // replace this absence check with compilation and execution coverage. + wasm: typeof globalThis.WebAssembly !== "undefined", writableStream: writable instanceof WritableStream && written.join() === "writable value", }); } diff --git a/packages/jco/test/fixtures/componentize/node-http-server/component.js b/packages/jco/test/fixtures/componentize/node-http-server/component.js index 2cb5922af..1d874e9de 100644 --- a/packages/jco/test/fixtures/componentize/node-http-server/component.js +++ b/packages/jco/test/fixtures/componentize/node-http-server/component.js @@ -1,14 +1,21 @@ import { createServer } from "node:http"; let server; +let requests = 0; + +export function count() { + return requests; +} export function start() { - server = createServer(async (request, response) => { + server ??= createServer(async (request, response) => { request.setEncoding("utf8"); const chunks = []; for await (const chunk of request) { chunks.push(chunk); } + await Promise.resolve(); + requests++; response.setHeader("Content-Type", "text/plain"); response.end(`${request.method} ${request.url}: ${chunks.join("")}`); }); diff --git a/packages/jco/test/fixtures/componentize/node-http-server/run.js b/packages/jco/test/fixtures/componentize/node-http-server/run.js index 33b4a9a1f..312666d13 100644 --- a/packages/jco/test/fixtures/componentize/node-http-server/run.js +++ b/packages/jco/test/fixtures/componentize/node-http-server/run.js @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import http from "node:http"; import { argv, stdout } from "node:process"; import { pathToFileURL } from "node:url"; @@ -5,23 +6,53 @@ import { pathToFileURL } from "node:url"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; const { instantiate } = await import(pathToFileURL(argv[2])); -const imports = new WASIShim().getImportObject(); -imports[argv[3]] = await import(argv[3]); -const instance = await instantiate(undefined, imports); -const port = await instance.start(); +const { createHttpHost } = await import(argv[3]); +async function createInstance() { + const imports = new WASIShim().getImportObject(); + let instance; + imports[argv[3]] = createHttpHost(() => instance.httpCallbacks); + instance = await instantiate(undefined, imports); + return instance; +} -try { - const body = await new Promise((resolve, reject) => { - const request = http.request(`http://127.0.0.1:${port}/items`, { method: "POST" }, (response) => { +function request(port, path = "/items", body = "hello") { + return new Promise((resolve, reject) => { + const request = http.request(`http://127.0.0.1:${port}${path}`, { method: "POST" }, (response) => { response.setEncoding("utf8"); const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.once("end", () => resolve(chunks.join(""))); }); request.once("error", reject); - request.end("hello"); + request.end(body); }); +} + +const first = await createInstance(); +const second = await createInstance(); +try { + const firstPort = await first.start(); + const secondPort = await second.start(); + const body = await request(firstPort); + assert.equal(body, "POST /items: hello"); + assert.deepEqual( + await Promise.all([ + request(firstPort, "/first", "one"), + request(firstPort, "/second", "two"), + request(secondPort, "/other", "three"), + ]), + ["POST /first: one", "POST /second: two", "POST /other: three"], + ); + assert.equal(await first.count(), 3); + assert.equal(await second.count(), 1); + await first.stop(); + assert.equal(await first.httpCallbacks.takeRequestListener(1), undefined); + const restartedPort = await first.start(); + assert.equal(await request(restartedPort), body); + assert.equal(await first.count(), 4); + assert.equal(await second.count(), 1); stdout.write(`${body}\n`); } finally { - await instance.stop(); + await first.stop(); + await second.stop(); } diff --git a/packages/jco/test/fixtures/componentize/node-http-server/wit/component.wit b/packages/jco/test/fixtures/componentize/node-http-server/wit/component.wit index 11d86d32c..a3264f3cf 100644 --- a/packages/jco/test/fixtures/componentize/node-http-server/wit/component.wit +++ b/packages/jco/test/fixtures/componentize/node-http-server/wit/component.wit @@ -3,4 +3,5 @@ package jco-fixtures:node-http-server; world component { export start: func() -> u16; export stop: func(); + export count: func() -> u32; } diff --git a/packages/jco/test/fixtures/componentize/node-http/peer.js b/packages/jco/test/fixtures/componentize/node-http/peer.js new file mode 100644 index 000000000..6f90ae134 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-http/peer.js @@ -0,0 +1,7 @@ +import http from "node:http"; + +const server = http.createServer((_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("hello from node:http"); +}); +server.listen(0, "127.0.0.1", () => process.send(server.address().port)); diff --git a/packages/jco/test/fixtures/componentize/node-http/run.js b/packages/jco/test/fixtures/componentize/node-http/run.js index fa527a15d..cc4495660 100644 --- a/packages/jco/test/fixtures/componentize/node-http/run.js +++ b/packages/jco/test/fixtures/componentize/node-http/run.js @@ -1,25 +1,26 @@ -import http from "node:http"; +import { fork } from "node:child_process"; +import { once } from "node:events"; import { argv, stdout } from "node:process"; import { pathToFileURL } from "node:url"; +import { withWasiSockets } from "../helpers/wasi-sockets.js"; + import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; -const server = http.createServer((_request, response) => { - response.setHeader("Content-Type", "text/plain"); - response.end("hello from node:http"); -}); -await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +// WASI's blocking stream reads occupy this thread; run the peer independently. +const server = fork(new URL("./peer.js", import.meta.url), { stdio: ["ignore", "ignore", "inherit", "ipc"] }); +const [port] = await once(server, "message"); try { - const address = server.address(); const { instantiate } = await import(pathToFileURL(argv[2])); - const imports = new WASIShim().getImportObject(); + const imports = withWasiSockets(new WASIShim().getImportObject()); if (argv[3]) { imports[argv[3]] = await import(argv[3]); } const instance = await instantiate(undefined, imports); - stdout.write(`${JSON.stringify(await instance.run(`http://127.0.0.1:${address.port}/`))}\n`); + stdout.write(`${JSON.stringify(await instance.run(`http://127.0.0.1:${port}/`))}\n`); } finally { - server.closeAllConnections(); - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + const exited = once(server, "exit"); + server.kill(); + await exited; } diff --git a/packages/jco/test/fixtures/componentize/node-http2/component-server.js b/packages/jco/test/fixtures/componentize/node-http2/component-server.js index 6b3fdbe48..385681963 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/component-server.js +++ b/packages/jco/test/fixtures/componentize/node-http2/component-server.js @@ -1,18 +1,12 @@ import { argv, stdout } from "node:process"; import { pathToFileURL } from "node:url"; +import { withWasiSockets } from "../helpers/wasi-sockets.js"; + import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; const { instantiate } = await import(pathToFileURL(argv[2])); -const imports = new WASIShim().getImportObject(); -Object.assign(imports["wasi:sockets/instance-network"], imports["wasi:sockets/network"]); -Object.assign(imports["wasi:sockets/ip-name-lookup"], imports["wasi:sockets/network"]); -Object.assign(imports["wasi:sockets/tcp-create-socket"], imports["wasi:sockets/tcp"]); -// ComponentizeJS 0.22 omits bindings for a methodless resource used across WIT interfaces. -// The vendored 0.2.10 WIT adds this unused method to force binding generation, so the -// canonical preview2-shim resource supplies a matching no-op during test instantiation. -imports["wasi:sockets/network"].Network.prototype.noop ??= () => {}; -imports["wasi:sockets/network"].networkErrorCode ??= () => undefined; +const imports = withWasiSockets(new WASIShim().getImportObject()); const instance = await instantiate(undefined, imports); const port = await instance.startServer(); stdout.write(`${port}\n`); diff --git a/packages/jco/test/fixtures/componentize/node-http2/component.js b/packages/jco/test/fixtures/componentize/node-http2/component.js index ebe6dd407..9c8a9d4c9 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/component.js +++ b/packages/jco/test/fixtures/componentize/node-http2/component.js @@ -1,14 +1,31 @@ import "./encoding-globals.js"; -import http2, { constants, createServer } from "node:http2"; +import http2, { constants, createServer, createSecureServer } from "node:http2"; let server; let handled; let markHandled; +let requests = 0; +let error = ""; export function startServer() { + return start(createServer()); +} + +export function startSecureServer(key, cert) { + return start(createSecureServer({ key, cert })); +} + +function start(value) { handled = new Promise((resolve) => (markHandled = resolve)); - server = createServer(); + server = value; + server.on("sessionError", (reason) => { + error = reason.code; + }); server.on("stream", (stream, headers) => { + requests++; + if (headers[":path"] === "/error") { + throw Object.assign(new Error("guest stream failed"), { code: "EHTTP2TEST" }); + } stream.respond({ ":status": 200, "content-type": "text/plain" }); const body = headers[":path"] === "/large" ? "s".repeat(131_072) : `server:${headers[":path"]}`; stream.end(body, () => queueMicrotask(markHandled)); @@ -17,6 +34,19 @@ export function startServer() { return server.address().port; } +export function count() { + return requests; +} +export function lastError() { + return error; +} + +export function restartServer() { + server.close(); + server.listen(0, "127.0.0.1"); + return server.address().port; +} + export async function serveOne() { await handled; } diff --git a/packages/jco/test/fixtures/componentize/node-http2/peer.js b/packages/jco/test/fixtures/componentize/node-http2/peer.js index 7917b6d25..db3d11beb 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/peer.js +++ b/packages/jco/test/fixtures/componentize/node-http2/peer.js @@ -8,8 +8,20 @@ if (argv[2] === "server") { stream.setEncoding("utf8"); stream.on("data", (chunk) => chunks.push(chunk)); stream.on("end", () => { - stream.respond({ ":status": 201, "content-type": "text/plain" }); const body = chunks.join(""); + if (headers[":path"] === "/echo") { + stream.respond({ ":status": 200, "content-type": "application/json" }); + stream.end( + JSON.stringify({ + method: headers[":method"], + path: headers[":path"], + authority: headers[":authority"], + data: body, + }), + ); + return; + } + stream.respond({ ":status": 201, "content-type": "text/plain" }); stream.end( headers[":path"] === "/large" ? `large:${headers[":method"]}:${headers[":path"]}:${body.length}:${body[0]}:${body.at(-1)}` diff --git a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js new file mode 100644 index 000000000..291bedfa5 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import http2 from "node:http2"; +import { connect } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +process.on("uncaughtException", (error) => { + console.error(error.stack); + process.exit(1); +}); +const timeout = setTimeout(() => { + throw new Error("HTTP/2 callback integration timed out"); +}, 30_000); +const { instantiate } = await import(pathToFileURL(argv[2])); +const { createHttp2Host } = await import(argv[3]); + +async function createInstance() { + const imports = new WASIShim().getImportObject(); + let instance; + const taken = []; + const disposed = []; + const errorHandled = Promise.withResolvers(); + imports[argv[3]] = createHttp2Host(() => ({ + async takeStreamListener(id) { + taken.push(id); + const resource = await instance.http2Callbacks.takeStreamListener(id); + assert.equal(await instance.http2Callbacks.takeStreamListener(id), undefined); + return { + handle: (stream) => resource.handle(stream), + [Symbol.dispose]() { + disposed.push(id); + return resource[Symbol.dispose](); + }, + }; + }, + async takeServerErrorListener(id) { + const resource = await instance.http2Callbacks.takeServerErrorListener(id); + assert.equal(await instance.http2Callbacks.takeServerErrorListener(id), undefined); + assert.equal(id, 2); + return { + async handle(reason) { + await resource.handle(reason); + errorHandled.resolve(reason); + }, + [Symbol.dispose]() { + disposed.push(id); + return resource[Symbol.dispose](); + }, + }; + }, + })); + instance = await instantiate(undefined, imports); + return { instance, taken, disposed, errorHandled: errorHandled.promise }; +} + +async function request(port, path, secure = false) { + const session = http2.connect(`${secure ? "https" : "http"}://127.0.0.1:${port}`, { rejectUnauthorized: false }); + try { + return await new Promise((resolve, reject) => { + session.once("error", reject); + const stream = session.request({ ":path": path }); + const chunks = []; + let status; + stream.setEncoding("utf8"); + stream.once("response", (headers) => { + status = headers[":status"]; + }); + stream.on("data", (chunk) => chunks.push(chunk)); + stream.once("error", reject); + stream.once("end", () => resolve({ status, body: chunks.join("") })); + stream.end(); + }); + } finally { + session.destroy(); + } +} + +const [key, cert] = await Promise.all([ + readFile(new URL("../../../../../preview2-shim/test/fixtures/tls/localhost.key", import.meta.url)), + readFile(new URL("../../../../../preview2-shim/test/fixtures/tls/localhost.crt", import.meta.url)), +]); +const peer = http2.createServer(); +peer.on("stream", (stream) => { + stream.respond({ ":status": 201, "content-type": "text/plain" }); + stream.end("native peer"); +}); +await new Promise((resolve) => peer.listen(0, "127.0.0.1", resolve)); +try { + for (const secure of [false, true]) { + const first = await createInstance(); + const second = await createInstance(); + const start = (instance) => (secure ? instance.startSecureServer(key, cert) : instance.startServer()); + try { + assert.deepEqual( + JSON.parse(await first.instance.runClient(`http://127.0.0.1:${peer.address().port}`, "/client", "")), + { + status: 201, + contentType: "text/plain", + body: "native peer", + }, + ); + const a = await start(first.instance); + const b = await start(second.instance); + assert.deepEqual( + await Promise.all([ + request(a, "/one", secure), + request(a, "/two", secure), + request(b, "/other", secure), + ]), + [ + { status: 200, body: "server:/one" }, + { status: 200, body: "server:/two" }, + { status: 200, body: "server:/other" }, + ], + ); + assert.equal(await first.instance.count(), 2); + assert.equal(await second.instance.count(), 1); + assert.deepEqual(first.taken, [1]); + assert.deepEqual(second.taken, [1]); + assert.deepEqual(await request(a, "/error", secure), { status: 500, body: "guest stream failed" }); + assert.deepEqual(await request(a, "/after-error", secure), { status: 200, body: "server:/after-error" }); + const large = await request(a, "/large", secure); + assert.equal(large.status, 200); + assert.equal(large.body, "s".repeat(131_072)); + + // A malformed native client triggers the host's sessionError event and resource callback. + const socket = secure + ? connectTls({ port: a, host: "127.0.0.1", rejectUnauthorized: false, ALPNProtocols: ["h2"] }) + : connect(a, "127.0.0.1"); + try { + socket.resume(); + socket.end("invalid HTTP/2 preface\r\n\r\n"); + const reason = await first.errorHandled; + assert.equal(reason.code, "ERR_HTTP2_ERROR"); + assert.equal(await first.instance.lastError(), reason.code); + } finally { + socket.destroy(); + } + + const restarted = await first.instance.restartServer(); + assert.deepEqual(await request(restarted, "/restarted", secure), { + status: 200, + body: "server:/restarted", + }); + assert.equal(await first.instance.count(), 6); + assert.equal(await second.instance.count(), 1); + assert.deepEqual(first.taken, [1, 1]); + await first.instance.stopServer(); + assert.equal(await first.instance.http2Callbacks.takeStreamListener(1), undefined); + assert.equal(await first.instance.http2Callbacks.takeServerErrorListener(2), undefined); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.deepEqual(first.disposed.sort(), [1, 1, 2]); + } finally { + await first.instance.stopServer(); + await second.instance.stopServer(); + } + } + stdout.write(`${JSON.stringify({ plain: true, secure: true, isolated: true })}\n`); +} finally { + await new Promise((resolve) => peer.close(resolve)); + clearTimeout(timeout); +} diff --git a/packages/jco/test/fixtures/componentize/node-http2/run.js b/packages/jco/test/fixtures/componentize/node-http2/run.js index 73ef982b0..ecce67011 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/run.js +++ b/packages/jco/test/fixtures/componentize/node-http2/run.js @@ -1,9 +1,10 @@ import { spawn } from "node:child_process"; -import { resolve4 } from "node:dns/promises"; import http2 from "node:http2"; -import { argv, stdout } from "node:process"; +import { argv, execArgv, stdout } from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { withWasiSockets } from "../helpers/wasi-sockets.js"; + import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; function spawnPeer(...args) { @@ -12,6 +13,40 @@ function spawnPeer(...args) { }); } +function waitForPort(child) { + return new Promise((resolve, reject) => { + let output = ""; + const cleanup = () => { + child.off("error", onError); + child.off("exit", onExit); + child.stdout.off("data", onData); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const onExit = (code, signal) => { + onError(new Error(`HTTP/2 server exited before announcing its port (${signal ?? code})`)); + }; + const onData = (chunk) => { + output += chunk; + if (!output.includes("\n")) { + return; + } + const port = Number(output.slice(0, output.indexOf("\n"))); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + onError(new Error(`Invalid HTTP/2 server port: ${output}`)); + return; + } + cleanup(); + resolve(port); + }; + child.once("error", onError); + child.once("exit", onExit); + child.stdout.on("data", onData); + }); +} + function request(authority, path, body) { return new Promise((resolve, reject) => { const session = http2.connect(authority); @@ -29,51 +64,35 @@ function request(authority, path, body) { }); } +// Keep the native server in its own process so it can respond while the guest +// waits on synchronous WASI socket operations. const localServer = spawnPeer("server"); -const localPort = await new Promise((resolve, reject) => { - localServer.once("error", reject); - localServer.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim()))); -}); - -const { instantiate } = await import(pathToFileURL(argv[2])); -const imports = new WASIShim().getImportObject(); -// WIT `use`d resources are projected beside the importing function by componentize-js, -// while preview2-shim exposes each resource on its defining interface. -Object.assign(imports["wasi:sockets/instance-network"], imports["wasi:sockets/network"]); -Object.assign(imports["wasi:sockets/ip-name-lookup"], imports["wasi:sockets/network"]); -Object.assign(imports["wasi:sockets/tcp-create-socket"], imports["wasi:sockets/tcp"]); -// ComponentizeJS 0.22 omits bindings for a methodless resource used across WIT interfaces. -// The vendored 0.2.10 WIT adds this unused method to force binding generation, so the -// canonical preview2-shim resource supplies a matching no-op during test instantiation. -imports["wasi:sockets/network"].Network.prototype.noop ??= () => {}; -// WASI sockets 0.2.10 exposed this function; 0.2.12 removed it. The adapter never calls it, -// but StarlingMonkey's exact 0.2.10 interface still requires a host implementation. -imports["wasi:sockets/network"].networkErrorCode ??= () => undefined; -const instance = await instantiate(undefined, imports); let componentServer; try { + const localPort = await waitForPort(localServer); + const { instantiate } = await import(pathToFileURL(argv[2])); + const imports = withWasiSockets(new WASIShim().getImportObject()); + const instance = await instantiate(undefined, imports); const local = JSON.parse(await instance.runClient(`http://127.0.0.1:${localPort}`, "/large", "")); componentServer = spawn( process.execPath, - [fileURLToPath(new URL("./component-server.js", import.meta.url)), argv[2]], + // Older supported hosts need --experimental-wasm-jspi in every process + // that instantiates the component, including this nested server. + [...execArgv, fileURLToPath(new URL("./component-server.js", import.meta.url)), argv[2]], { stdio: ["ignore", "pipe", "inherit"] }, ); - const guestPort = await new Promise((resolve, reject) => { - componentServer.once("error", reject); - componentServer.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim()))); - }); + const guestPort = await waitForPort(componentServer); const guestBody = await request(`http://127.0.0.1:${guestPort}`, "/large", "runner"); const guest = { length: guestBody.length, first: guestBody[0], last: guestBody.at(-1) }; componentServer.kill(); - let external; - if (argv[3] === "external") { - const [externalAddress] = await resolve4("nghttp2.org"); - external = JSON.parse(await instance.runClient(`http://${externalAddress}`, "/httpbin/post", "nghttp2.org")); + let echo; + if (argv[3] === "echo") { + echo = JSON.parse(await instance.runClient(`http://127.0.0.1:${localPort}`, "/echo", "echo.test")); } - stdout.write(`${JSON.stringify({ local, guest, external })}\n`); + stdout.write(`${JSON.stringify({ local, guest, echo })}\n`); } finally { componentServer?.kill(); localServer.kill(); diff --git a/packages/jco/test/fixtures/componentize/node-http2/wit-starling/component.wit b/packages/jco/test/fixtures/componentize/node-http2/wit-starling/component.wit index 420d3640c..511ec8dfb 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/wit-starling/component.wit +++ b/packages/jco/test/fixtures/componentize/node-http2/wit-starling/component.wit @@ -2,6 +2,10 @@ package test:http2; world component { export start-server: func() -> u16; + export start-secure-server: func(key: list, cert: list) -> u16; + export restart-server: func() -> u16; + export count: func() -> u32; + export last-error: func() -> string; export serve-one: func(); export stop-server: func(); export run-client: func(authority: string, path: string, request-authority: string) -> string; diff --git a/packages/jco/test/fixtures/componentize/node-module/source.js b/packages/jco/test/fixtures/componentize/node-module/source.js index 4834c2ed1..8690b2503 100644 --- a/packages/jco/test/fixtures/componentize/node-module/source.js +++ b/packages/jco/test/fixtures/componentize/node-module/source.js @@ -27,7 +27,7 @@ export function run() { return JSON.stringify({ // Classification: pure data, exact. moduleIsClass: nodeModule === nodeModule.Module, - builtinCount: builtinModules.length, + builtins: builtinModules, isBuiltinFs: isBuiltin("node:fs"), isBuiltinBareTest: isBuiltin("test"), isBuiltinPrefixedTest: isBuiltin("node:test"), diff --git a/packages/jco/test/fixtures/componentize/node-os/component.js b/packages/jco/test/fixtures/componentize/node-os/component.js index 3333a9d91..6cb44ab1d 100644 --- a/packages/jco/test/fixtures/componentize/node-os/component.js +++ b/packages/jco/test/fixtures/componentize/node-os/component.js @@ -1,15 +1,44 @@ import os, { arch, availableParallelism, platform, userInfo } from "node:os"; -export function run() { +function errorFields(invoke) { + try { + invoke(); + return null; + } catch (error) { + return { + name: error.name, + code: error.code, + syscall: error.syscall, + info: error.info, + }; + } +} + +export function run(denied) { + const staticProperties = { + eol: os.EOL, + devNull: os.devNull, + invalidArgument: os.constants.errno.EINVAL, + }; + if (denied) { + return JSON.stringify({ + ...staticProperties, + errors: [arch, platform, userInfo, os.loadavg, os.type, os.uptime].map(errorFields), + }); + } const user = userInfo(); return JSON.stringify({ + ...staticProperties, namespaceIdentity: os.arch === arch, arch: arch(), platform: platform(), parallelism: availableParallelism(), username: user.username, homedir: user.homedir, - eol: os.EOL, - devNull: os.devNull, + type: os.type(), + loadavg: os.loadavg(), + uptime: os.uptime(), + bufferedUsername: userInfo({ encoding: "buffer" }).username.toString(), + priorityError: errorFields(() => os.getPriority(2147483647)), }); } diff --git a/packages/jco/test/fixtures/componentize/node-os/run.js b/packages/jco/test/fixtures/componentize/node-os/run.js index 494a8ad44..fcace44d3 100644 --- a/packages/jco/test/fixtures/componentize/node-os/run.js +++ b/packages/jco/test/fixtures/componentize/node-os/run.js @@ -4,5 +4,8 @@ import { pathToFileURL } from "node:url"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; const { instantiate } = await import(pathToFileURL(argv[2])); -const instance = await instantiate(undefined, new WASIShim().getImportObject()); -stdout.write(`${instance.run()}\n`); +const hostSpecifier = argv[3]; +const host = await import(hostSpecifier); +const imports = { ...new WASIShim().getImportObject(), [hostSpecifier]: host }; +const instance = await instantiate(undefined, imports); +stdout.write(`${instance.run(argv[4] === "denied")}\n`); diff --git a/packages/jco/test/fixtures/componentize/node-os/wit/component.wit b/packages/jco/test/fixtures/componentize/node-os/wit/component.wit index c15377c96..ca3154bf3 100644 --- a/packages/jco/test/fixtures/componentize/node-os/wit/component.wit +++ b/packages/jco/test/fixtures/componentize/node-os/wit/component.wit @@ -1,5 +1,5 @@ package jco-fixtures:node-os; world component { - export run: func() -> string; + export run: func(denied: bool) -> string; } diff --git a/packages/jco/test/fixtures/componentize/node-path/component.js b/packages/jco/test/fixtures/componentize/node-path/component.js index 3401d2f69..a89d6c09c 100644 --- a/packages/jco/test/fixtures/componentize/node-path/component.js +++ b/packages/jco/test/fixtures/componentize/node-path/component.js @@ -14,3 +14,7 @@ export function lexical() { export function fromCwd() { return resolve("relative"); } + +export function match(path, pattern, windows) { + return (windows ? win32.matchesGlob : matchesGlob)(path, pattern); +} diff --git a/packages/jco/test/fixtures/componentize/node-path/wit/component.wit b/packages/jco/test/fixtures/componentize/node-path/wit/component.wit index 54818df75..296f4e9da 100644 --- a/packages/jco/test/fixtures/componentize/node-path/wit/component.wit +++ b/packages/jco/test/fixtures/componentize/node-path/wit/component.wit @@ -5,4 +5,5 @@ world component { export lexical: func() -> string; export from-cwd: func() -> string; + export match: func(path: string, pattern: string, windows: bool) -> bool; } diff --git a/packages/jco/test/node/assert.js b/packages/jco/test/node/assert.js index cb8577a43..9f51e6ced 100644 --- a/packages/jco/test/node/assert.js +++ b/packages/jco/test/node/assert.js @@ -7,10 +7,7 @@ import { COMPONENT_JS_FIXTURES_DIR } from "../common.js"; import { exec, getTmpDir, jcoPath } from "../helpers.js"; suite("node:assert", () => { - // TODO(unskip): jco pins @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert, and no published - // jco-std exports the assert shim at all -- it only exists in the workspace copy. Unskip once - // a jco-std release carrying it is published and jco's range is bumped to it. - test.skip("bundles and executes APIs guest-side", async () => { + test("bundles and executes APIs guest-side", async () => { const fixtureDir = join(COMPONENT_JS_FIXTURES_DIR, "node-assert"); const outputDir = await getTmpDir(); const componentPath = join(outputDir, "component.wasm"); diff --git a/packages/jco/test/node/buffer.js b/packages/jco/test/node/buffer.js index f50950d5d..87ff0a555 100644 --- a/packages/jco/test/node/buffer.js +++ b/packages/jco/test/node/buffer.js @@ -151,9 +151,7 @@ suite("node:buffer", () => { } }); - // TODO(unskip): global Error injection resolves jco-std's versioned Errors module, which is - // not published yet. Unskip once a release carrying that export is available to Jco's tests. - test.skip("bundles and executes APIs guest-side", async () => { + test("bundles and executes APIs guest-side", async () => { const fixtureDir = join(COMPONENT_JS_FIXTURES_DIR, "node-buffer"); const outputDir = await getTmpDir(); const componentPath = join(outputDir, "component.wasm"); diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index fa4f96908..88ea13135 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -90,12 +90,33 @@ describe("Node builtin adapters", () => { }); expect(opts.asyncMode).toBe("jspi"); - expect(opts.asyncImports).toEqual(["application:custom/host#load", "jco:node/dns@0.1.0#*"]); + const dnsAsyncFunctions = [ + "lookup", + "lookup-service", + "resolve4", + "resolve6", + "resolve-any", + "resolve-caa", + "resolve-cname", + "resolve-mx", + "resolve-naptr", + "resolve-ns", + "resolve-ptr", + "resolve-soa", + "resolve-srv", + "resolve-tlsa", + "resolve-txt", + "reverse", + ]; + expect(opts.asyncImports).toEqual([ + "application:custom/host#load", + ...dnsAsyncFunctions.map((name) => `jco:node/dns@0.1.0#${name}`), + ]); expect(opts.asyncExports).toEqual(["selected-export", "*"]); expect(opts.map?.["jco:node/dns@0.1.0"]).toBe("/application/dns-host.js"); withDefaultNodeCapabilities(opts); - expect(opts.asyncImports).toHaveLength(2); + expect(opts.asyncImports).toHaveLength(dnsAsyncFunctions.length + 1); expect(opts.asyncExports).toHaveLength(2); }); diff --git a/packages/jco/test/node/child-process.js b/packages/jco/test/node/child-process.js index b828e9f60..e0f2f60cd 100644 --- a/packages/jco/test/node/child-process.js +++ b/packages/jco/test/node/child-process.js @@ -1,20 +1,16 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { version as nodeVersion } from "node:process"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { assert, suite, test } from "vitest"; import { componentizeFixture, transpileComponent } from "../helpers.js"; /** jco-std's Node host adapter, which an application must opt into explicitly. */ -const NODE_HOST = pathToFileURL( - fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/child-process-host-node.js", import.meta.url)), -).href; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host/node"); suite("node:child_process in a component", () => { - // TODO(unskip): use the published jco-std child-process exports once a release containing them is available. - test.skip("componentizes and calls through the opt-in Node host", async () => { + test("componentizes and calls through the opt-in Node host", async () => { // Built from a copy: componentizing rewrites the world in place to add the WIT import. const { componentPath, fixtureDir, stderr } = await componentizeFixture({ fixture: "node-child-process", diff --git a/packages/jco/test/node/cluster.js b/packages/jco/test/node/cluster.js index 73c79a28b..3138f1fcc 100644 --- a/packages/jco/test/node/cluster.js +++ b/packages/jco/test/node/cluster.js @@ -1,15 +1,13 @@ import { readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { assert, suite, test } from "vitest"; import { componentizeFixture, exec, transpileComponent } from "../helpers.js"; /** jco-std's Node host adapter, which an application must opt into explicitly. */ -const NODE_HOST = pathToFileURL( - fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/cluster-host-node.js", import.meta.url)), -).href; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host/node"); /** Build a cluster fixture from a copy, since componentizing rewrites its world in place. */ async function buildClusterFixture(fixture, name) { @@ -34,8 +32,7 @@ async function buildClusterFixture(fixture, name) { } suite("node:cluster in a component", () => { - // TODO(unskip): use the published jco-std cluster exports once a release containing them is available. - test.skip("componentizes and calls through the opt-in Node host", async () => { + test("componentizes and calls through the opt-in Node host", async () => { const { appDir, modulePath, stderr } = await buildClusterFixture("node-cluster", "node-cluster"); assert.include(stderr, "Jco added generated WIT import jco:node/cluster@0.1.0"); @@ -59,11 +56,9 @@ suite("node:cluster in a component", () => { }); }); - // TODO(unskip): use the published jco-std cluster exports once a release containing them is available. - // // Driven from a spawned script rather than in-process: cluster.fork() re-executes the current // entry, so forking from inside the test runner would fork the runner itself. - test.skip("forks a worker that runs the component and reports back", async () => { + test("forks a worker that runs the component and reports back", async () => { const { outputDir, modulePath } = await buildClusterFixture("node-cluster-roundtrip", "node-cluster-rt"); // The runner is a bare node process outside the workspace, so give the transpiled output a diff --git a/packages/jco/test/node/console.js b/packages/jco/test/node/console.js index f7ea114ef..405f5528d 100644 --- a/packages/jco/test/node/console.js +++ b/packages/jco/test/node/console.js @@ -1,44 +1,46 @@ // End-to-end coverage for `node:console` in StarlingMonkey components. import { assert, suite, test } from "vitest"; +import { symlink } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { componentizeFixture, exec, setupAsyncTest } from "../helpers.js"; +/** jco-std's Node host adapter, which an application must opt into explicitly. */ +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host/node"); + suite("node:console in a component", () => { - // TODO(unskip): use the published jco-std console exports once a release containing them is available. - // StarlingMonkey also needs to finish componentizing the bundled console core within the - // ten-minute integration budget. The direct host suite covers the opt-in Node passthrough meanwhile. - test.skip("componentizes and runs default and custom consoles", async () => { - const { componentPath, fixtureDir, stderr } = await componentizeFixture({ + // The bundled console core must componentize within the ten-minute integration budget. + test("componentizes and runs default and custom consoles", async () => { + const { componentPath, outputDir, fixtureDir, stderr } = await componentizeFixture({ fixture: "node-console", bundle: true, + copy: true, }); assert.include(stderr, "Jco added generated WIT import jco:node/console@0.1.0"); const { esModuleOutputPath, cleanup } = await setupAsyncTest({ - component: { name: "node-console", path: componentPath, skipInstantiation: true }, + component: { name: "node-console", path: componentPath, outputDir, skipInstantiation: true }, jco: { transpile: { extraArgs: { map: { - "jco:node/console@0.1.0": pathToFileURL( - fileURLToPath( - new URL( - "../../../jco-std/dist/wasi/0.2.x/node/24.x.x/console-host-node.js", - import.meta.url, - ), - ), - ).href, + "jco:node/console@0.1.0": NODE_HOST, }, }, }, }, }); + await symlink( + fileURLToPath(new URL("../../node_modules", import.meta.url)), + join(outputDir, "node_modules"), + "dir", + ); + try { - const output = await exec(join(fixtureDir, "run.js"), esModuleOutputPath); + const output = await exec(join(fixtureDir, "run.js"), esModuleOutputPath, NODE_HOST); assert.strictEqual(output.stderr, "guest stderr\n"); assert.strictEqual( output.stdout, diff --git a/packages/jco/test/node/dns.js b/packages/jco/test/node/dns.js index 986f83adb..2bd36eac8 100644 --- a/packages/jco/test/node/dns.js +++ b/packages/jco/test/node/dns.js @@ -2,14 +2,13 @@ import { assert, expect, suite, test } from "vitest"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { DNS_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; -const NODE_HOST = pathToFileURL( - fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/dns-host-node.js", import.meta.url)), -).href; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns/host/node"); suite("node:dns in a component", () => { test.concurrent("installs the DNS WIT dependency without duplicating it", async () => { @@ -27,45 +26,47 @@ suite("node:dns in a component", () => { expect((await readFile(world, "utf8")).match(/import jco:node\/dns@0\.1\.0;/g)).toHaveLength(1); }); - // TODO(unskip): use the published jco-std DNS exports once a release containing them is available. - test.skip("componentizes and resolves example.com through the opt-in Node host", async () => { - const { componentPath, stderr } = await componentizeFixture({ - fixture: "node-dns", - bundle: true, - copy: true, - extraArgs: ["--backend", "starlingmonkey"], - }); - assert.include(stderr, "Jco added generated WIT import jco:node/dns@0.1.0"); + test.skipIf(!hasJspi)( + "componentizes and resolves example.com through the opt-in Node host", + async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-dns", + bundle: true, + copy: true, + extraArgs: ["--backend", "starlingmonkey"], + }); + assert.include(stderr, "Jco added generated WIT import jco:node/dns@0.1.0"); - const { esModuleOutputPath, cleanup } = await setupAsyncTest({ - component: { name: "node-dns", path: componentPath, skipInstantiation: true }, - jco: { - transpile: { - extraArgs: { - asyncExports: ["run"], - map: { - "jco:node/dns@0.1.0": NODE_HOST, + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-dns", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: { + map: { + "jco:node/dns@0.1.0": NODE_HOST, + }, }, }, }, - }, - }); + }); - try { - const runner = fileURLToPath(new URL("../fixtures/componentize/node-dns/run.js", import.meta.url)); - const output = await exec(runner, esModuleOutputPath, NODE_HOST); - const report = JSON.parse(output.stdout); - assert.isAtLeast(report.serverCount, 0); - assert.strictEqual(report.namespaceIdentity, true); - assert.strictEqual(report.promisesIdentity, true); - assert.strictEqual(report.resultOrder, "ipv4first"); - assert.strictEqual(report.cancelCode, "ERR_JCO_UNSUPPORTED_NODE_API"); - // This intentionally performs a network lookup. The addresses may change; - // only the stable shape of the reserved example domain is asserted. - assert.isAtLeast(report.externalAddressCount, 1); - assert.strictEqual(report.externalAddressesAreIpv4, true); - } finally { - await cleanup(); - } - }, 600_000); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-dns/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, NODE_HOST); + const report = JSON.parse(output.stdout); + assert.isAtLeast(report.serverCount, 0); + assert.strictEqual(report.namespaceIdentity, true); + assert.strictEqual(report.promisesIdentity, true); + assert.strictEqual(report.resultOrder, "ipv4first"); + assert.strictEqual(report.cancelCode, "ERR_JCO_UNSUPPORTED_NODE_API"); + // This intentionally performs a network lookup. The addresses may change; + // only the stable shape of the reserved example domain is asserted. + assert.isAtLeast(report.externalAddressCount, 1); + assert.strictEqual(report.externalAddressesAreIpv4, true); + } finally { + await cleanup(); + } + }, + 600_000, + ); }); diff --git a/packages/jco/test/node/errors.js b/packages/jco/test/node/errors.js index e007cf1ea..545f4a4c7 100644 --- a/packages/jco/test/node/errors.js +++ b/packages/jco/test/node/errors.js @@ -75,8 +75,7 @@ suite("Node Errors globals", () => { expect(source).not.toContain("DefaultError"); }); - // TODO(unskip): use the published jco-std Errors globals once a release containing them is available. - test.skip("provides Node error globals to a StarlingMonkey guest", async () => { + test("provides Node error globals to a StarlingMonkey guest", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-errors", bundle: true, diff --git a/packages/jco/test/node/events.js b/packages/jco/test/node/events.js index fd428cac2..cb502cef3 100644 --- a/packages/jco/test/node/events.js +++ b/packages/jco/test/node/events.js @@ -27,8 +27,7 @@ const NODE_RESULT = { }; suite("node:events in a component", () => { - // TODO(unskip): use the published jco-std events export once a release containing it is available. - test.skip("componentizes and matches Node", async () => { + test("componentizes and matches Node", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-events", entry: "source.js", diff --git a/packages/jco/test/node/ffi.js b/packages/jco/test/node/ffi.js index 36d46d853..4aeea2540 100644 --- a/packages/jco/test/node/ffi.js +++ b/packages/jco/test/node/ffi.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -14,11 +15,18 @@ const NODE_HOST = pathToFileURL( const UNSUPPORTED = "ERR_JCO_UNSUPPORTED_NODE_API"; +// The host adapter requires a runtime with node:ffi enabled via --experimental-ffi. +function hostHasFfi() { + try { + createRequire(import.meta.url)("node:ffi"); + return true; + } catch { + return false; + } +} + suite("node:ffi in a component", () => { - // TODO(unskip): needs two things CI does not have yet -- a jco-std release carrying the - // node/26.x.x ffi exports, and a Node 26 runtime started with `--experimental-ffi`, since the - // host adapter forwards to the runtime's real node:ffi. - test.skip("componentizes and calls native code through the opt-in Node host", async () => { + test.skipIf(!hostHasFfi())("componentizes and calls native code through the opt-in Node host", async () => { // Built from a copy: componentizing rewrites the world in place to add the WIT import. const { componentPath, fixtureDir, stderr } = await componentizeFixture({ fixture: "node-ffi", diff --git a/packages/jco/test/node/fs.js b/packages/jco/test/node/fs.js index 54e68cc3c..1b59ed8dc 100644 --- a/packages/jco/test/node/fs.js +++ b/packages/jco/test/node/fs.js @@ -1,6 +1,5 @@ import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { assert, expect, suite, test } from "vitest"; @@ -8,9 +7,7 @@ import { FS_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js" import { componentizeFixture, getTmpDir, transpileComponent } from "../helpers.js"; /** jco-std's Node host adapter, which an application must opt into explicitly. */ -const NODE_HOST = pathToFileURL( - fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/fs-host-node.js", import.meta.url)), -).href; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs/host/node"); suite("node:fs in a component", () => { test.concurrent("injects one filesystem capability into the selected world", async () => { @@ -41,8 +38,7 @@ suite("node:fs in a component", () => { } }); - // TODO(unskip): use the published jco-std filesystem exports once a release containing them is available. - test.skip("componentizes sync, callback, and promise APIs through the opt-in Node host", async () => { + test("componentizes sync, callback, and promise APIs through the opt-in Node host", async () => { const { componentPath, fixtureDir, outputDir, stderr } = await componentizeFixture({ fixture: "node-fs", bundle: true, diff --git a/packages/jco/test/node/globals.js b/packages/jco/test/node/globals.js index 1fd78eb5e..57b49868c 100644 --- a/packages/jco/test/node/globals.js +++ b/packages/jco/test/node/globals.js @@ -9,6 +9,20 @@ import { componentizeFixture, getTmpDir, setupAsyncTest } from "../helpers.js"; const EXPECTED_REPORT = { abort: true, + abortCases: { + reasonIdentity: true, + thrownIdentity: true, + dependencyOrder: true, + oneEvent: true, + alreadyAborted: true, + nested: true, + empty: true, + defaultReason: true, + timeout: true, + invalidInputs: true, + invalidReceiver: true, + nativeIdentity: true, + }, base64: true, blob: true, buffer: true, @@ -35,7 +49,8 @@ const EXPECTED_REPORT = { timers: true, transformStream: true, url: true, - wasm: true, + // Update this capability expectation and add execution coverage when the engine supports it. + wasm: false, writableStream: true, }; @@ -75,7 +90,11 @@ suite("Node globals", () => { }); test.concurrent("maps only globals backed by Jco implementations", () => { - expect(nodeGlobals({ bufferModule: "/buffer.js", errorsModule: "/errors.js" })).toEqual({ + expect( + nodeGlobals({ bufferModule: "/buffer.js", errorsModule: "/errors.js", abortGlobalsModule: "/abort.js" }), + ).toEqual({ + AbortController: ["/abort.js", "AbortController"], + AbortSignal: ["/abort.js", "AbortSignal"], AggregateError: ["/errors.js", "AggregateError"], Buffer: ["/buffer.js", "Buffer"], DOMException: ["/errors.js", "DOMException"], @@ -90,6 +109,24 @@ suite("Node globals", () => { }); }); + test.each([ + ["export const value = new AbortController();", true], + ["export const value = AbortSignal.any([]);", true], + ["export const value = 24;", false], + ["class AbortSignal {} export const value = new AbortSignal();", false], + ])("loads Abort compatibility only for free global references: %s", async (entrySource, included) => { + const root = await getTmpDir(); + const entry = join(root, "entry.js"); + const abortGlobalsModule = join(root, "abort.js"); + await writeFile(entry, entrySource); + await writeFile( + abortGlobalsModule, + "globalThis.__ABORT_GLOBAL_MARKER__ = true; export const AbortController = globalThis.AbortController; export const AbortSignal = globalThis.AbortSignal;", + ); + const source = await bundleComponentSource(entry, { inject: nodeGlobals({ abortGlobalsModule }) }); + expect(source.includes("__ABORT_GLOBAL_MARKER__")).toBe(included); + }); + test.concurrent("injects Buffer when its free identifier is used", async () => { const root = await getTmpDir(); const entry = join(root, "entry.js"); @@ -155,11 +192,13 @@ suite("Node globals", () => { expect(source).not.toContain("__BUFFER_GLOBAL_MARKER__"); }); - // TODO(unskip): use the published jco-std Errors globals once a release containing them is available. - test.skip("provides the supported Node globals to a StarlingMonkey guest", async () => { + test("provides the supported Node globals to a StarlingMonkey guest", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-globals", + entry: "source.js", + wit: "source.wit", bundle: true, + copy: true, extraArgs: ["--backend", "starlingmonkey"], }); const { instance, cleanup } = await setupAsyncTest({ diff --git a/packages/jco/test/node/http.js b/packages/jco/test/node/http.js index 661fc8c51..2c4034d42 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -1,6 +1,6 @@ import { readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { worldMetadataFor } from "../../src/cmd/componentize.js"; import { describe, expect, test, vi } from "vitest"; @@ -15,6 +15,7 @@ import { injectNodeWitImports, } from "../../src/node-wit.js"; import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; const modulePaths = { httpModule: "/jco/http.js", @@ -23,9 +24,7 @@ const modulePaths = { httpWasiHttpImplementationModule: "/jco/http/wasi-http.js", }; -const NODE_HOST = pathToFileURL( - fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/http-host-node.js", import.meta.url)), -).href; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node"); describe("node:http builtin adapter", () => { test.each([ @@ -74,7 +73,7 @@ describe("node:http builtin adapter", () => { await writeFile(entry, 'import { createServer } from "node:http"; export { createServer };\n'); await writeFile( httpModule, - "export const httpCallbacks = { RequestListener: class RequestListener {} }; export default {};\n", + "export const httpCallbacks = { RequestListener: class {}, takeRequestListener() {} }; export default {};\n", ); const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { httpModule }); const bundleOptions = { plugins: [plugin] }; @@ -137,6 +136,9 @@ describe("node:http WIT installation", () => { expect(source).toContain("request: func(options: request-options)"); expect(source).toContain("resource server"); const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).not.toContainEqual( + expect.objectContaining({ namespace: "jco", package: "node", interface: "http-callbacks" }), + ); expect(metadata.exports).toContainEqual( expect.objectContaining({ namespace: "jco", package: "node", interface: "http-callbacks" }), ); @@ -194,9 +196,8 @@ describe("node:http WIT installation", () => { ); }); -describe("node:http in a component", () => { - // TODO(unskip): use the published jco-std HTTP server exports once a release containing them is available. - test.skip("serves a request through guest -> WIT callback resource -> host node:http", async () => { +describe.skipIf(!hasJspi)("node:http in a component", () => { + test("serves a request through guest -> WIT callback resource -> host node:http", async () => { const { componentPath, stderr } = await componentizeFixture({ fixture: "node-http-server", bundle: true, @@ -225,8 +226,7 @@ describe("node:http in a component", () => { } }, 600_000); - // TODO(unskip): use the published jco-std HTTP exports once a release containing them is available. - test.skip.each(["direct", "wasi-sockets", "wasi-http"])( + test.each(["direct", "wasi-sockets", "wasi-http"])( "componentizes and performs a local request via %s", async (implementation) => { const { componentPath, stderr } = await componentizeFixture({ diff --git a/packages/jco/test/node/http2.js b/packages/jco/test/node/http2.js index ec88c1471..041bb5a98 100644 --- a/packages/jco/test/node/http2.js +++ b/packages/jco/test/node/http2.js @@ -11,6 +11,7 @@ import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; import { HTTP2_CALLBACKS_SPECIFIER, nodeBuiltinPlugin } from "../../src/node-builtins.js"; import { HTTP2_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; const modulePaths = { http2Module: "/jco/http2.js", @@ -74,7 +75,7 @@ describe("node:http2 builtin adapter", () => { await writeFile(entry, 'import { createServer } from "node:http2"; export { createServer };\n'); await writeFile( http2Module, - "export const http2Callbacks = { StreamListener: class StreamListener {} }; export default {};\n", + "export const http2Callbacks = { StreamListener: class StreamListener {}, takeStreamListener() {}, ServerErrorListener: class {}, takeServerErrorListener() {} }; export default {};\n", ); const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { http2Module }); const source = await bundleNodeGuestExportsWrapper(entry, HTTP2_WIT_REQUIREMENT.guestExports, { @@ -132,6 +133,9 @@ describe("node:http2 WIT installation", () => { expect(source).toContain("resource client-stream"); expect(source).toContain("resource stream-listener"); const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).not.toContainEqual( + expect.objectContaining({ namespace: "jco", package: "node", interface: "http2-callbacks" }), + ); expect(metadata.imports).toContainEqual( expect.objectContaining({ namespace: "jco", package: "node", interface: "http2" }), ); @@ -166,14 +170,31 @@ describe("node:http2 WIT installation", () => { }); }); -describe("node:http2 in a fully formed component", () => { +test("reports an HTTP/2 component server startup failure without waiting for a port", async () => { + const root = await getTmpDir(); + const component = join(root, "failed-server.mjs"); + await writeFile( + component, + ` +export async function instantiate() { + return { + runClient() { return "{}"; }, + startServer() { throw new Error("test server startup failed"); }, + }; +} +`, + ); + const runner = fileURLToPath(new URL("../fixtures/componentize/node-http2/run.js", import.meta.url)); + await expect(exec(runner, component)).rejects.toThrow(/HTTP\/2 server exited before announcing its port/); +}, 10_000); + +describe.skipIf(!hasJspi)("node:http2 in a fully formed component", () => { const expectedLocalReport = { local: { status: 201, contentType: "text/plain", body: "large:POST:/large:131072:x:x" }, guest: { length: 131072, first: "s", last: "s" }, }; - // TODO(unskip): enable after a published jco-std release contains the HTTP/2 exports. - test.skip("runs a fully formed wasi:sockets component against local HTTP/2 clients and servers", async () => { + test("runs a fully formed wasi:sockets component against local HTTP/2 clients and servers", async () => { const { componentPath, stderr } = await componentizeFixture({ fixture: "node-http2", bundle: true, @@ -194,8 +215,7 @@ describe("node:http2 in a fully formed component", () => { } }, 600_000); - // TODO(unskip): enable after a published jco-std release contains the HTTP/2 exports. - test.skip("runs the same wasi:sockets component under StarlingMonkey", async () => { + test("runs the same wasi:sockets component under StarlingMonkey", async () => { const { componentPath, stderr } = await componentizeFixture({ fixture: "node-http2", wit: "wit-starling", @@ -217,8 +237,7 @@ describe("node:http2 in a fully formed component", () => { } }, 600_000); - // TODO(unskip): enable after a published jco-std release contains the HTTP/2 exports. - test.skip("runs the component against the public nghttp2.org h2c server", async () => { + test("posts to a local Node h2c server with an explicit request authority", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-http2", bundle: true, @@ -226,29 +245,46 @@ describe("node:http2 in a fully formed component", () => { extraArgs: ["--backend", "quickjs", "--with-nodejs-http2-via", "wasi-sockets"], }); const { esModuleOutputPath, cleanup } = await setupAsyncTest({ - component: { name: "node-http2-wasi-sockets-external", path: componentPath, skipInstantiation: true }, + component: { name: "node-http2-wasi-sockets-echo", path: componentPath, skipInstantiation: true }, jco: { transpile: { extraArgs: { asyncExports: ["*"] } } }, }); try { const runner = fileURLToPath(new URL("../fixtures/componentize/node-http2/run.js", import.meta.url)); - const output = await exec(runner, esModuleOutputPath, "external"); + const output = await exec(runner, esModuleOutputPath, "echo"); const report = JSON.parse(output.stdout); - expect(report.external).toMatchObject({ status: 200, contentType: "application/json" }); - expect(JSON.parse(report.external.body).data).toBe("client"); + expect(report.echo).toMatchObject({ status: 200, contentType: "application/json" }); + expect(JSON.parse(report.echo.body)).toEqual({ + method: "POST", + path: "/echo", + authority: "echo.test", + data: "client", + }); } finally { await cleanup(); } }, 600_000); - // TODO(unskip): enable after a published jco-std release contains the HTTP/2 exports. - test.skip("componentizes idiomatic client and server code through the direct boundary", async () => { - const { stderr } = await componentizeFixture({ + test("runs client and server callbacks through the direct component boundary", async () => { + const { componentPath, stderr } = await componentizeFixture({ fixture: "node-http2", + wit: "wit-starling", bundle: true, copy: true, extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http2-via", "direct"], }); expect(stderr).toContain("Jco added generated WIT import jco:node/http2@0.1.0"); expect(stderr).toContain("jco:node/http2-callbacks@0.1.0"); + const host = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node"); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-http2-direct", path: componentPath, skipInstantiation: true }, + jco: { transpile: { extraArgs: { asyncExports: ["*"], map: { "jco:node/http2@0.1.0": host } } } }, + }); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-http2/run-direct.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, host); + expect(JSON.parse(output.stdout)).toEqual({ plain: true, secure: true, isolated: true }); + } finally { + await cleanup(); + } }, 600_000); }); diff --git a/packages/jco/test/node/inspector.js b/packages/jco/test/node/inspector.js index 5a27fe46e..22e937a8b 100644 --- a/packages/jco/test/node/inspector.js +++ b/packages/jco/test/node/inspector.js @@ -7,6 +7,7 @@ import { expect, suite, test } from "vitest"; import { INSPECTOR_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; import { componentizeFixture, exec, getTmpDir, transpileComponent } from "../helpers.js"; +import { hasJspi } from "../common.js"; /** jco-std's Node host adapter, which an application must opt into explicitly. */ const NODE_HOST = pathToFileURL( @@ -40,12 +41,8 @@ suite("node:inspector WIT injection", () => { }); }); -suite("node:inspector in a component", () => { - // TODO(unskip): needs a jco-std release carrying the node/24.x.x inspector exports. Until then - // packages/jco resolves the published jco-std, which does not define these subpaths, so the - // guest cannot componentize. Proven green locally by pointing jco's jco-std dependency at the - // workspace build; restore before committing. - test.skip("drives the real inspector through the opt-in Node host", async () => { +suite.skipIf(!hasJspi)("node:inspector in a component", () => { + test("drives the real inspector through the opt-in Node host", async () => { // Built from a copy: componentizing rewrites the world in place to add the import/export. const { componentPath, fixtureDir, stderr } = await componentizeFixture({ fixture: "node-inspector", diff --git a/packages/jco/test/node/module.js b/packages/jco/test/node/module.js index 75ab0ddd5..9679bc5c2 100644 --- a/packages/jco/test/node/module.js +++ b/packages/jco/test/node/module.js @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import nodeModule from "node:module"; +import { builtinModules as node24Builtins } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module"; + import { suite, test } from "vitest"; import { componentizeFixture, transpileComponent } from "../helpers.js"; @@ -8,8 +10,7 @@ import { componentizeFixture, transpileComponent } from "../helpers.js"; const UNSUPPORTED = "ERR_JCO_UNSUPPORTED_NODE_API"; suite("node:module in a component", () => { - // TODO(unskip): use the published jco-std node/24.x.x module export once a release contains it. - test.skip("componentizes, computes what it can, and refuses the loading half", async () => { + test("componentizes, computes what it can, and refuses the loading half", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-module", entry: "source.js", @@ -27,7 +28,8 @@ suite("node:module in a component", () => { // Classification and source-map arithmetic are held to the host's real `node:module`, not // to values written down here, so a divergence shows up as a failure rather than as drift. assert.equal(result.moduleIsClass, nodeModule === nodeModule.Module); - assert.equal(result.builtinCount, nodeModule.builtinModules.length); + // The guest targets Node 24, even when the host runs a newer or older Node. + assert.deepEqual(result.builtins, node24Builtins); assert.equal(result.isBuiltinFs, nodeModule.isBuiltin("node:fs")); assert.equal(result.isBuiltinBareTest, nodeModule.isBuiltin("test")); assert.equal(result.isBuiltinPrefixedTest, nodeModule.isBuiltin("node:test")); diff --git a/packages/jco/test/node/os.js b/packages/jco/test/node/os.js index 6b4969a2b..0d726e7e6 100644 --- a/packages/jco/test/node/os.js +++ b/packages/jco/test/node/os.js @@ -1,13 +1,16 @@ import { readFile, writeFile } from "node:fs/promises"; import nativeOs from "node:os"; import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { assert, expect, suite, test } from "vitest"; import { OS_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host/node"); +const DENY_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host"); + suite("node:os in a component", () => { test.concurrent("installs the typed OS WIT dependency idempotently", async () => { const root = await getTmpDir(); @@ -20,51 +23,73 @@ suite("node:os in a component", () => { expect(osWit).toContain("interface os"); expect(osWit).toContain("record cpu-info"); expect(osWit).toContain("network-interfaces: func("); + expect(osWit).toEqual( + await readFile(new URL("../../../jco-std/wit/node-0.1.0/os.wit", import.meta.url), "utf8"), + ); expect(await injectNodeWitImports(root, undefined, [OS_WIT_REQUIREMENT])).toBeUndefined(); expect((await readFile(world, "utf8")).match(/import jco:node\/os@0\.1\.0;/g)).toHaveLength(1); }); - // TODO(unskip): enable after a jco-std release includes the node:os package exports. - test.skip("componentizes and reads the real host through the opt-in Node adapter", async () => { - const { componentPath, fixtureDir, stderr } = await componentizeFixture({ - fixture: "node-os", - bundle: true, - }); - assert.include(stderr, "Jco added generated WIT import jco:node/os@0.1.0"); + test.each(["node", "denied"])( + "componentizes and runs with the %s OS adapter", + async (adapter) => { + const hostSpecifier = adapter === "node" ? NODE_HOST : DENY_HOST; + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-os", + bundle: true, + copy: true, + }); + assert.include(stderr, "Jco added generated WIT import jco:node/os@0.1.0"); - const { esModuleOutputPath, cleanup } = await setupAsyncTest({ - component: { name: "node-os", path: componentPath, skipInstantiation: true }, - jco: { - transpile: { - extraArgs: { - map: { - "jco:node/os@0.1.0": pathToFileURL( - fileURLToPath( - new URL( - "../../../jco-std/dist/wasi/0.2.x/node/24.x.x/os-host-node.js", - import.meta.url, - ), - ), - ).href, + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-os", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: { + map: { + "jco:node/os@0.1.0": hostSpecifier, + }, }, }, }, - }, - }); + }); - try { - const output = await exec(join(fixtureDir, "run.js"), esModuleOutputPath); - const report = JSON.parse(output.stdout); - assert.strictEqual(report.namespaceIdentity, true); - assert.strictEqual(report.arch, nativeOs.arch()); - assert.strictEqual(report.platform, nativeOs.platform()); - assert.isAbove(report.parallelism, 0); - assert.strictEqual(report.username, nativeOs.userInfo().username); - assert.strictEqual(report.homedir, nativeOs.homedir()); - assert.strictEqual(report.eol, nativeOs.EOL); - assert.strictEqual(report.devNull, nativeOs.devNull); - } finally { - await cleanup(); - } - }, 600_000); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-os/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, hostSpecifier, adapter); + const report = JSON.parse(output.stdout); + assert.strictEqual(report.eol, "\n"); + assert.strictEqual(report.devNull, "/dev/null"); + assert.strictEqual(report.invalidArgument, 22); + if (adapter === "denied") { + expect(report.errors).toEqual( + Array(6).fill({ name: "Error", code: "ERR_JCO_OS_ADAPTER_REQUIRED" }), + ); + return; + } + assert.strictEqual(report.namespaceIdentity, true); + assert.strictEqual(report.arch, nativeOs.arch()); + assert.strictEqual(report.platform, nativeOs.platform()); + assert.isAbove(report.parallelism, 0); + assert.strictEqual(report.username, nativeOs.userInfo().username); + assert.strictEqual(report.homedir, nativeOs.homedir()); + assert.strictEqual(report.type, nativeOs.type()); + assert.strictEqual(report.bufferedUsername, nativeOs.userInfo().username); + assert.lengthOf(report.loadavg, 3); + assert.isTrue(report.loadavg.every(Number.isFinite)); + assert.isAbove(report.uptime, 0); + let nativeError; + try { + nativeOs.getPriority(2147483647); + } catch (error) { + nativeError = { name: error.name, code: error.code, syscall: error.syscall, info: error.info }; + } + assert.isDefined(nativeError, "the nonexistent PID should fail"); + assert.deepEqual(report.priorityError, nativeError); + } finally { + await cleanup(); + } + }, + 600_000, + ); }); diff --git a/packages/jco/test/node/path.js b/packages/jco/test/node/path.js index 55887c2f4..b823c075d 100644 --- a/packages/jco/test/node/path.js +++ b/packages/jco/test/node/path.js @@ -3,17 +3,14 @@ // The adapter unit tests (`node-builtins.js`) call the plugin's hooks directly, which cannot // tell whether `jco componentize` uses it at all. These build a real component and run it. import { cwd } from "node:process"; -import { join } from "node:path"; +import { join, posix, win32 } from "node:path"; import { assert, expect, suite, test } from "vitest"; import { componentizeFixture, setupAsyncTest } from "../helpers.js"; suite("node:path in a component", () => { - // TODO(unskip): jco pins @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path, which the published - // jco-std does not export yet -- it only has the ./node/path alias. Unskip once a jco-std - // release carrying the wasi/0.2.x/node/24.x.x entry points is published and jco's range is bumped to it. - test.skip("componentizes and runs lexical and cwd-backed path operations", async () => { + test("componentizes and runs lexical and cwd-backed path operations", async () => { const { componentPath, stderr } = await componentizeFixture({ fixture: "node-path", bundle: true }); assert.strictEqual(stderr, ""); @@ -28,6 +25,24 @@ suite("node:path in a component", () => { // resolve() of a relative path goes through wasi:cli/environment#initial-cwd. assert.strictEqual(instance.fromCwd(), join(cwd(), "relative")); + + // Exercise the lazily loaded matcher, including its brace-expansion dependency. + // Repeated calls also cover reuse after the first initialization. + for (const windows of [false, true]) { + const native = windows ? win32 : posix; + for (const [value, pattern] of [ + ["src/component.ts", "**/*.{js,ts}"], + ["src/component.md", "**/*.{js,ts}"], + ["file2.js", "file{1..3}.js"], + ["file4.js", "file{1..3}.js"], + ["a/b.js", "@(a|b)/*.js"], + ["literal[1].js", "literal[[]1].js"], + ["src\\component.ts", "src\\*.{js,ts}"], + ["src/component.ts", "**/*.{js,ts}"], + ]) { + assert.strictEqual(instance.match(value, pattern, windows), native.matchesGlob(value, pattern)); + } + } } finally { await cleanup(); } diff --git a/packages/jco/test/node/querystring.js b/packages/jco/test/node/querystring.js index b7751d078..7cb5f21fb 100644 --- a/packages/jco/test/node/querystring.js +++ b/packages/jco/test/node/querystring.js @@ -112,9 +112,7 @@ suite("node:querystring", () => { } }); - // TODO(unskip): global Error injection resolves jco-std's versioned Errors module, which is - // not published yet. Unskip once a release carrying that export is available to Jco's tests. - test.skip("bundles and executes APIs guest-side", async () => { + test("bundles and executes APIs guest-side", async () => { const fixtureDir = join(COMPONENT_JS_FIXTURES_DIR, "node-querystring"); const outputDir = await getTmpDir(); const componentPath = join(outputDir, "component.wasm"); diff --git a/packages/jco/test/node/stream.js b/packages/jco/test/node/stream.js index 7b32e5eeb..4edb85838 100644 --- a/packages/jco/test/node/stream.js +++ b/packages/jco/test/node/stream.js @@ -22,8 +22,7 @@ const EXPECTED_REPORT = { }; suite("Node stream modules", () => { - // TODO(unskip): use the published jco-std stream modules once a release containing them is available. - test.skip("bundles both stream APIs and executes them in a StarlingMonkey guest", async () => { + test("bundles both stream APIs and executes them in a StarlingMonkey guest", async () => { const { componentPath } = await componentizeFixture({ fixture: "node-stream", entry: "source.js", diff --git a/packages/jco/test/node/string-decoder.js b/packages/jco/test/node/string-decoder.js index 59fa847d6..7d250ad4b 100644 --- a/packages/jco/test/node/string-decoder.js +++ b/packages/jco/test/node/string-decoder.js @@ -6,9 +6,7 @@ import { suite, test } from "vitest"; import { componentizeFixture, transpileComponent } from "../helpers.js"; suite("node:string_decoder in a component", () => { - // TODO(unskip): jco pins a versioned jco-std string-decoder export that is not published yet. - // Enable this after a jco-std release containing the entry point is available to jco. - test.skip("componentizes idiomatic streaming decoders and matches Node", async () => { + test("componentizes idiomatic streaming decoders and matches Node", async () => { const { componentPath, stderr } = await componentizeFixture({ fixture: "node-string-decoder", entry: "source.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 291384506..20b5fbb95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -349,11 +349,11 @@ importers: specifier: npm:@bytecodealliance/componentize-js@^0.19.3 version: '@bytecodealliance/componentize-js@0.19.3' '@bytecodealliance/jco-std': - specifier: ^0.2.1 - version: 0.2.1 + specifier: workspace:* + version: link:../jco-std '@bytecodealliance/jco-transpile': - specifier: ^0.12.1 - version: 0.12.1 + specifier: ^0.13.0 + version: 0.13.0 '@bytecodealliance/preview2-shim': specifier: ^0.24.1 version: 0.24.1 @@ -790,15 +790,9 @@ packages: '@bytecodealliance/jco-std@0.2.0': resolution: {integrity: sha512-/GJoiSS6jVerxqAZOlm0CqbU64tokRLVdLtJeF9gnC9YVlYZA8PC/RUIW6pEtx9Np2hwHzPUTwb4RSzobaRIBw==} - '@bytecodealliance/jco-std@0.2.1': - resolution: {integrity: sha512-pJtu+dQ8RTlHCtAAhY6wwguNa196Td5T30stnXKCJvfc0mhxKNTszdosKR1YxqR4mktAVYiCn9Uvzjw8ffF5HA==} - '@bytecodealliance/jco-transpile@0.11.0': resolution: {integrity: sha512-0mTr0qGd3Y55BczPO127NTgfY/lSpRgDMElifTnSzbidsyAo4wa4zrSoqwQpBrSiJvXqTLGm1sRT2zkwGaGelw==} - '@bytecodealliance/jco-transpile@0.12.1': - resolution: {integrity: sha512-8bAYnPAkuHf8OVDeXfpDyEwymPPN6KUwzAk//cO7ixT8dFgYN1pKxBJRX4SXIKlfCjEYepJe4BVLGtL9v4hsOw==} - '@bytecodealliance/jco-transpile@0.13.0': resolution: {integrity: sha512-9hZ2GMqOlYP1Bf7qQ8v3sCsmkFoXGbgi0HtjHipzrgyk+9GGDzvvbehTGe0oM54rcbKJacvyjpPDn7wtpgzwYA==} @@ -822,9 +816,6 @@ packages: '@bytecodealliance/preview2-shim@0.21.0': resolution: {integrity: sha512-rl1NnsvBlL6Q912BUkYotiZ6L/LE8SJUnvJOuElp/kjULHnAaz8Zn4qeBiiiRmQcoZGYXXrW5dhEbPeYtcxXXQ==} - '@bytecodealliance/preview2-shim@0.22.0': - resolution: {integrity: sha512-xQclicSPicWpEkCJ7f4JiI1JuxZcktmVExh++fDxerPfP+MknUd7Yx3jjk5R9uYhTD9bdefYHw8J0zehXGQL1A==} - '@bytecodealliance/preview2-shim@0.24.1': resolution: {integrity: sha512-QGZsKD5M76xBhwu3BzLWqYDEpaMX90QWrrfhkQPn2pN9t378koumtdAVH4lr25mqGCZt10YNXkWvyWkw/vJbLQ==} @@ -837,9 +828,6 @@ packages: '@bytecodealliance/preview3-shim@0.4.0': resolution: {integrity: sha512-BSObtycxeNmR+aVJ6QnSR6OxxMBj+Tv4/CZ/VsKtcJsaaUwLe7CEiiu+GrET0mChocTw6mMfQ7DmPxwWoWnMdQ==} - '@bytecodealliance/preview3-shim@0.5.0': - resolution: {integrity: sha512-Tse27SdCca9cNU/GAXyP4cTxjiO681DOCCIrkcPXMu0D0H+VsXk4KtwTMHnbbT4lqmoVsunLZiDUUvyUI0PQBg==} - '@bytecodealliance/preview3-shim@0.6.0': resolution: {integrity: sha512-DCAswwLHjOUUIt8P73dftlD+N+sGKEs0CjO2uYZb6oirRO+w9uXMAGQtxqB5EcCUQu0y5uQeQJjr507uIo1Wmg==} @@ -4886,8 +4874,6 @@ snapshots: '@bytecodealliance/jco-std@0.2.0': {} - '@bytecodealliance/jco-std@0.2.1': {} - '@bytecodealliance/jco-transpile@0.11.0': dependencies: '@bytecodealliance/preview2-shim': 0.21.0 @@ -4895,13 +4881,6 @@ snapshots: binaryen: 130.0.0 oxc-minify: 0.136.0 - '@bytecodealliance/jco-transpile@0.12.1': - dependencies: - '@bytecodealliance/preview2-shim': 0.22.0 - '@bytecodealliance/preview3-shim': 0.5.0 - binaryen: 130.0.0 - oxc-minify: 0.136.0 - '@bytecodealliance/jco-transpile@0.13.0': dependencies: '@bytecodealliance/preview2-shim': 0.24.1 @@ -4950,10 +4929,6 @@ snapshots: '@bytecodealliance/preview2-shim@0.21.0': {} - '@bytecodealliance/preview2-shim@0.22.0': - dependencies: - '@bytecodealliance/jco-node-fs': 0.2.0 - '@bytecodealliance/preview2-shim@0.24.1': dependencies: '@bytecodealliance/jco-node-fs': 0.3.2 @@ -4970,11 +4945,6 @@ snapshots: dependencies: '@bytecodealliance/preview2-shim': 0.21.0 - '@bytecodealliance/preview3-shim@0.5.0': - dependencies: - '@bytecodealliance/jco-node-fs': 0.2.0 - '@bytecodealliance/preview2-shim': 0.22.0 - '@bytecodealliance/preview3-shim@0.6.0': dependencies: '@bytecodealliance/jco-node-fs': 0.3.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 086b856a4..c33387c7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -100,3 +100,4 @@ minimumReleaseAgeExclude: - puppeteer@25.1.0 - ws@8.21.0 - '@bytecodealliance/jco@1.26.0 || 1.26.1 || 1.27.0 || 1.30.0 || 1.31.0' + - '@bytecodealliance/jco-std@0.3.0'