From 8c208ad2e207029d97b38e4c5a398a2638ef6709 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:04:38 +0000 Subject: [PATCH 01/68] fix(std): match Node 24 in the node:http agent Claude-Session: https://claude.ai/code/session_01Pdi7wpHmmr1qBp6STdZP5e --- .../src/wasi/0.2.x/node/24.x.x/http/agent.ts | 29 +++-- .../test/wasi/0.2.x/node/24.x.x/http/agent.ts | 111 ++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts index ff5679136..ab228d8e4 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts @@ -20,6 +20,7 @@ export interface AgentOptions { timeout?: number; defaultPort?: number; protocol?: string; + noDelay?: boolean; [name: string]: unknown; } @@ -50,7 +51,10 @@ export class Agent extends EventEmitter { constructor(options: AgentOptions = {}) { super(); - this.options = { ...options }; + // lib/_http_agent.js normalises two fields into `agent.options` itself: `noDelay` + // defaults to true, and `path` is forced to null so net does not read the bag as a + // pipe target. Both are observable through `agent.options`. + this.options = { ...options, noDelay: options.noDelay ?? true, path: null }; this.defaultPort = options.defaultPort ?? 80; this.protocol = options.protocol ?? "http:"; this.keepAlive = options.keepAlive ?? false; @@ -69,14 +73,25 @@ export class Agent extends EventEmitter { } getName(options: AgentNameOptions = {}): string { + // Field order and the conditional separators follow lib/_http_agent.js at the pinned + // commit: an absent port contributes an empty field rather than `defaultPort`, and + // `family` and `socketPath` are appended only when set, so the name is variable-length. + let name = options.host || "localhost"; + name += ":"; + if (options.port) { + name += options.port; + } + name += ":"; + if (options.localAddress) { + name += options.localAddress; + } + if (options.family === 4 || options.family === 6) { + name += `:${options.family}`; + } if (options.socketPath) { - return `${options.socketPath}:`; + name += `:${options.socketPath}`; } - const host = options.host ?? "localhost"; - const port = options.port ?? this.defaultPort; - const localAddress = options.localAddress ?? ""; - const family = options.family === 4 || options.family === 6 ? `:${options.family}` : ""; - return `${host}:${port}:${localAddress}${family}`; + return name; } keepSocketAlive(_socket: unknown): boolean { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts new file mode 100644 index 000000000..09013f547 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts @@ -0,0 +1,111 @@ +import nodeHttp from "node:http"; + +import { describe, expect, test } from "vitest"; + +import { Agent, globalAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; + +interface NameCase { + label: string; + options?: Parameters[0]; +} + +const NAME_CASES: NameCase[] = [ + { label: "no arguments" }, + { label: "empty options", options: {} }, + { label: "host and port", options: { host: "example.com", port: 8080 } }, + { label: "string port", options: { host: "example.com", port: "8080" } }, + { label: "host only", options: { host: "example.com" } }, + { label: "port only", options: { port: 8080 } }, + { label: "empty host", options: { host: "" } }, + { label: "port zero", options: { host: "example.com", port: 0 } }, + { label: "local address", options: { host: "example.com", port: 80, localAddress: "1.2.3.4" } }, + { label: "family 4", options: { host: "example.com", port: 80, family: 4 } }, + { label: "family 6", options: { host: "example.com", port: 80, family: 6 } }, + { label: "family 0", options: { host: "example.com", port: 80, family: 0 } }, + { + label: "local address and family", + options: { host: "example.com", port: 80, localAddress: "1.2.3.4", family: 6 }, + }, + { label: "socket path", options: { host: "example.com", port: 80, socketPath: "/tmp/sock" } }, + { label: "socket path only", options: { socketPath: "/tmp/sock" } }, + { + label: "socket path with family", + options: { host: "example.com", port: 80, family: 4, socketPath: "/tmp/sock" }, + }, +]; + +describe("http.Agent", () => { + test.concurrent("keeps Node's option defaults", () => { + const agent = new Agent(); + expect(agent.defaultPort).toBe(80); + expect(agent.protocol).toBe("http:"); + expect(agent.keepAlive).toBe(false); + expect(agent.keepAliveMsecs).toBe(1_000); + expect(agent.maxSockets).toBe(Number.POSITIVE_INFINITY); + expect(agent.maxFreeSockets).toBe(256); + expect(agent.maxTotalSockets).toBe(Number.POSITIVE_INFINITY); + expect(agent.scheduling).toBe("lifo"); + }); + + test.concurrent("does not substitute defaultPort for an absent port", () => { + // lib/_http_agent.js appends `options.port` only when truthy, so the port field is + // empty rather than the agent's defaultPort. + expect(new Agent().getName({ host: "example.com" })).toBe("example.com::"); + expect(new Agent({ defaultPort: 8080 }).getName({ host: "example.com" })).toBe("example.com::"); + }); + + test.concurrent("appends socketPath last instead of returning early", () => { + expect(new Agent().getName({ host: "example.com", port: 80, socketPath: "/tmp/sock" })).toBe( + "example.com:80::/tmp/sock", + ); + }); + + test.concurrent("normalises noDelay and path into options like Node", () => { + expect(new Agent().options).toEqual({ noDelay: true, path: null }); + expect(new Agent({ noDelay: false, keepAlive: true }).options).toEqual({ + noDelay: false, + keepAlive: true, + path: null, + }); + }); + + test.concurrent("keeps the global agent's documented options", () => { + expect(globalAgent.keepAlive).toBe(true); + expect(globalAgent.scheduling).toBe("lifo"); + expect(globalAgent.options).toMatchObject({ + keepAlive: true, + scheduling: "lifo", + timeout: 5_000, + }); + }); + + test.concurrent("refuses to own connections", () => { + expect(() => new Agent().createConnection()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); + +describeDifferential("http.Agent differential", () => { + for (const { label, options } of NAME_CASES) { + test.concurrent(`getName matches Node for ${label}`, () => { + const portable = new Agent(); + const native = new nodeHttp.Agent(); + expect(portable.getName(options)).toBe(native.getName(options)); + }); + } + + test.concurrent("matches Node's normalised option bag", () => { + expect(new Agent().options).toEqual({ ...new nodeHttp.Agent().options }); + expect(new Agent({ keepAlive: true, maxSockets: 4 }).options).toEqual({ + ...new nodeHttp.Agent({ keepAlive: true, maxSockets: 4 }).options, + }); + }); + + test.concurrent("getName ignores the agent's own defaultPort like Node", () => { + const portable = new Agent({ defaultPort: 8080 }); + const native = new nodeHttp.Agent({ defaultPort: 8080 }); + expect(portable.getName({ host: "example.com" })).toBe(native.getName({ host: "example.com" })); + }); +}); From 298eca1231694a425a76f1f3113073b65d12cefe Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:24:23 +0000 Subject: [PATCH 02/68] fix(std): give agent: false requests a fresh node:http agent Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- .../src/wasi/0.2.x/node/24.x.x/http/client-request.ts | 8 +++++--- .../jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts | 2 +- .../jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts index bfdd71b6c..8b7b4e44c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts @@ -121,7 +121,7 @@ function abortError(reason: unknown): Error & { code: string } { } export class ClientRequestBase extends OutgoingMessage { - readonly agent: Agent | undefined; + readonly agent: Agent; readonly protocol: string; readonly host: string; readonly path: string; @@ -145,10 +145,12 @@ export class ClientRequestBase extends OutgoingMessage { this.#hostname = normalized.hostname; this.#port = normalized.port; this.#responseListener = responseListener; + // lib/_http_client.js gives an `agent: false` request a fresh instance of the default + // agent's class rather than no agent at all, so the request never shares the global pool. this.agent = normalized.options.agent === false - ? undefined - : ((normalized.options.agent as Agent | undefined) ?? globalAgent); + ? new (globalAgent.constructor as new () => Agent)() + : ((normalized.options.agent as Agent | null | undefined) ?? globalAgent); this.protocol = normalized.protocol; this.host = normalized.authority; this.path = normalized.path; 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 0b450e1e1..5421ce94e 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 @@ -15,7 +15,7 @@ export interface HttpRequestOptions { auth?: string | null; timeout?: number; signal?: AbortSignal; - agent?: AgentLike | boolean; + agent?: AgentLike | boolean | null; defaultPort?: number | string; family?: number; hints?: number; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts index ce57df36e..2f20f4344 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts @@ -4,6 +4,16 @@ import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x import { nextTurn, recordingImplementation } from "./helpers/index.js"; describe("node:http client requests", () => { + test.concurrent("gives an agent: false request a fresh agent instead of none", () => { + const { http } = recordingImplementation(); + const request = http.request({ host: "example.com", agent: false }); + expect(request.agent).toBeInstanceOf(http.Agent); + expect(request.agent).not.toBe(http.globalAgent); + expect(request.agent.keepAlive).toBe(false); + expect(http.request({ host: "example.com", agent: null }).agent).toBe(http.globalAgent); + expect(http.request({ host: "example.com" }).agent).toBe(http.globalAgent); + }); + test.concurrent("normalizes URL options and buffers a request body", async () => { const { http, requests } = recordingImplementation(); const events: string[] = []; From c3997a067ae583264c61f420645811bef0edbb18 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:24:23 +0000 Subject: [PATCH 03/68] fix(std): reject non-object node:http server options Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- .../src/wasi/0.2.x/node/24.x.x/http/core.ts | 4 +-- .../src/wasi/0.2.x/node/24.x.x/http/server.ts | 27 ++++++++++++++++--- .../wasi/0.2.x/node/24.x.x/http/server.ts | 24 +++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts index 1a194d1f9..7103ad40c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts @@ -50,7 +50,7 @@ export interface NodeHttpModule { WebSocket: RuntimeConstructor; _connectionListener: typeof connectionListener; createServer: ( - optionsOrListener?: ServerOptions | RequestListener, + optionsOrListener?: ServerOptions | RequestListener | null, listener?: RequestListener, ) => ServerBase; get: ( @@ -96,7 +96,7 @@ export function createHttp(implementation: HttpImplementation): NodeHttpModule { } function createServer( - optionsOrListener: ServerOptions | RequestListener = {}, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ): ServerBase { return new Server(optionsOrListener, listener); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts index f45646d22..a49a2a4ac 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts @@ -55,6 +55,22 @@ export interface ServerOptions { [name: string]: unknown; } +/** + * Reads the first `Server` argument the way lib/_http_server.js does. + * + * A listener or a nullish value means "no options"; any other non-object is + * `ERR_INVALID_ARG_TYPE` rather than being read as an option bag. + */ +function serverOptions(value: ServerOptions | RequestListener | null | undefined): ServerOptions { + if (typeof value === "function" || value === null || value === undefined) { + return {}; + } + if (typeof value !== "object") { + throw invalidArgType("options", "object", value); + } + return value; +} + export class ServerResponse extends OutgoingMessage { readonly req: IncomingMessage; statusCode = 200; @@ -178,7 +194,7 @@ export class ServerBase extends EventEmitter { constructor( implementation: HttpImplementation, - optionsOrListener: ServerOptions | RequestListener = {}, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { super(); @@ -189,7 +205,7 @@ export class ServerBase extends EventEmitter { "the selected HTTP implementation cannot accept inbound connections", ); } - const options = typeof optionsOrListener === "function" ? {} : optionsOrListener; + const options = serverOptions(optionsOrListener); const requestListener = typeof optionsOrListener === "function" ? optionsOrListener : listener; for (const name of [ "IncomingMessage", @@ -324,13 +340,16 @@ export class ServerBase extends EventEmitter { } export interface ServerConstructor { - new (optionsOrListener?: ServerOptions | RequestListener, listener?: RequestListener): ServerBase; + new ( + optionsOrListener?: ServerOptions | RequestListener | null, + listener?: RequestListener, + ): ServerBase; } export function createServerConstructor(implementation: HttpImplementation): ServerConstructor { return class Server extends ServerBase { constructor( - optionsOrListener: ServerOptions | RequestListener = {}, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { super(implementation, optionsOrListener, listener); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts index ff4be0d3c..30f7f11a7 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts @@ -1,3 +1,5 @@ +import nodeHttp from "node:http"; + import { describe, expect, test, vi } from "vitest"; import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; @@ -102,6 +104,28 @@ describe("node:http Server", () => { const backend = serverImplementation(); const http = createHttp(backend.implementation); expect(new http.Server()).toBeInstanceOf(http.Server); + expect(new http.Server(null)).toBeInstanceOf(http.Server); + expect(new http.Server(null, () => undefined).listenerCount("request")).toBe(1); + }); + + test("rejects a non-object options argument the way Node does", () => { + const backend = serverImplementation(); + const http = createHttp(backend.implementation); + for (const value of ["8080", 8080, true]) { + let native: unknown; + try { + nodeHttp.createServer(value as never); + } catch (error) { + native = error; + } + expect(native).toMatchObject({ code: "ERR_INVALID_ARG_TYPE" }); + expect(() => http.createServer(value as never)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: (native as Error).message, + }), + ); + } }); test("rejects server operations the buffered boundary cannot represent", async () => { From 5e4d7f1518a7bfe0ee07b6a31b21c61910a37fa0 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:34:33 +0000 Subject: [PATCH 04/68] feat(std): add the node:https shim Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- packages/jco-std/package.json | 10 + .../interfaces/wasi-cli-environment.d.ts | 4 +- .../wasi-clocks-monotonic-clock.d.ts | 4 +- .../types/interfaces/wasi-config-store.d.ts | 4 +- .../wasi-http-incoming-handler.d.ts | 2 +- .../types/interfaces/wasi-http-types.d.ts | 116 ++++---- .../types/interfaces/wasi-io-error.d.ts | 4 +- .../types/interfaces/wasi-io-poll.d.ts | 16 +- .../types/interfaces/wasi-io-streams.d.ts | 52 ++-- .../wasi/0.2.x/node/24.x.x/http-host-node.ts | 75 ++++- .../0.2.x/node/24.x.x/http/client-request.ts | 66 ++++- .../src/wasi/0.2.x/node/24.x.x/http/core.ts | 88 ++++-- .../0.2.x/node/24.x.x/http/impl/wasi-http.ts | 21 +- .../node/24.x.x/http/impl/wasi-sockets.ts | 11 +- .../wasi/0.2.x/node/24.x.x/http/profile.ts | 31 +++ .../src/wasi/0.2.x/node/24.x.x/http/server.ts | 43 ++- .../src/wasi/0.2.x/node/24.x.x/http/tls.ts | 173 ++++++++++++ .../src/wasi/0.2.x/node/24.x.x/http/types.ts | 85 +++++- .../src/wasi/0.2.x/node/24.x.x/https.ts | 15 + .../src/wasi/0.2.x/node/24.x.x/https/agent.ts | 259 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/https/core.ts | 58 ++++ packages/jco-std/wit/node-0.1.0/http.wit | 32 +++ 22 files changed, 1012 insertions(+), 157 deletions(-) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 7520288b8..378d917b0 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -176,6 +176,16 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/http2-host-node.d.ts", "node": "./dist/wasi/0.2.x/node/24.x.x/http2-host-node.js" }, + "./wasi/0.2.x/node/24.x.x/https": { + "types": "./dist/wasi/0.2.x/node/24.x.x/https.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/https.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/https.js" + }, + "./wasi/0.2.x/node/24.x.x/https/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/https/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/https/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/https/core.js" + }, "./wasi/0.2.x/node/24.x.x/path": { "types": "./dist/wasi/0.2.x/node/24.x.x/path.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/path.js", diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts index c800c96d2..b7bc48e03 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts @@ -1,10 +1,10 @@ declare module 'wasi:cli/environment@0.2.12' { /** * Get the POSIX-style environment variables. - * + * * Each environment variable is provided as a pair of string variable names * and string value. - * + * * Morally, these are a value import, but until value imports are available * in the component model, this import function should return the same * values each time it is called. diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts index 3e6b3ff4c..3744c8078 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts @@ -2,10 +2,10 @@ declare module 'wasi:clocks/monotonic-clock@0.2.12' { /** * Read the current value of the clock. - * + * * The clock is monotonic, therefore calling this function repeatedly will * produce a sequence of non-decreasing values. - * + * * For completeness, this function traps if it's not possible to represent * the value of the clock in an `instant`. Consequently, implementations * should ensure that the starting time is low enough to avoid the diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts index 0ac5fcc82..e6d9a5253 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts @@ -1,14 +1,14 @@ declare module 'wasi:config/store@0.2.0-rc.1' { /** * Gets a configuration value of type `string` associated with the `key`. - * + * * The value is returned as an `option`. If the key is not found, * `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. */ export function get(key: string): string | undefined; /** * Gets a list of configuration key-value pairs of type `string`. - * + * * If an error occurs, an `Err(error)` is returned. */ export function getAll(): Array<[string, string]>; diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts index a1e63d979..bcc6d9139 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts @@ -7,7 +7,7 @@ declare module 'wasi:http/incoming-handler@0.2.12' { * method, which allows execution to continue after the response has been * sent. This enables both streaming to the response body, and performing other * work. - * + * * The implementor of this function must write a response to the * `response-outparam` before returning, or else the caller will respond * with an error on its behalf. diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts index 2ec86da20..fb43ce6cc 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts @@ -6,13 +6,13 @@ declare module 'wasi:http/types@0.2.12' { /** * Attempts to extract a http-related `error` from the wasi:io `error` * provided. - * + * * Stream operations which return * `wasi:io/stream.stream-error.last-operation-failed` have a payload of * type `wasi:io/error.error` with more information about the operation * that failed. This payload can be passed through to this function to see * if there's http-related information about the error to return. - * + * * Note that this function is fallible because not all io-errors are * http-related errors. */ @@ -265,18 +265,18 @@ declare module 'wasi:http/types@0.2.12' { } /** * Field keys are always strings. - * + * * Field keys should always be treated as case insensitive by the `fields` * resource for the purposes of equality checking. - * + * * # Deprecation - * + * * This type has been deprecated in favor of the `field-name` type. */ export type FieldKey = string; /** * Field names are always strings. - * + * * Field names should always be treated as case insensitive by the `fields` * resource for the purposes of equality checking. */ @@ -300,26 +300,26 @@ declare module 'wasi:http/types@0.2.12' { */ export type StatusCode = number; export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; - + export class Fields implements Disposable { /** * Construct an empty HTTP Fields. - * + * * The resulting `fields` is mutable. */ constructor() /** * Construct an HTTP Fields. - * + * * The resulting `fields` is mutable. - * + * * The list represents each name-value pair in the Fields. Names * which have multiple values are represented by multiple entries in this * list with the same name. - * + * * The tuple is a pair of the field name, represented as a string, and * Value, represented as a list of bytes. - * + * * An error result will be returned if any `field-name` or `field-value` is * syntactically invalid, or if a field is forbidden. */ @@ -339,9 +339,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Set all of the values for a name. Clears any existing values for that * name, if they have been set. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` or any of * the `field-value`s are syntactically invalid. */ @@ -349,9 +349,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Delete all values for a name. Does nothing if no values for the name * exist. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` is * syntactically invalid. */ @@ -359,9 +359,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Append a value for a name. Does not change or delete any existing * values for that name. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` or * `field-value` are syntactically invalid. */ @@ -369,11 +369,11 @@ declare module 'wasi:http/types@0.2.12' { /** * Retrieve the full set of names and values in the Fields. Like the * constructor, the list represents each name-value pair. - * + * * The outer list represents each name-value pair in the Fields. Names * which have multiple values are represented by multiple entries in this * list with the same name. - * + * * The names and values are always returned in the original casing and in * the order in which they will be serialized for transport. */ @@ -386,7 +386,7 @@ declare module 'wasi:http/types@0.2.12' { clone(): Fields; [Symbol.dispose](): void; } - + export class FutureIncomingResponse implements Disposable { /** * This type does not have a public constructor. @@ -400,14 +400,14 @@ declare module 'wasi:http/types@0.2.12' { subscribe(): Pollable; /** * Returns the incoming HTTP Response, or an error, once one is ready. - * + * * The outer `option` represents future readiness. Users can wait on this * `option` to become `some` using the `subscribe` method. - * + * * The outer `result` is used to retrieve the response or error at most * once. It will be success on the first call in which the outer option * is `some`, and error on subsequent calls. - * + * * The inner `result` represents that either the incoming HTTP Response * status and headers have received successfully, or that an error * occurred. Errors may also occur while consuming the response body, @@ -417,7 +417,7 @@ declare module 'wasi:http/types@0.2.12' { get(): Result, void> | undefined; [Symbol.dispose](): void; } - + export class FutureTrailers implements Disposable { /** * This type does not have a public constructor. @@ -432,19 +432,19 @@ declare module 'wasi:http/types@0.2.12' { /** * Returns the contents of the trailers, or an error which occurred, * once the future is ready. - * + * * The outer `option` represents future readiness. Users can wait on this * `option` to become `some` using the `subscribe` method. - * + * * The outer `result` is used to retrieve the trailers or error at most * once. It will be success on the first call in which the outer option * is `some`, and error on subsequent calls. - * + * * The inner `result` represents that either the HTTP Request or Response * body, as well as any trailers, were received successfully, or that an * error occurred receiving them. The optional `trailers` indicates whether * or not trailers were present in the body. - * + * * When some `trailers` are returned by this method, the `trailers` * resource is immutable, and a child. Use of the `set`, `append`, or * `delete` methods will return an error, and the resource must be @@ -453,7 +453,7 @@ declare module 'wasi:http/types@0.2.12' { get(): Result, void> | undefined; [Symbol.dispose](): void; } - + export class IncomingBody implements Disposable { /** * This type does not have a public constructor. @@ -461,14 +461,14 @@ declare module 'wasi:http/types@0.2.12' { private constructor(); /** * Returns the contents of the body, as a stream of bytes. - * + * * Returns success on first call: the stream representing the contents * can be retrieved at most once. Subsequent calls will return error. - * + * * The returned `input-stream` resource is a child: it must be dropped * before the parent `incoming-body` is dropped, or consumed by * `incoming-body.finish`. - * + * * This invariant ensures that the implementation can determine whether * the user is consuming the contents of the body, waiting on the * `future-trailers` to be ready, or neither. This allows for network @@ -484,7 +484,7 @@ declare module 'wasi:http/types@0.2.12' { static finish(this_: IncomingBody): FutureTrailers; [Symbol.dispose](): void; } - + export class IncomingRequest implements Disposable { /** * This type does not have a public constructor. @@ -508,10 +508,10 @@ declare module 'wasi:http/types@0.2.12' { authority(): string | undefined; /** * Get the `headers` associated with the request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * The `headers` returned are a child resource: it must be dropped before * the parent `incoming-request` is dropped. Dropping this * `incoming-request` before all children are dropped will trap. @@ -524,7 +524,7 @@ declare module 'wasi:http/types@0.2.12' { consume(): IncomingBody; [Symbol.dispose](): void; } - + export class IncomingResponse implements Disposable { /** * This type does not have a public constructor. @@ -536,10 +536,10 @@ declare module 'wasi:http/types@0.2.12' { status(): StatusCode; /** * Returns the headers from the incoming response. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `incoming-response` is dropped. */ @@ -551,7 +551,7 @@ declare module 'wasi:http/types@0.2.12' { consume(): IncomingBody; [Symbol.dispose](): void; } - + export class OutgoingBody implements Disposable { /** * This type does not have a public constructor. @@ -559,11 +559,11 @@ declare module 'wasi:http/types@0.2.12' { private constructor(); /** * Returns a stream for writing the body contents. - * + * * The returned `output-stream` is a child resource: it must be dropped * before the parent `outgoing-body` resource is dropped (or finished), * otherwise the `outgoing-body` drop or `finish` will trap. - * + * * Returns success on the first call: the `output-stream` resource for * this `outgoing-body` may be retrieved at most once. Subsequent calls * will return error. @@ -574,7 +574,7 @@ declare module 'wasi:http/types@0.2.12' { * called to signal that the response is complete. If the `outgoing-body` * is dropped without calling `outgoing-body.finalize`, the implementation * should treat the body as corrupted. - * + * * Fails if the body's `outgoing-request` or `outgoing-response` was * constructed with a Content-Length header, and the contents written * to the body (via `write`) does not match the value given in the @@ -583,14 +583,14 @@ declare module 'wasi:http/types@0.2.12' { static finish(this_: OutgoingBody, trailers: Trailers | undefined): void; [Symbol.dispose](): void; } - + export class OutgoingRequest implements Disposable { /** * Construct a new `outgoing-request` with a default `method` of `GET`, and * `none` values for `path-with-query`, `scheme`, and `authority`. - * + * * * `headers` is the HTTP Headers for the Request. - * + * * It is possible to construct, or manipulate with the accessor functions * below, an `outgoing-request` with an invalid combination of `scheme` * and `authority`, or `headers` which are not permitted to be sent. @@ -601,7 +601,7 @@ declare module 'wasi:http/types@0.2.12' { /** * Returns the resource corresponding to the outgoing Body for this * Request. - * + * * Returns success on the first call: the `outgoing-body` resource for * this `outgoing-request` can be retrieved at most once. Subsequent * calls will return error. @@ -653,10 +653,10 @@ declare module 'wasi:http/types@0.2.12' { setAuthority(authority: string | undefined): void; /** * Get the headers associated with the Request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `outgoing-request` is dropped, or its ownership is transferred to * another component by e.g. `outgoing-handler.handle`. @@ -664,13 +664,13 @@ declare module 'wasi:http/types@0.2.12' { headers(): Headers; [Symbol.dispose](): void; } - + export class OutgoingResponse implements Disposable { /** * Construct an `outgoing-response`, with a default `status-code` of `200`. * If a different `status-code` is needed, it must be set via the * `set-status-code` method. - * + * * * `headers` is the HTTP Headers for the Response. */ constructor(headers: Headers) @@ -685,10 +685,10 @@ declare module 'wasi:http/types@0.2.12' { setStatusCode(statusCode: StatusCode): void; /** * Get the headers associated with the Request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `outgoing-request` is dropped, or its ownership is transferred to * another component by e.g. `outgoing-handler.handle`. @@ -696,7 +696,7 @@ declare module 'wasi:http/types@0.2.12' { headers(): Headers; /** * Returns the resource corresponding to the outgoing Body for this Response. - * + * * Returns success on the first call: the `outgoing-body` resource for * this `outgoing-response` can be retrieved at most once. Subsequent * calls will return error. @@ -704,7 +704,7 @@ declare module 'wasi:http/types@0.2.12' { body(): OutgoingBody; [Symbol.dispose](): void; } - + export class RequestOptions implements Disposable { /** * Construct a default `request-options` value. @@ -741,7 +741,7 @@ declare module 'wasi:http/types@0.2.12' { setBetweenBytesTimeout(duration: Duration | undefined): void; [Symbol.dispose](): void; } - + export class ResponseOutparam implements Disposable { /** * This type does not have a public constructor. @@ -750,11 +750,11 @@ declare module 'wasi:http/types@0.2.12' { /** * Set the value of the `response-outparam` to either send a response, * or indicate an error. - * + * * This method consumes the `response-outparam` to ensure that it is * called at most once. If it is never called, the implementation * will respond with an error. - * + * * The user may provide an `error` to `response` to allow the * implementation determine how to respond with an HTTP error response. */ diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts index af82b6319..c285e878b 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts @@ -1,5 +1,5 @@ declare module 'wasi:io/error@0.2.12' { - + export class Error implements Disposable { /** * This type does not have a public constructor. @@ -8,7 +8,7 @@ declare module 'wasi:io/error@0.2.12' { /** * Returns a string that is suitable to assist humans in debugging * this error. - * + * * WARNING: The returned string should not be consumed mechanically! * It may change across platforms, hosts, or other implementation * details. Parsing this string is a major platform-compatibility diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts index 7e40bdbe1..9a3022734 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts @@ -1,27 +1,27 @@ declare module 'wasi:io/poll@0.2.12' { /** * Poll for completion on a set of pollables. - * + * * This function takes a list of pollables, which identify I/O sources of * interest, and waits until one or more of the events is ready for I/O. - * + * * The result `list` contains one or more indices of handles in the * argument list that is ready for I/O. - * + * * This function traps if either: * - the list is empty, or: * - the list contains more elements than can be indexed with a `u32` value. - * + * * A timeout can be implemented by adding a pollable from the * wasi-clocks API to the list. - * + * * This function does not return a `result`; polling in itself does not * do any I/O so it doesn't fail. If any of the I/O sources identified by * the pollables has an error, it is indicated by marking the source as * being ready for I/O. */ export function poll(in_: Array): Uint32Array; - + export class Pollable implements Disposable { /** * This type does not have a public constructor. @@ -29,14 +29,14 @@ declare module 'wasi:io/poll@0.2.12' { private constructor(); /** * Return the readiness of a pollable. This function never blocks. - * + * * Returns `true` when the pollable is ready, and `false` otherwise. */ ready(): boolean; /** * `block` returns immediately if the pollable is ready, and otherwise * blocks until ready. - * + * * This function is equivalent to calling `poll.poll` on a list * containing only this pollable. */ diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts index f7ba066c7..64403d4e2 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts @@ -9,9 +9,9 @@ declare module 'wasi:io/streams@0.2.12' { export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; /** * The last operation (a write or flush) failed before completion. - * + * * More information is available in the `error` payload. - * + * * After this, the stream will be closed. All future operations return * `stream-error::closed`. */ @@ -27,7 +27,7 @@ declare module 'wasi:io/streams@0.2.12' { export interface StreamErrorClosed { tag: 'closed', } - + export class InputStream implements Disposable { /** * This type does not have a public constructor. @@ -35,27 +35,27 @@ declare module 'wasi:io/streams@0.2.12' { private constructor(); /** * Perform a non-blocking read from the stream. - * + * * When the source of a `read` is binary data, the bytes from the source * are returned verbatim. When the source of a `read` is known to the * implementation to be text, bytes containing the UTF-8 encoding of the * text are returned. - * + * * This function returns a list of bytes containing the read data, * when successful. The returned list will contain up to `len` bytes; * it may return fewer than requested, but not more. The list is * empty when no bytes are available for reading at this time. The * pollable given by `subscribe` will be ready when more bytes are * available. - * + * * This function fails with a `stream-error` when the operation * encounters an error, giving `last-operation-failed`, or when the * stream is closed, giving `closed`. - * + * * When the caller gives a `len` of 0, it represents a request to * read 0 bytes. If the stream is still open, this call should * succeed and return an empty list, or otherwise fail with `closed`. - * + * * The `len` parameter is a `u64`, which could represent a list of u8 which * is not possible to allocate in wasm32, or not desirable to allocate as * as a return value by the callee. The callee may return a list of bytes @@ -69,7 +69,7 @@ declare module 'wasi:io/streams@0.2.12' { blockingRead(len: bigint): Uint8Array; /** * Skip bytes from a stream. Returns number of bytes skipped. - * + * * Behaves identical to `read`, except instead of returning a list * of bytes, returns the number of bytes consumed from the stream. */ @@ -90,7 +90,7 @@ declare module 'wasi:io/streams@0.2.12' { subscribe(): Pollable; [Symbol.dispose](): void; } - + export class OutputStream implements Disposable { /** * This type does not have a public constructor. @@ -98,11 +98,11 @@ declare module 'wasi:io/streams@0.2.12' { private constructor(); /** * Check readiness for writing. This function never blocks. - * + * * Returns the number of bytes permitted for the next call to `write`, * or an error. Calling `write` with more bytes than this function has * permitted will trap. - * + * * When this function returns 0 bytes, the `subscribe` pollable will * become ready when this function will report at least 1 byte, or an * error. @@ -110,16 +110,16 @@ declare module 'wasi:io/streams@0.2.12' { checkWrite(): bigint; /** * Perform a write. This function never blocks. - * + * * When the destination of a `write` is binary data, the bytes from * `contents` are written verbatim. When the destination of a `write` is * known to the implementation to be text, the bytes of `contents` are * transcoded from UTF-8 into the encoding of the destination and then * written. - * + * * Precondition: check-write gave permit of Ok(n) and contents has a * length of less than or equal to n. Otherwise, this function will trap. - * + * * returns Err(closed) without writing if the stream has closed since * the last call to check-write provided a permit. */ @@ -127,7 +127,7 @@ declare module 'wasi:io/streams@0.2.12' { /** * Perform a write of up to 4096 bytes, and then flush the stream. Block * until all of these operations are complete, or an error occurs. - * + * * Returns success when all of the contents written are successfully * flushed to output. If an error occurs at any point before all * contents are successfully flushed, that error is returned as soon as @@ -139,11 +139,11 @@ declare module 'wasi:io/streams@0.2.12' { blockingWriteAndFlush(contents: Uint8Array): void; /** * Request to flush buffered output. This function never blocks. - * + * * This tells the output-stream that the caller intends any buffered * output to be flushed. the output which is expected to be flushed * is all that has been passed to `write` prior to this call. - * + * * Upon calling this function, the `output-stream` will not accept any * writes (`check-write` will return `ok(0)`) until the flush has * completed. The `subscribe` pollable will become ready when the @@ -160,9 +160,9 @@ declare module 'wasi:io/streams@0.2.12' { * is ready for more writing, or an error has occurred. When this * pollable is ready, `check-write` will return `ok(n)` with n>0, or an * error. - * + * * If the stream is closed, this pollable is always ready immediately. - * + * * The created `pollable` is a child resource of the `output-stream`. * Implementations may trap if the `output-stream` is dropped before * all derived `pollable`s created with this function are dropped. @@ -170,7 +170,7 @@ declare module 'wasi:io/streams@0.2.12' { subscribe(): Pollable; /** * Write zeroes to a stream. - * + * * This should be used precisely like `write` with the exact same * preconditions (must use check-write first), but instead of * passing a list of bytes, you simply pass the number of zero-bytes @@ -181,30 +181,30 @@ declare module 'wasi:io/streams@0.2.12' { * Perform a write of up to 4096 zeroes, and then flush the stream. * Block until all of these operations are complete, or an error * occurs. - * + * * Functionality is equivelant to `blocking-write-and-flush` with * contents given as a list of len containing only zeroes. */ blockingWriteZeroesAndFlush(len: bigint): void; /** * Read from one stream and write to another. - * + * * The behavior of splice is equivalent to: * 1. calling `check-write` on the `output-stream` * 2. calling `read` on the `input-stream` with the smaller of the * `check-write` permitted length and the `len` provided to `splice` * 3. calling `write` on the `output-stream` with that read data. - * + * * Any error reported by the call to `check-write`, `read`, or * `write` ends the splice and reports that error. - * + * * This function returns the number of bytes transferred; it may be less * than `len`. */ splice(src: InputStream, len: bigint): bigint; /** * Read from one stream and write to another, with blocking. - * + * * This is similar to `splice`, except that it blocks until the * `output-stream` is ready for writing, and the `input-stream` * is ready for reading, before performing the `splice`. 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 51cb56a1e..fce71f1e4 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 @@ -2,16 +2,21 @@ * Opt-in Node.js HTTP provider. * * The operation mapping follows nodejs/node v24.19.0, commit - * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js and + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js, lib/https.js, and * lib/_http_client.js (MIT license). The Node stream lifecycle is adapted to - * one buffered, typed WIT request/response exchange. + * one buffered, typed WIT request/response exchange. Requests with the `https` + * scheme and servers carrying a `tls` record go through real `node:https`, so + * TLS is terminated by the host's own stack. */ +import { Buffer } from "node:buffer"; import * as nodeHttp from "node:http"; import { CallbackResource, createCallbackQueue, retireCallbacks, } from "./internal/callback-resource.js"; +import * as nodeHttps from "node:https"; +import type * as nodeTls from "node:tls"; import { fieldsToRawHeaders, @@ -30,10 +35,56 @@ import type { DirectHttpServerAddress, DirectHttpServerConstructor, DirectHttpServerOptions, + DirectTlsOptions, } from "./http/types.js"; type AsyncResult = Promise>; type Timer = ReturnType; +type NodeTlsOptions = nodeTls.SecureContextOptions & + Pick & + Pick; + +function buffers(values: Uint8Array[]): Buffer[] { + return values.map((value) => Buffer.from(value)); +} + +/** + * Maps the WIT `tls-options` record onto the option names `node:tls` reads. + * + * Only present fields are copied, so Node applies its own defaults for the rest exactly as it + * would for a native caller. + */ +function nodeTlsOptions(tls: DirectTlsOptions): NodeTlsOptions { + const options: NodeTlsOptions = { + key: tls.key && buffers(tls.key), + cert: tls.cert && buffers(tls.cert), + pfx: tls.pfx && buffers(tls.pfx), + passphrase: tls.passphrase, + ca: tls.ca && buffers(tls.ca), + crl: tls.crl && buffers(tls.crl), + dhparam: tls.dhparam && Buffer.from(tls.dhparam), + ciphers: tls.ciphers, + ecdhCurve: tls.ecdhCurve, + sigalgs: tls.sigalgs, + minVersion: tls.minVersion as nodeTls.SecureVersion | undefined, + maxVersion: tls.maxVersion as nodeTls.SecureVersion | undefined, + secureProtocol: tls.secureProtocol, + secureOptions: tls.secureOptions, + sessionIdContext: tls.sessionIdContext, + honorCipherOrder: tls.honorCipherOrder, + ALPNProtocols: tls.alpnProtocols, + servername: tls.servername, + rejectUnauthorized: tls.rejectUnauthorized, + requestCert: tls.requestCert, + }; + for (const [name, value] of Object.entries(options)) { + if (value === undefined) { + delete options[name as keyof NodeTlsOptions]; + } + } + return options; +} + function timeoutError(syscall: string): Error & { code: string; syscall: string } { return Object.assign(new Error(`HTTP ${syscall} timed out`), { code: "ETIMEDOUT", @@ -50,12 +101,16 @@ export async function request(options: DirectHttpRequest): AsyncResult { clearTimeout(connectTimer); @@ -143,13 +198,23 @@ function serverAddress( class NodeHttpServer { readonly #pending = new Set>(); - readonly #server: nodeHttp.Server; + readonly #server: nodeHttp.Server | nodeHttps.Server; constructor( options: DirectHttpServerOptions, handle: (request: DirectHttpIncomingRequest) => Promise, ) { - this.#server = nodeHttp.createServer(nodeServerOptions(options), (request, response) => { + // A TLS record, including an empty one, selects a native HTTPS server. + const create = + options.tls === undefined + ? (handler: nodeHttp.RequestListener) => + nodeHttp.createServer(nodeServerOptions(options), handler) + : (handler: nodeHttp.RequestListener) => + nodeHttps.createServer( + { ...nodeServerOptions(options), ...nodeTlsOptions(options.tls!) }, + handler, + ); + this.#server = create((request, response) => { const pending = this.#handle(handle, request, response); this.#pending.add(pending); const complete = () => { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts index 8b7b4e44c..0e6ef352c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts @@ -9,17 +9,20 @@ * request/response exchange with the selected implementation. */ -import { Agent, globalAgent } from "./agent.js"; +import { Agent } from "./agent.js"; import { base64 } from "./body.js"; import { deprecated, invalidArgType, invalidArgValue, unsupported } from "./errors.js"; import { validateHeaderName } from "./headers.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import type { ProtocolProfile } from "./profile.js"; +import { tlsMaterial } from "./tls.js"; import type { HttpImplementation, HttpImplementationRequest, HttpImplementationResponse, HttpRequestOptions, + HttpTlsMaterial, } from "./types.js"; export type ResponseListener = (response: IncomingMessage) => void; @@ -35,6 +38,22 @@ interface NormalizedRequest { path: string; } +/** + * Resolves the port assumed when the options carry none. + * + * lib/_http_client.js reads `options.defaultPort || (this.agent && this.agent.defaultPort)`, + * and an `agent: false` request still gets a fresh instance of the module's own agent class, + * so the profile's port is the correct final fallback for both modules. + */ +function resolvedDefaultPort(options: HttpRequestOptions, profile: ProtocolProfile): number { + const agent = options.agent; + const agentDefaultPort = + typeof agent === "object" && agent !== null && typeof agent.defaultPort === "number" + ? agent.defaultPort + : undefined; + return Number(options.defaultPort || agentDefaultPort || profile.defaultPort); +} + function urlOptions(input: string | URL): HttpRequestOptions { const url = input instanceof URL ? input : new URL(input); return { @@ -60,17 +79,18 @@ function numericPort(value: number | string | null | undefined, fallback: number function normalizedRequest( input: RequestInput, extra: HttpRequestOptions | undefined, + profile: ProtocolProfile, ): NormalizedRequest { const base = typeof input === "string" || input instanceof URL ? urlOptions(input) : input; if (typeof base !== "object" || base === null) { throw invalidArgType("options", "object, string, or URL", input); } const options = { ...base, ...extra }; - const protocol = options.protocol ?? "http:"; - if (protocol !== "http:") { + const protocol = options.protocol ?? profile.protocol; + if (protocol !== profile.protocol) { const error = invalidArgValue("protocol", protocol); error.code = "ERR_INVALID_PROTOCOL"; - error.message = `Protocol \"${protocol}\" not supported. Expected \"http:\"`; + error.message = `Protocol \"${protocol}\" not supported. Expected \"${profile.protocol}\"`; throw error; } let hostname = options.hostname ?? options.host ?? "localhost"; @@ -85,7 +105,8 @@ function normalizedRequest( } else if (hostname.split(":").length === 2) { [hostname, hostPort] = hostname.split(":"); } - const port = numericPort(options.port ?? hostPort, Number(options.defaultPort ?? 80)); + const defaultPort = resolvedDefaultPort(options, profile); + const port = numericPort(options.port ?? hostPort, defaultPort); const method = (options.method ?? "GET").toUpperCase(); validateHeaderName(method, "Method"); const path = options.path ?? "/"; @@ -105,7 +126,7 @@ function normalizedRequest( protocol, hostname, port, - authority: port === 80 ? authorityHost : `${authorityHost}:${port}`, + authority: port === defaultPort ? authorityHost : `${authorityHost}:${port}`, path, }; } @@ -129,28 +150,38 @@ export class ClientRequestBase extends OutgoingMessage { readonly reusedSocket = false; maxHeadersCount: number | null = null; readonly #implementation: HttpImplementation; + readonly #profile: ProtocolProfile; readonly #hostname: string; readonly #port: number; + readonly #tls: HttpTlsMaterial | undefined; readonly #responseListener: ResponseListener | undefined; constructor( implementation: HttpImplementation, + profile: ProtocolProfile, input: RequestInput, options: HttpRequestOptions | undefined, responseListener: ResponseListener | undefined, ) { - const normalized = normalizedRequest(input, options); + const normalized = normalizedRequest(input, options, profile); super(normalized.options.headers); this.#implementation = implementation; + this.#profile = profile; this.#hostname = normalized.hostname; this.#port = normalized.port; + // lib/https.js hands the whole option bag to tls.connect; the shim carries the + // serializable subset and refuses the rest by name before anything is sent. + this.#tls = + profile.scheme === "https" + ? tlsMaterial(normalized.options, `${profile.module}.request option`) + : undefined; this.#responseListener = responseListener; // lib/_http_client.js gives an `agent: false` request a fresh instance of the default // agent's class rather than no agent at all, so the request never shares the global pool. this.agent = normalized.options.agent === false - ? new (globalAgent.constructor as new () => Agent)() - : ((normalized.options.agent as Agent | null | undefined) ?? globalAgent); + ? new (profile.globalAgent.constructor as new () => Agent)() + : ((normalized.options.agent as Agent | null | undefined) ?? profile.globalAgent); this.protocol = normalized.protocol; this.host = normalized.authority; this.path = normalized.path; @@ -176,19 +207,19 @@ export class ClientRequestBase extends OutgoingMessage { } abort(): never { - return deprecated("http.ClientRequest.abort", "request.destroy()"); + return deprecated(`${this.#profile.module}.ClientRequest.abort`, "request.destroy()"); } setNoDelay(_noDelay = true): never { return unsupported( - "http.ClientRequest.setNoDelay", + `${this.#profile.module}.ClientRequest.setNoDelay`, "the selected implementation owns the socket", ); } setSocketKeepAlive(_enable = false, _initialDelay = 0): never { return unsupported( - "http.ClientRequest.setSocketKeepAlive", + `${this.#profile.module}.ClientRequest.setSocketKeepAlive`, "the selected implementation owns the socket", ); } @@ -206,7 +237,7 @@ export class ClientRequestBase extends OutgoingMessage { const timeout = this._timeout() || undefined; const request: HttpImplementationRequest = { method: this.method, - scheme: "http", + scheme: this.#profile.scheme, authority: this.host, pathWithQuery: this.path, headers: this._headers.fields(), @@ -215,6 +246,9 @@ export class ClientRequestBase extends OutgoingMessage { firstByteTimeoutMs: timeout, betweenBytesTimeoutMs: timeout, }; + if (this.#tls !== undefined) { + request.tls = this.#tls; + } const response = this.#implementation.request(request); return () => this.#deliver(response); } @@ -242,7 +276,10 @@ export interface ClientRequestConstructor { ): ClientRequestBase; } -export function createClientRequest(implementation: HttpImplementation): ClientRequestConstructor { +export function createClientRequest( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ClientRequestConstructor { return class ClientRequest extends ClientRequestBase { constructor( input: RequestInput, @@ -251,6 +288,7 @@ export function createClientRequest(implementation: HttpImplementation): ClientR ) { super( implementation, + profile, input, typeof options === "function" ? undefined : options, typeof options === "function" ? options : callback, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts index 7103ad40c..0048c1178 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts @@ -1,6 +1,7 @@ import { Agent, globalAgent } from "./agent.js"; import { ClientRequestBase, + type ClientRequestConstructor, createClientRequest, type RequestInput, type ResponseListener, @@ -10,6 +11,7 @@ import { invalidArgType, outOfRange, unsupported } from "./errors.js"; import { validateHeaderName, validateHeaderValue } from "./headers.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import { HTTP_PROFILE, type ProtocolProfile } from "./profile.js"; import { connectionListener, createServerConstructor, @@ -36,19 +38,12 @@ function globalConstructor(name: "WebSocket" | "CloseEvent" | "MessageEvent"): R return typeof value === "function" ? (value as RuntimeConstructor) : unavailableConstructor(name); } -export interface NodeHttpModule { - Agent: typeof Agent; - ClientRequest: ReturnType; - CloseEvent: RuntimeConstructor; - IncomingMessage: typeof IncomingMessage; - METHODS: string[]; - MessageEvent: RuntimeConstructor; - OutgoingMessage: typeof OutgoingMessage; - STATUS_CODES: Record; +/** + * The protocol-dependent half of `node:http`, which `node:https` reuses wholesale. + */ +export interface ProtocolModule { + ClientRequest: ClientRequestConstructor; Server: ServerConstructor; - ServerResponse: typeof ServerResponse; - WebSocket: RuntimeConstructor; - _connectionListener: typeof connectionListener; createServer: ( optionsOrListener?: ServerOptions | RequestListener | null, listener?: RequestListener, @@ -58,24 +53,25 @@ export interface NodeHttpModule { options?: HttpRequestOptions | ResponseListener, callback?: ResponseListener, ) => ClientRequestBase; - globalAgent: Agent; - maxHeaderSize: number; request: ( input: RequestInput, options?: HttpRequestOptions | ResponseListener, callback?: ResponseListener, ) => ClientRequestBase; - setGlobalProxyFromEnv: (environment?: Record) => never; - setMaxIdleHTTPParsers: (max: number) => void; - validateHeaderName: typeof validateHeaderName; - validateHeaderValue: typeof validateHeaderValue; } -let maxIdleHttpParsers = 1_000; - -export function createHttp(implementation: HttpImplementation): NodeHttpModule { - const ClientRequest = createClientRequest(implementation); - const Server = createServerConstructor(implementation); +/** + * Builds the request/server surface for one protocol. + * + * `lib/https.js` reuses `_http_client` and `_http_server` unchanged and only varies the + * protocol, default port, and default agent, so both modules share this builder. + */ +export function createProtocolModule( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ProtocolModule { + const ClientRequest = createClientRequest(implementation, profile); + const Server = createServerConstructor(implementation, profile); function request( input: RequestInput, @@ -102,6 +98,52 @@ export function createHttp(implementation: HttpImplementation): NodeHttpModule { return new Server(optionsOrListener, listener); } + return { ClientRequest, Server, createServer, get, request }; +} + +export interface NodeHttpModule { + Agent: typeof Agent; + ClientRequest: ClientRequestConstructor; + CloseEvent: RuntimeConstructor; + IncomingMessage: typeof IncomingMessage; + METHODS: string[]; + MessageEvent: RuntimeConstructor; + OutgoingMessage: typeof OutgoingMessage; + STATUS_CODES: Record; + Server: ServerConstructor; + ServerResponse: typeof ServerResponse; + WebSocket: RuntimeConstructor; + _connectionListener: typeof connectionListener; + createServer: ( + optionsOrListener?: ServerOptions | RequestListener | null, + listener?: RequestListener, + ) => ServerBase; + get: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + globalAgent: Agent; + maxHeaderSize: number; + request: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + setGlobalProxyFromEnv: (environment?: Record) => never; + setMaxIdleHTTPParsers: (max: number) => void; + validateHeaderName: typeof validateHeaderName; + validateHeaderValue: typeof validateHeaderValue; +} + +let maxIdleHttpParsers = 1_000; + +export function createHttp(implementation: HttpImplementation): NodeHttpModule { + const { ClientRequest, Server, createServer, get, request } = createProtocolModule( + implementation, + HTTP_PROFILE, + ); + function setMaxIdleHTTPParsers(max: number): void { if (typeof max !== "number") { throw invalidArgType("max", "number", max); 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 df9a45b52..4c993d6a9 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 @@ -149,6 +149,17 @@ function method(value: string): WasiHttpMethod { : { tag: "other", val: value }; } +function scheme(value: string): WasiHttpScheme { + switch (value) { + case "http": + return { tag: "HTTP" }; + case "https": + return { tag: "HTTPS" }; + default: + return { tag: "other", val: value }; + } +} + function duration(milliseconds: number | undefined): bigint | undefined { return milliseconds === undefined ? undefined : BigInt(milliseconds) * 1_000_000n; } @@ -229,6 +240,12 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm "wasi:http outgoing-handler cannot accept arbitrary inbound HTTP connections", request(request) { + if (request.tls !== undefined) { + unsupported( + `${request.scheme}.request TLS options with the wasi-http implementation`, + "wasi:http/outgoing-handler owns certificate validation and cannot take per-request TLS configuration", + ); + } try { const host = request.headers.find(({ name }) => name.toLowerCase() === "host"); if ( @@ -250,9 +267,7 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm ); const outgoing = new provider.types.OutgoingRequest(fields); outgoing.setMethod(method(request.method)); - outgoing.setScheme( - request.scheme === "http" ? { tag: "HTTP" } : { tag: "other", val: request.scheme }, - ); + outgoing.setScheme(scheme(request.scheme)); outgoing.setAuthority(request.authority); outgoing.setPathWithQuery(request.pathWithQuery); const body = outgoing.body(); 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 f2ea2d178..46ac41e5e 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 @@ -556,12 +556,21 @@ export function createWasiSocketsHttpImplementation( ): HttpImplementation { return { createServer(options, handler, onError) { + if (options.tls !== undefined) { + unsupported( + "https.Server with the wasi-sockets implementation", + "wasi:sockets carries no TLS stack, so a server can only speak plaintext HTTP/1.1", + ); + } return new WasiSocketsHttpServer(provider, options, handler, onError); }, request(request) { if (request.scheme !== "http") { - throw invalidArgValue("protocol", `${request.scheme}:`); + unsupported( + `${request.scheme}: requests with the wasi-sockets implementation`, + "wasi:sockets carries no TLS stack, so a client can only speak plaintext HTTP/1.1", + ); } if ( request.connectTimeoutMs !== undefined || diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts new file mode 100644 index 000000000..a30a1846a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts @@ -0,0 +1,31 @@ +/** + * Protocol profile shared by the node:http and node:https shims. + * + * `node:https` is `lib/_http_client.js` and `lib/_http_server.js` driven with a different + * protocol, default port, and global agent (nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js). Rather than fork those modules, + * every protocol-dependent constant is collected here and threaded through the shared core. + */ + +import { Agent, globalAgent } from "./agent.js"; + +export interface ProtocolProfile { + /** Module name used in error labels, e.g. `https.Server`. */ + readonly module: "http" | "https"; + /** `options.protocol` this module accepts; anything else is `ERR_INVALID_PROTOCOL`. */ + readonly protocol: "http:" | "https:"; + /** URI scheme handed to the selected implementation. */ + readonly scheme: "http" | "https"; + /** Port assumed when neither the options nor an explicit agent supply one. */ + readonly defaultPort: number; + /** Agent used when `options.agent` is absent, matching Node's `_defaultAgent`. */ + readonly globalAgent: Agent; +} + +export const HTTP_PROFILE: ProtocolProfile = { + module: "http", + protocol: "http:", + scheme: "http", + defaultPort: 80, + globalAgent, +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts index a49a2a4ac..37dba01b7 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts @@ -16,6 +16,8 @@ import { codedError } from "../errors/core.js"; import { invalidArgType, invalidArgValue, unsupported } from "./errors.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import type { ProtocolProfile } from "./profile.js"; +import { tlsMaterial } from "./tls.js"; import type { HttpBodyChunk, HttpCallback, @@ -27,12 +29,19 @@ import type { HttpOutgoingResponseData, HttpServerAddress, HttpServerImplementation, + HttpTlsOptions, } from "./types.js"; export type RequestListener = (request: IncomingMessage, response: ServerResponse) => void; export type GetConnectionsCallback = (error: Error | null, count: number) => void; -export interface ServerOptions { +/** + * `http.createServer` options plus the TLS material `https.createServer` accepts. + * + * Node routes the TLS half to `tls.Server` and the rest to `_http_server`; the shim keeps one + * option bag and lets the profile decide whether the TLS half is read at all. + */ +export interface ServerOptions extends HttpTlsOptions { requestTimeout?: number; headersTimeout?: number; keepAliveTimeout?: number; @@ -192,15 +201,19 @@ export class ServerBase extends EventEmitter { requestTimeout: number; #server: HttpServerImplementation; + readonly #profile: ProtocolProfile; + constructor( implementation: HttpImplementation, + profile: ProtocolProfile, optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { super(); + this.#profile = profile; if (!implementation.createServer) { unsupported( - "http.Server", + `${profile.module}.Server`, implementation.serverUnsupportedReason ?? "the selected HTTP implementation cannot accept inbound connections", ); @@ -217,7 +230,7 @@ export class ServerBase extends EventEmitter { ] as const) { if (options[name] !== undefined) { unsupported( - `http.Server option ${name}`, + `${profile.module}.Server option ${name}`, "this option cannot be represented by the current typed WIT boundary", ); } @@ -230,8 +243,17 @@ export class ServerBase extends EventEmitter { if (requestListener) { this.on("request", requestListener); } + // `tls.Server` terminates TLS below the HTTP layer, so the material is normalized once + // here and carried by the implementation rather than by any HTTP-level option. An https + // server always carries the record, even an empty one, so an implementation with no TLS + // stack refuses it rather than serving plaintext; Node itself constructs an https.Server + // without a certificate and fails each handshake instead. + const tls = + profile.scheme === "https" + ? (tlsMaterial(options, `${profile.module}.createServer option`) ?? {}) + : undefined; this.#server = implementation.createServer( - options, + { ...options, tls }, (request) => this.#handle(request), (error) => queueMicrotask(() => this.emit("error", error)), ); @@ -241,7 +263,7 @@ export class ServerBase extends EventEmitter { const { options, callback } = parseListenArguments(args); if (options.signal !== undefined) { unsupported( - "http.Server.listen signal", + `${this.#profile.module}.Server.listen signal`, "an AbortSignal cannot be retained across the current WIT server resource boundary", ); } @@ -300,7 +322,7 @@ export class ServerBase extends EventEmitter { setTimeout(milliseconds = 0, callback?: (...args: never[]) => unknown): this { if (milliseconds !== 0 || callback !== undefined) { unsupported( - "http.Server.setTimeout", + `${this.#profile.module}.Server.setTimeout`, "timeout events require an additional server callback across the WIT boundary", ); } @@ -332,7 +354,7 @@ export class ServerBase extends EventEmitter { const request = new IncomingMessage(data); const response = new ServerResponse(request); if (!this.emit("request", request, response)) { - unsupported("http.Server request", "the server has no request listener"); + unsupported(`${this.#profile.module}.Server request`, "the server has no request listener"); } request._start(); return response._completed(); @@ -346,13 +368,16 @@ export interface ServerConstructor { ): ServerBase; } -export function createServerConstructor(implementation: HttpImplementation): ServerConstructor { +export function createServerConstructor( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ServerConstructor { return class Server extends ServerBase { constructor( optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { - super(implementation, optionsOrListener, listener); + super(implementation, profile, optionsOrListener, listener); } }; } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts new file mode 100644 index 000000000..96f1377e2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts @@ -0,0 +1,173 @@ +/** + * Normalizes Node's TLS options into the material the WIT boundary carries. + * + * The accepted option shapes follow nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/_tls_common.js `configSecureContext`, + * lib/_tls_wrap.js, and doc/api/tls.md (MIT license). Only the serializable subset crosses the + * boundary: PEM/DER/PFX blobs, strings, booleans, and ALPN protocol names. Options that install + * a callback, hand over an opaque host handle, or name an OpenSSL engine have no representation + * in a typed WIT record and are refused by name instead of being silently dropped. Options the + * record does not carry at all are ignored, exactly as `http.createServer` ignores keys it does + * not know. + * + * Every material field stays a list. Node accepts arrays for `key`, `cert`, `pfx`, `ca`, and + * `crl`, and OpenSSL reads only the first key from a concatenated PEM, so joining a `key` + * bundle into one blob would silently drop every key after the first. + */ + +import { invalidArgType, outOfRange, unsupported } from "./errors.js"; +import type { HttpTlsMaterial, HttpTlsOptions, TlsMaterial } from "./types.js"; + +const CALLBACK = "a callback cannot be retained across the WIT boundary"; +const ENGINE = "OpenSSL engines are not addressable from a component"; + +/** TLS options that cannot cross a typed WIT boundary, mapped to the reason they cannot. */ +const UNREPRESENTABLE_TLS_OPTIONS: Readonly> = { + ALPNCallback: CALLBACK, + SNICallback: CALLBACK, + checkServerIdentity: CALLBACK, + pskCallback: CALLBACK, + secureContext: "a prebuilt SecureContext is an opaque host handle", + session: "a TLS session is bound to the implementation's own connections", + ticketKeys: "TLS ticket keys are owned by the implementation", + clientCertEngine: ENGINE, + privateKeyEngine: ENGINE, + privateKeyIdentifier: ENGINE, +}; + +const encoder = new TextEncoder(); + +function isMaterial(value: unknown): value is TlsMaterial { + return typeof value === "string" || ArrayBuffer.isView(value) || value instanceof ArrayBuffer; +} + +function bytes(name: string, value: unknown): Uint8Array { + if (typeof value === "string") { + return encoder.encode(value); + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), + ); + } + throw invalidArgType(name, "string, Buffer, TypedArray, DataView, or ArrayBuffer", value); +} + +/** + * Reads a material option that Node accepts either as one blob or as an array of blobs. + * + * Object entries (`{ pem, passphrase }` for keys, `{ buf, passphrase }` for PFX bundles) carry + * a per-entry passphrase the WIT record has no field for, so they are refused rather than + * losing the passphrase. + */ +function materialList(api: string, name: string, value: unknown): Uint8Array[] { + if (Array.isArray(value)) { + return (value as unknown[]).map((entry, index) => { + if (typeof entry === "object" && entry !== null && !isMaterial(entry)) { + throw unsupported( + `${api} ${name}[${index}]`, + "only string, Buffer, TypedArray, DataView, and ArrayBuffer entries can cross the WIT boundary", + ); + } + return bytes(`${name}[${index}]`, entry); + }); + } + return [bytes(name, value)]; +} + +function string(name: string, value: unknown): string { + if (typeof value !== "string") { + throw invalidArgType(name, "string", value); + } + return value; +} + +function boolean(name: string, value: unknown): boolean { + if (typeof value !== "boolean") { + throw invalidArgType(name, "boolean", value); + } + return value; +} + +function uint32(name: string, value: unknown): number { + if (typeof value !== "number") { + throw invalidArgType(name, "number", value); + } + if (!Number.isInteger(value) || value < 0 || value > 0xff_ff_ff_ff) { + throw outOfRange(name, ">= 0 && <= 4294967295", value); + } + return value; +} + +function alpnProtocols(value: unknown): string[] { + if (Array.isArray(value)) { + return (value as unknown[]).map((entry, index) => string(`ALPNProtocols[${index}]`, entry)); + } + if (!isMaterial(value)) { + throw invalidArgType("ALPNProtocols", "Array, Buffer, TypedArray, or DataView", value); + } + // Node also accepts the wire encoding: length-prefixed protocol names. + const wire = bytes("ALPNProtocols", value); + const protocols: string[] = []; + const decoder = new TextDecoder(); + let offset = 0; + while (offset < wire.byteLength) { + const length = wire[offset]; + offset += 1; + if (length === 0 || offset + length > wire.byteLength) { + throw invalidArgType("ALPNProtocols", "a valid ALPN protocol list", value); + } + protocols.push(decoder.decode(wire.subarray(offset, offset + length))); + offset += length; + } + return protocols; +} + +/** + * Extracts the TLS material from `https.createServer` or `https.request` options. + * + * `api` labels refusals, e.g. `https.request option`. Returns `undefined` when no carried option + * is present at all so callers can tell "no TLS configuration" from an empty one. + */ +export function tlsMaterial(options: HttpTlsOptions, api: string): HttpTlsMaterial | undefined { + const bag = options as Record; + for (const [name, reason] of Object.entries(UNREPRESENTABLE_TLS_OPTIONS)) { + if (bag[name] !== undefined) { + unsupported(`${api} ${name}`, reason); + } + } + const material: HttpTlsMaterial = {}; + const read = ( + name: string, + key: K, + convert: (value: unknown) => HttpTlsMaterial[K], + ): void => { + if (bag[name] !== undefined) { + material[key] = convert(bag[name]); + } + }; + read("key", "key", (value) => materialList(api, "key", value)); + read("cert", "cert", (value) => materialList(api, "cert", value)); + read("pfx", "pfx", (value) => materialList(api, "pfx", value)); + read("passphrase", "passphrase", (value) => string("passphrase", value)); + read("ca", "ca", (value) => materialList(api, "ca", value)); + read("crl", "crl", (value) => materialList(api, "crl", value)); + read("dhparam", "dhparam", (value) => bytes("dhparam", value)); + read("ciphers", "ciphers", (value) => string("ciphers", value)); + read("ecdhCurve", "ecdhCurve", (value) => string("ecdhCurve", value)); + read("sigalgs", "sigalgs", (value) => string("sigalgs", value)); + read("minVersion", "minVersion", (value) => string("minVersion", value)); + read("maxVersion", "maxVersion", (value) => string("maxVersion", value)); + read("secureProtocol", "secureProtocol", (value) => string("secureProtocol", value)); + read("secureOptions", "secureOptions", (value) => uint32("secureOptions", value)); + read("sessionIdContext", "sessionIdContext", (value) => string("sessionIdContext", value)); + read("honorCipherOrder", "honorCipherOrder", (value) => boolean("honorCipherOrder", value)); + read("ALPNProtocols", "alpnProtocols", alpnProtocols); + read("servername", "servername", (value) => string("servername", value)); + read("rejectUnauthorized", "rejectUnauthorized", (value) => boolean("rejectUnauthorized", value)); + read("requestCert", "requestCert", (value) => boolean("requestCert", value)); + return Object.keys(material).length > 0 ? material : undefined; +} 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 5421ce94e..3fed1ee30 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 @@ -4,7 +4,13 @@ import type { HostErrno, HostErrorBase } from "../internal/wit-types.js"; export type HttpHeaderValue = string | number | readonly string[]; export type HttpHeaders = Record; -export interface HttpRequestOptions { +/** + * `http.request` options plus the TLS options `https.request` accepts. + * + * The TLS members are read only by the `node:https` profile; `node:http` ignores them the way + * Node's `net.connect` does. + */ +export interface HttpRequestOptions extends HttpTlsOptions { protocol?: string; host?: string | null; hostname?: string | null; @@ -32,6 +38,70 @@ export interface HttpRequestOptions { export interface AgentLike { readonly options: Readonly>; + readonly defaultPort?: number; +} + +/** PEM, DER, or PFX material as Node's TLS options accept it. */ +export type TlsMaterial = string | ArrayBufferView | ArrayBuffer; + +/** + * The subset of Node's `tls.createServer` / `tls.connect` options the typed WIT boundary + * carries. + * + * Follows nodejs/node v24.19.0, commit cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, + * lib/_tls_common.js `configSecureContext` and lib/_tls_wrap.js. Options with no serializable + * representation (`secureContext`, `SNICallback`, `ALPNCallback`, `checkServerIdentity`, + * engine identifiers, sessions, ticket keys) are refused by `tlsMaterial()` rather than + * silently dropped. + */ +export interface HttpTlsOptions { + key?: TlsMaterial | readonly TlsMaterial[]; + cert?: TlsMaterial | readonly TlsMaterial[]; + pfx?: TlsMaterial | readonly TlsMaterial[]; + passphrase?: string; + ca?: TlsMaterial | readonly TlsMaterial[]; + crl?: TlsMaterial | readonly TlsMaterial[]; + dhparam?: TlsMaterial; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + ALPNProtocols?: readonly string[] | TlsMaterial; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; +} + +/** + * Normalized TLS material handed to an implementation; mirrors the `tls-options` record of + * `jco:node/http@0.1.0` field for field. + */ +export interface HttpTlsMaterial { + key?: Uint8Array[]; + cert?: Uint8Array[]; + pfx?: Uint8Array[]; + passphrase?: string; + ca?: Uint8Array[]; + crl?: Uint8Array[]; + dhparam?: Uint8Array; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + alpnProtocols?: string[]; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; } export type HttpBodyChunk = string | ArrayBuffer | ArrayBufferView; @@ -51,6 +121,8 @@ export interface HttpImplementationRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + /** Client TLS configuration; only ever set by `node:https`, and only when options carry some. */ + tls?: HttpTlsMaterial; } export interface HttpImplementationResponse { @@ -94,6 +166,12 @@ export interface HttpServerOptions { highWaterMark?: number; insecureHTTPParser?: boolean; uniqueHeaders?: Array; + /** + * Present for every `node:https` server, even when no material was supplied, so that an + * implementation without a TLS stack refuses instead of serving plaintext. Absent for + * `node:http` servers. + */ + tls?: HttpTlsMaterial; [name: string]: unknown; } @@ -201,6 +279,7 @@ export interface DirectHttpRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + tls?: DirectTlsOptions; } export interface DirectHttpResponse { @@ -213,6 +292,9 @@ export interface DirectHttpResponse { export type DirectHttpResult = { tag: "ok"; val: T } | { tag: "err"; val: DirectHttpError }; +/** The `tls-options` record of `jco:node/http@0.1.0`. */ +export type DirectTlsOptions = HttpTlsMaterial; + export interface DirectHttpServerOptions { requestTimeout?: number; headersTimeout?: number; @@ -227,6 +309,7 @@ export interface DirectHttpServerOptions { keepAliveInitialDelay?: number; rejectNonStandardBodyWrites?: boolean; optimizeEmptyRequests?: boolean; + tls?: DirectTlsOptions; } export interface DirectHttpListenOptions { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts new file mode 100644 index 000000000..dd280eb34 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts @@ -0,0 +1,15 @@ +import * as host from "jco:node/http@0.1.0"; + +import { createDirectHttpImplementation } from "./http/impl/direct.js"; +import { createHttps } from "./https/core.js"; + +const https = createHttps(createDirectHttpImplementation(host)); + +export const Agent = https.Agent; +export const Server = https.Server; +export const createServer = https.createServer; +export const get = https.get; +export const globalAgent = https.globalAgent; +export const request = https.request; + +export default https; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts new file mode 100644 index 000000000..4aef994c6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts @@ -0,0 +1,259 @@ +/** + * Agent for the portable node:https shim. + * + * The operation mapping follows nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js (MIT license). The option defaults, + * the TLS session cache, and the full `getName()` field order are kept verbatim; socket + * pooling, TLS handshakes, and CONNECT proxy tunnelling are owned by the selected + * implementation and refuse rather than pretend. + */ + +import { Agent as HttpAgent, type AgentNameOptions, type AgentOptions } from "../http/agent.js"; +import { unsupported } from "../http/errors.js"; +import type { ProtocolProfile } from "../http/profile.js"; + +export interface HttpsAgentOptions extends AgentOptions { + maxCachedSessions?: number; + servername?: string; +} + +/** `getName()` reads TLS options too, so its argument is wider than the http one. */ +export interface HttpsAgentNameOptions extends AgentNameOptions { + ca?: unknown; + cert?: unknown; + clientCertEngine?: unknown; + ciphers?: unknown; + key?: unknown; + pfx?: unknown; + passphrase?: unknown; + rejectUnauthorized?: unknown; + servername?: unknown; + minVersion?: unknown; + maxVersion?: unknown; + secureProtocol?: unknown; + crl?: unknown; + honorCipherOrder?: unknown; + ecdhCurve?: unknown; + dhparam?: unknown; + secureOptions?: unknown; + sessionIdContext?: unknown; + sigalgs?: unknown; + privateKeyIdentifier?: unknown; + privateKeyEngine?: unknown; +} + +interface SessionCache { + map: Record; + list: string[]; +} + +interface PfxEntry { + buf?: unknown; + passphrase?: unknown; +} + +/** + * Builds the `pfx` field of an agent key. + * + * Ported from `getPfxAgentKey` in lib/https.js: a plain value contributes itself, while an + * array contributes `:buf:passphrase` per entry so distinct bundles get distinct keys. The + * falsy (`||`) fallbacks and the literal `undefined` for a missing passphrase are Node's own. + */ +function pfxAgentKey(pfx: unknown, passphrase: unknown): string { + if (!Array.isArray(pfx)) { + return String(pfx); + } + let key = ""; + for (const value of pfx as Array) { + const raw = value?.buf || value; + const pass = value?.passphrase || passphrase; + key += `:${String(raw)}:${String(pass)}`; + } + return key; +} + +export class Agent extends HttpAgent { + maxCachedSessions: number; + readonly _sessionCache: SessionCache; + + constructor(options: HttpsAgentOptions = {}) { + super({ + ...options, + defaultPort: options.defaultPort ?? 443, + protocol: options.protocol ?? "https:", + }); + // lib/https.js: only an absent option falls back to 100; any supplied value is kept as-is. + const configured = this.options.maxCachedSessions; + this.maxCachedSessions = configured === undefined ? 100 : (configured as number); + this._sessionCache = { map: {}, list: [] }; + } + + override createConnection(..._args: unknown[]): never { + return unsupported( + "https.Agent.createConnection", + "TLS handshakes and CONNECT tunnels are owned by the selected implementation", + ); + } + + /** + * Own prototype member in Node so a per-request `checkServerIdentity` socket is never + * pooled; the shim has no sockets, so it only preserves the shape and defers to the base. + */ + override keepSocketAlive(socket: unknown): boolean { + return super.keepSocketAlive(socket); + } + + /** + * Appends the 19 TLS fields to the http agent key. + * + * The order, the `!== undefined` guards on `rejectUnauthorized`, `honorCipherOrder`, and + * `secureOptions`, the `servername !== host` guard, and the `JSON.stringify` of `sigalgs` + * are all load-bearing: they are what makes two option bags share or split a socket pool. + */ + override getName(options: HttpsAgentNameOptions = {}): string { + let name = super.getName(options); + + name += ":"; + if (options.ca) { + name += options.ca; + } + + name += ":"; + if (options.cert) { + name += options.cert; + } + + name += ":"; + if (options.clientCertEngine) { + name += options.clientCertEngine; + } + + name += ":"; + if (options.ciphers) { + name += options.ciphers; + } + + name += ":"; + if (options.key) { + name += options.key; + } + + name += ":"; + if (options.pfx) { + name += pfxAgentKey(options.pfx, options.passphrase); + } + + name += ":"; + if (options.rejectUnauthorized !== undefined) { + name += options.rejectUnauthorized; + } + + name += ":"; + if (options.servername && options.servername !== options.host) { + name += options.servername; + } + + name += ":"; + if (options.minVersion) { + name += options.minVersion; + } + + name += ":"; + if (options.maxVersion) { + name += options.maxVersion; + } + + name += ":"; + if (options.secureProtocol) { + name += options.secureProtocol; + } + + name += ":"; + if (options.crl) { + name += options.crl; + } + + name += ":"; + if (options.honorCipherOrder !== undefined) { + name += options.honorCipherOrder; + } + + name += ":"; + if (options.ecdhCurve) { + name += options.ecdhCurve; + } + + name += ":"; + if (options.dhparam) { + name += options.dhparam; + } + + name += ":"; + if (options.secureOptions !== undefined) { + name += options.secureOptions; + } + + name += ":"; + if (options.sessionIdContext) { + name += options.sessionIdContext; + } + + name += ":"; + if (options.sigalgs) { + name += JSON.stringify(options.sigalgs); + } + + name += ":"; + if (options.privateKeyIdentifier) { + name += options.privateKeyIdentifier; + } + + name += ":"; + if (options.privateKeyEngine) { + name += options.privateKeyEngine; + } + + return name; + } + + _getSession(key: string): unknown { + return this._sessionCache.map[key]; + } + + _cacheSession(key: string, session: unknown): void { + if (this.maxCachedSessions === 0) { + return; + } + if (this._sessionCache.map[key]) { + this._sessionCache.map[key] = session; + return; + } + if (this._sessionCache.list.length >= this.maxCachedSessions) { + const oldKey = this._sessionCache.list.shift(); + if (oldKey !== undefined) { + delete this._sessionCache.map[oldKey]; + } + } + this._sessionCache.list.push(key); + this._sessionCache.map[key] = session; + } + + _evictSession(key: string): void { + const index = this._sessionCache.list.indexOf(key); + if (index === -1) { + return; + } + this._sessionCache.list.splice(index, 1); + delete this._sessionCache.map[key]; + } +} + +export const globalAgent = new Agent({ keepAlive: true, scheduling: "lifo", timeout: 5_000 }); + +export const HTTPS_PROFILE: ProtocolProfile = { + module: "https", + protocol: "https:", + scheme: "https", + defaultPort: 443, + globalAgent, +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts new file mode 100644 index 000000000..74ff55f5c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts @@ -0,0 +1,58 @@ +/** + * Module factory for the portable node:https shim. + * + * The export set follows nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js (MIT license): `lib/https.js` reuses + * `_http_client` and `_http_server` unchanged and varies only the protocol, the default port, + * and the default agent, so this module reuses the node:http core through a profile rather + * than forking it. + * + * Deliberately absent, matching the upstream module's own export list: nothing here is + * deprecated at the pinned release, and the CONNECT proxy tunnelling added in Node 24 + * (`getTunnelConfigForProxiedHttps`, `establishTunnel`, `ERR_PROXY_TUNNEL`) needs a raw + * socket, so a proxied agent refuses through `Agent.createConnection` rather than silently + * making a direct connection. + */ + +import type { ClientRequestBase, RequestInput, ResponseListener } from "../http/client-request.js"; +import { createProtocolModule, type ProtocolModule } from "../http/core.js"; +import type { + RequestListener, + ServerBase, + ServerConstructor, + ServerOptions, +} from "../http/server.js"; +import type { HttpImplementation, HttpRequestOptions } from "../http/types.js"; +import { Agent, globalAgent, HTTPS_PROFILE } from "./agent.js"; + +export interface NodeHttpsModule { + Agent: typeof Agent; + Server: ServerConstructor; + createServer: ( + optionsOrListener?: ServerOptions | RequestListener, + listener?: RequestListener, + ) => ServerBase; + get: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + globalAgent: Agent; + request: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; +} + +export function createHttps(implementation: HttpImplementation): NodeHttpsModule { + const protocol: ProtocolModule = createProtocolModule(implementation, HTTPS_PROFILE); + return { + Agent, + Server: protocol.Server, + createServer: protocol.createServer, + get: protocol.get, + globalAgent, + request: protocol.request, + }; +} 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 2d20a4fbe..7862c1350 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -79,6 +79,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -89,6 +91,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -103,6 +133,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { From 3e213119e8c00d55ed3426483af16ef77a60143a Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:34:33 +0000 Subject: [PATCH 05/68] test(std): add node:https unit tests Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- .../wasi/0.2.x/node/24.x.x/https/agent.ts | 226 ++++++++++++++++++ .../test/wasi/0.2.x/node/24.x.x/https/get.ts | 64 +++++ .../0.2.x/node/24.x.x/https/helpers/index.ts | 81 +++++++ .../test/wasi/0.2.x/node/24.x.x/https/host.ts | 125 ++++++++++ .../wasi/0.2.x/node/24.x.x/https/module.ts | 127 ++++++++++ .../wasi/0.2.x/node/24.x.x/https/request.ts | 224 +++++++++++++++++ .../wasi/0.2.x/node/24.x.x/https/server.ts | 122 ++++++++++ .../test/wasi/0.2.x/node/24.x.x/https/tls.ts | 191 +++++++++++++++ .../wasi/0.2.x/node/24.x.x/https/wasi-http.ts | 93 +++++++ .../0.2.x/node/24.x.x/https/wasi-sockets.ts | 57 +++++ 10 files changed, 1310 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts new file mode 100644 index 000000000..92a7bc3cb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts @@ -0,0 +1,226 @@ +import nodeHttps from "node:https"; + +import { describe, expect, test } from "vitest"; + +import { Agent as HttpAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { Agent, globalAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; + +type NameOptions = Parameters[0]; + +/** + * One case per `getName` field, plus the guards that decide whether a field contributes. + * The order of the fields is what makes two option bags share or split a socket pool, so a + * mismatch anywhere here is a real behavioural difference, not a cosmetic one. + */ +const NAME_CASES: Array<{ label: string; options?: NameOptions }> = [ + { label: "no arguments" }, + { label: "empty options", options: {} }, + { label: "host and port", options: { host: "example.com", port: 443 } }, + { label: "host only", options: { host: "example.com" } }, + { label: "local address", options: { host: "h", port: 443, localAddress: "1.2.3.4" } }, + { label: "family 4", options: { host: "h", port: 443, family: 4 } }, + { label: "family 6", options: { host: "h", port: 443, family: 6 } }, + { label: "socket path", options: { host: "h", port: 443, socketPath: "/tmp/s" } }, + { label: "family before the TLS fields", options: { host: "h", port: 443, family: 4, ca: "C" } }, + { + label: "socket path before the TLS fields", + options: { host: "h", port: 443, socketPath: "/tmp/s", ca: "C" }, + }, + { label: "ca", options: { host: "h", port: 443, ca: "CA" } }, + { label: "cert", options: { host: "h", port: 443, cert: "CERT" } }, + { label: "clientCertEngine", options: { host: "h", port: 443, clientCertEngine: "ENGINE" } }, + { label: "ciphers", options: { host: "h", port: 443, ciphers: "AES" } }, + { label: "key", options: { host: "h", port: 443, key: "KEY" } }, + { label: "pfx string", options: { host: "h", port: 443, pfx: "PFX" } }, + { + label: "pfx array with passphrases", + options: { + host: "h", + port: 443, + pfx: [{ buf: "b1", passphrase: "p1" }, { buf: "b2" }], + passphrase: "outer", + } as NameOptions, + }, + { label: "pfx array of plain values", options: { host: "h", port: 443, pfx: ["a", "b"] } }, + { + label: "pfx array without any passphrase", + options: { host: "h", port: 443, pfx: [{ buf: "b1" }, "b2"] } as NameOptions, + }, + { + label: "pfx array with empty buf and passphrase strings", + options: { + host: "h", + port: 443, + pfx: [{ buf: "", passphrase: "" }], + passphrase: "outer", + } as NameOptions, + }, + { + label: "pfx array holding null", + options: { host: "h", port: 443, pfx: [null] } as NameOptions, + }, + { + label: "rejectUnauthorized false", + options: { host: "h", port: 443, rejectUnauthorized: false }, + }, + { label: "rejectUnauthorized true", options: { host: "h", port: 443, rejectUnauthorized: true } }, + { label: "servername equal to host", options: { host: "h", port: 443, servername: "h" } }, + { + label: "servername different from host", + options: { host: "h", port: 443, servername: "other" }, + }, + { label: "minVersion", options: { host: "h", port: 443, minVersion: "TLSv1.2" } }, + { label: "maxVersion", options: { host: "h", port: 443, maxVersion: "TLSv1.3" } }, + { label: "secureProtocol", options: { host: "h", port: 443, secureProtocol: "TLS_method" } }, + { label: "crl", options: { host: "h", port: 443, crl: "CRL" } }, + { label: "honorCipherOrder false", options: { host: "h", port: 443, honorCipherOrder: false } }, + { label: "ecdhCurve", options: { host: "h", port: 443, ecdhCurve: "auto" } }, + { label: "dhparam", options: { host: "h", port: 443, dhparam: "DH" } }, + { label: "secureOptions zero", options: { host: "h", port: 443, secureOptions: 0 } }, + { label: "sessionIdContext", options: { host: "h", port: 443, sessionIdContext: "ctx" } }, + { label: "sigalgs string", options: { host: "h", port: 443, sigalgs: "ecdsa" } }, + { label: "sigalgs object", options: { host: "h", port: 443, sigalgs: { a: 1 } } }, + { label: "privateKeyIdentifier", options: { host: "h", port: 443, privateKeyIdentifier: "id" } }, + { label: "privateKeyEngine", options: { host: "h", port: 443, privateKeyEngine: "eng" } }, + { + label: "every field at once", + options: { + host: "h", + port: 8443, + localAddress: "1.2.3.4", + family: 6, + ca: "CA", + cert: "CERT", + clientCertEngine: "ENGINE", + ciphers: "AES", + key: "KEY", + pfx: "PFX", + rejectUnauthorized: false, + servername: "other", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + crl: "CRL", + honorCipherOrder: true, + ecdhCurve: "auto", + dhparam: "DH", + secureOptions: 1, + sessionIdContext: "ctx", + sigalgs: ["a"], + privateKeyIdentifier: "id", + privateKeyEngine: "eng", + }, + }, +]; + +describe("https.Agent", () => { + test.concurrent("keeps Node's option defaults", () => { + const agent = new Agent(); + expect(agent.defaultPort).toBe(443); + expect(agent.protocol).toBe("https:"); + expect(agent.maxCachedSessions).toBe(100); + expect(agent.keepAlive).toBe(false); + }); + + test.concurrent("honours explicit defaultPort, protocol, and maxCachedSessions", () => { + const agent = new Agent({ defaultPort: 8443, protocol: "http:", maxCachedSessions: 0 }); + expect(agent.defaultPort).toBe(8443); + expect(agent.protocol).toBe("http:"); + expect(agent.maxCachedSessions).toBe(0); + }); + + test.concurrent("keeps the global agent's documented options", () => { + expect(globalAgent.defaultPort).toBe(443); + expect(globalAgent.protocol).toBe("https:"); + expect(globalAgent.keepAlive).toBe(true); + expect(globalAgent.scheduling).toBe("lifo"); + }); + + test.concurrent("refuses to own TLS connections", () => { + expect(() => new Agent().createConnection()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Agent.createConnection"), + }), + ); + }); + + test.concurrent("stores and evicts TLS sessions", () => { + const agent = new Agent({ maxCachedSessions: 2 }); + agent._cacheSession("a", 1); + agent._cacheSession("b", 2); + expect(agent._getSession("a")).toBe(1); + agent._cacheSession("c", 3); + expect(agent._getSession("a")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual(["b", "c"]); + agent._cacheSession("b", 20); + expect(agent._getSession("b")).toBe(20); + expect(agent._sessionCache.list).toEqual(["b", "c"]); + agent._evictSession("b"); + expect(agent._getSession("b")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual(["c"]); + agent._evictSession("missing"); + expect(agent._sessionCache.list).toEqual(["c"]); + }); + + test.concurrent("caches nothing when the cache is disabled", () => { + const agent = new Agent({ maxCachedSessions: 0 }); + agent._cacheSession("a", 1); + expect(agent._getSession("a")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual([]); + }); + + test.concurrent("produces a 23-field key for a plain host and port", () => { + expect(new Agent().getName({ host: "h", port: 443 }).split(":")).toHaveLength(23); + }); + + test.concurrent("keeps a supplied maxCachedSessions value as-is", () => { + // Only an absent option falls back to 100 in lib/https.js. + expect(new Agent({ maxCachedSessions: null as never }).maxCachedSessions).toBeNull(); + expect(new Agent({ maxCachedSessions: 7 }).maxCachedSessions).toBe(7); + }); +}); + +describeDifferential("https.Agent differential", () => { + for (const { label, options } of NAME_CASES) { + test.concurrent(`getName matches Node for ${label}`, () => { + expect(new Agent().getName(options)).toBe( + new nodeHttps.Agent().getName(options as Parameters[0]), + ); + }); + } + + test.concurrent("declares the same own prototype members as Node", () => { + expect(Object.getOwnPropertyNames(Agent.prototype).sort()).toEqual( + Object.getOwnPropertyNames(nodeHttps.Agent.prototype).sort(), + ); + expect(Object.getPrototypeOf(Agent)).toBe(HttpAgent); + }); + + test.concurrent("matches Node's option defaults", () => { + for (const options of [ + undefined, + {}, + { maxCachedSessions: 5 }, + { maxCachedSessions: null as never }, + { defaultPort: 8443 }, + { protocol: "http:" }, + { keepAlive: true, scheduling: "lifo" as const }, + ]) { + const portable = new Agent(options); + const native = new nodeHttps.Agent(options); + expect(portable.defaultPort).toBe(native.defaultPort); + expect(portable.protocol).toBe(native.protocol); + expect(portable.maxCachedSessions).toBe(native.maxCachedSessions); + expect({ ...portable.options }).toEqual({ ...native.options }); + } + }); + + test.concurrent("matches Node's global agent defaults", () => { + expect(globalAgent.defaultPort).toBe(nodeHttps.globalAgent.defaultPort); + expect(globalAgent.protocol).toBe(nodeHttps.globalAgent.protocol); + expect(globalAgent.maxCachedSessions).toBe(nodeHttps.globalAgent.maxCachedSessions); + expect({ ...globalAgent.options }).toEqual({ ...nodeHttps.globalAgent.options }); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts new file mode 100644 index 000000000..0260ca600 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; + +import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/incoming-message.js"; +import { nextTurn, recordingImplementation, response } from "./helpers/index.js"; + +describe("node:https get", () => { + test.concurrent("ends the request itself and returns the same request shape", () => { + const { https, requests } = recordingImplementation(); + const request = https.get("https://example.com/"); + expect(request).toBeInstanceOf(https.request("https://example.com/").constructor); + expect(request.writableEnded).toBe(true); + expect(requests[0]).toMatchObject({ method: "GET", scheme: "https", authority: "example.com" }); + expect(requests[0].body.byteLength).toBe(0); + }); + + test.concurrent("delivers response, data, and end asynchronously", async () => { + const { https } = recordingImplementation(response("secure body")); + const events: string[] = []; + const message = new Promise((resolve) => { + https.get("https://example.com/", (incoming) => { + events.push("callback"); + resolve(incoming); + }); + }); + // The implementation returned synchronously, but nothing is observable until a later turn. + expect(events).toEqual([]); + const incoming = await message; + incoming.setEncoding("utf8"); + const chunks: string[] = []; + incoming.on("data", (chunk: string) => chunks.push(chunk)); + await new Promise((resolve) => incoming.once("end", resolve)); + expect(events).toEqual(["callback"]); + expect(chunks.join("")).toBe("secure body"); + expect(incoming.statusCode).toBe(200); + expect(incoming.headers["content-type"]).toBe("text/plain"); + }); + + test.concurrent("accepts options and a callback after a URL", async () => { + const { https, requests } = recordingImplementation(); + const message = new Promise((resolve) => { + https.get( + "https://example.com/base", + { path: "/override", headers: { "X-A": "1" } }, + resolve, + ); + }); + await message; + expect(requests[0].pathWithQuery).toBe("/override"); + expect(requests[0].headers.some(({ name }) => name.toLowerCase() === "x-a")).toBe(true); + }); + + test.concurrent("reports implementation failures on the request", async () => { + const https = recordingImplementation().https; + const failing = recordingImplementation(); + failing.implementation.request = () => { + throw Object.assign(new Error("boom"), { code: "ECONNREFUSED" }); + }; + void https; + const request = failing.https.get("https://example.com/"); + const error = await new Promise((resolve) => request.once("error", resolve)); + expect(error).toMatchObject({ code: "ECONNREFUSED" }); + await nextTurn(); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts new file mode 100644 index 000000000..3e5578091 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts @@ -0,0 +1,81 @@ +import { createHttps } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; +import type { + HttpImplementation, + HttpImplementationRequest, + HttpImplementationResponse, + HttpListenOptions, + HttpRequestHandler, + HttpServerImplementation, + HttpServerOptions, +} from "../../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; + +const encoder = new TextEncoder(); + +export function response(body = "response body"): HttpImplementationResponse { + return { + statusCode: 200, + statusMessage: "OK", + httpVersion: "1.1", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode(body), + }; +} + +export function recordingImplementation(result = response()): { + https: ReturnType; + requests: HttpImplementationRequest[]; + implementation: HttpImplementation; +} { + const requests: HttpImplementationRequest[] = []; + const implementation: HttpImplementation = { + request(request) { + requests.push(request); + return result; + }, + }; + return { https: createHttps(implementation), requests, implementation }; +} + +export function servingImplementation(): { + https: ReturnType; + implementation: HttpImplementation; + options: HttpServerOptions[]; + request: (data: Parameters[0]) => ReturnType; +} { + const options: HttpServerOptions[] = []; + let handler: HttpRequestHandler | undefined; + const backend: HttpServerImplementation = { + listen: (_listenOptions: HttpListenOptions) => ({ + address: "127.0.0.1", + family: "IPv4" as const, + port: 8443, + }), + close: () => true, + closeAllConnections: () => undefined, + closeIdleConnections: () => undefined, + getConnections: () => 0, + address: () => ({ address: "127.0.0.1", family: "IPv4", port: 8443 }), + ref: () => undefined, + unref: () => undefined, + }; + const implementation: HttpImplementation = { + request: () => { + throw new Error("not used"); + }, + createServer(serverOptions, requestHandler) { + options.push(serverOptions); + handler = requestHandler; + return backend; + }, + }; + return { + https: createHttps(implementation), + implementation, + options, + request: (data) => handler!(data), + }; +} + +export function nextTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts new file mode 100644 index 000000000..3eaa3ed28 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts @@ -0,0 +1,125 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { Server, request } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; +import type { + DirectHttpRequestListener, + DirectHttpServer, + DirectHttpServerOptions, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const FIXTURES = new URL("../../../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = new Uint8Array(readFileSync(new URL("localhost.crt", FIXTURES))); +const key = new Uint8Array(readFileSync(new URL("localhost.key", FIXTURES))); + +const servers = new Set(); + +afterEach(async () => { + await Promise.all([...servers].map((server) => server.close())); + servers.clear(); +}); + +const echo: DirectHttpRequestListener = { + handle: async (incoming) => ({ + tag: "ok", + val: { + statusCode: 200, + statusMessage: "OK", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode(`${incoming.method} ${incoming.url} ${decoder.decode(incoming.body)}`), + }, + }), + [Symbol.dispose]: () => undefined, +}; + +async function listen( + options: DirectHttpServerOptions, +): Promise<{ server: DirectHttpServer; port: number }> { + const server = new Server(options, echo); + servers.add(server); + const started = await server.listen({ port: 0, host: "127.0.0.1" }); + if (started.tag === "err" || started.val.tag !== "tcp") { + throw new Error(`expected a TCP listener, got ${JSON.stringify(started)}`); + } + return { server, port: started.val.val.port }; +} + +describe("node:https direct Node host", () => { + test("terminates TLS for a server carrying a tls record", async () => { + const { port } = await listen({ tls: { key: [key], cert: [cert] } }); + const result = await request({ + method: "POST", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/secure", + headers: [ + { name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }, + { name: "Content-Length", value: encoder.encode("5") }, + ], + body: encoder.encode("hello"), + // The fixture certificate names `localhost`, so SNI carries that name while the + // connection itself goes to the loopback address. + tls: { ca: [cert], servername: "localhost" }, + }); + expect(result.tag).toBe("ok"); + if (result.tag === "ok") { + expect(result.val.statusCode).toBe(200); + expect(decoder.decode(result.val.body)).toBe("POST /secure hello"); + } + }); + + test("verifies the server certificate unless told not to", async () => { + const { port } = await listen({ tls: { key: [key], cert: [cert] } }); + const base = { + method: "GET", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + }; + const untrusted = await request({ ...base, tls: { servername: "localhost" } }); + expect(untrusted.tag).toBe("err"); + if (untrusted.tag === "err") { + expect(untrusted.val.code).toMatch(/SELF_SIGNED|DEPTH_ZERO/); + } + const unverified = await request({ + ...base, + tls: { servername: "localhost", rejectUnauthorized: false }, + }); + expect(unverified).toMatchObject({ tag: "ok", val: { statusCode: 200 } }); + }); + + test("builds an https server from an empty tls record and fails the handshake like Node", async () => { + const { port } = await listen({ tls: {} }); + const result = await request({ + method: "GET", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + tls: { rejectUnauthorized: false }, + }); + expect(result.tag).toBe("err"); + }); + + test("keeps serving plaintext when no tls record is present", async () => { + const { port } = await listen({}); + const result = await request({ + method: "GET", + scheme: "http", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/plain", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + }); + expect(result).toMatchObject({ tag: "ok", val: { statusCode: 200 } }); + if (result.tag === "ok") { + expect(decoder.decode(result.val.body)).toBe("GET /plain "); + } + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts new file mode 100644 index 000000000..692e8eb8b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts @@ -0,0 +1,127 @@ +import nodeHttp from "node:http"; +import nodeHttps from "node:https"; +import * as nodeHttpsNamespace from "node:https"; + +import { describe, expect, test } from "vitest"; + +import { Agent as HttpAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; +import { createDirectHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/direct.js"; +import * as denyHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; +import { recordingImplementation } from "./helpers/index.js"; + +describe("node:https module", () => { + test.concurrent("exposes the Node 24 module surface", () => { + const { https } = recordingImplementation(); + expect(Object.keys(https).sort()).toEqual(Object.keys(nodeHttps).sort()); + }); + + test.concurrent("matches Node's default-versus-namespace split", () => { + // node:https has no `default` key of its own; the namespace adds one, so the two objects + // are never the same value and a `default` import sees only the six real exports. + expect(nodeHttpsNamespace.default).not.toBe(nodeHttpsNamespace); + expect(Object.keys(nodeHttpsNamespace).sort()).toEqual( + [...Object.keys(nodeHttps), "default"].sort(), + ); + }); + + test.concurrent("exposes a strictly smaller surface than node:http", () => { + const { https } = recordingImplementation(); + const { http } = { http: createHttp({ request: () => response() }) }; + expect(Object.keys(https).every((name) => name in http)).toBe(true); + expect(Object.keys(http).length).toBeGreaterThan(Object.keys(https).length); + function response(): never { + throw new Error("not used"); + } + }); + + test.concurrent("subclasses the node:http agent on both chains", () => { + const { https } = recordingImplementation(); + expect(Object.getPrototypeOf(https.Agent)).toBe(HttpAgent); + expect(Object.getPrototypeOf(https.Agent.prototype)).toBe(HttpAgent.prototype); + expect(new https.Agent()).toBeInstanceOf(HttpAgent); + }); + + test.concurrent("gives each protocol its own classes and global agent", () => { + const { https } = recordingImplementation(); + const http = createHttp({ + request: () => { + throw new Error("not used"); + }, + }); + expect(https.Agent).not.toBe(http.Agent); + expect(https.globalAgent).not.toBe(http.globalAgent); + expect(https.Server).not.toBe(http.Server); + expect(https.request).not.toBe(http.request); + }); + + test.concurrent("keeps one Agent class across module instances", () => { + expect(recordingImplementation().https.Agent).toBe(recordingImplementation().https.Agent); + expect(recordingImplementation().https.globalAgent).toBe( + recordingImplementation().https.globalAgent, + ); + }); + + test.concurrent("denies the direct capability by default", async () => { + const https = createHttps(createDirectHttpImplementation(denyHost)); + expect(() => https.createServer()).toThrow( + expect.objectContaining({ code: "ERR_JCO_HTTP_ADAPTER_REQUIRED" }), + ); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ code: "ERR_JCO_HTTP_ADAPTER_REQUIRED" }); + }); + + test.concurrent("rejects server construction when an implementation cannot listen", () => { + const { https } = recordingImplementation(); + expect(() => https.createServer(() => undefined)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server"), + }), + ); + }); + + test.concurrent("matches Node's callable shapes", () => { + const { https } = recordingImplementation(); + for (const name of ["createServer", "get", "request"] as const) { + expect(typeof https[name]).toBe("function"); + expect(typeof nodeHttps[name]).toBe("function"); + } + for (const name of ["Agent", "Server"] as const) { + expect(typeof https[name]).toBe("function"); + expect(https[name].prototype).toBeTypeOf("object"); + } + }); + + test.concurrent("omits the node:http-only exports Node also omits", () => { + const { https } = recordingImplementation(); + for (const name of [ + "METHODS", + "STATUS_CODES", + "maxHeaderSize", + "IncomingMessage", + "OutgoingMessage", + "ServerResponse", + "ClientRequest", + "validateHeaderName", + "validateHeaderValue", + "setMaxIdleHTTPParsers", + "setGlobalProxyFromEnv", + "_connectionListener", + "WebSocket", + ]) { + expect(name in https).toBe(false); + expect(name in nodeHttps).toBe(false); + expect(name in nodeHttp).toBe(true); + } + }); + + test.concurrent("does not touch globals on import", () => { + const before = Object.keys(globalThis).length; + recordingImplementation(); + expect(Object.keys(globalThis).length).toBe(before); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts new file mode 100644 index 000000000..76ea1e39c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts @@ -0,0 +1,224 @@ +import nodeHttps from "node:https"; + +import { describe, expect, test } from "vitest"; + +import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/incoming-message.js"; +import { Agent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; +import { recordingImplementation } from "./helpers/index.js"; + +const decoder = new TextDecoder(); + +function header( + request: { headers: Array<{ name: string; value: Uint8Array }> }, + name: string, +): string | undefined { + const field = request.headers.find((entry) => entry.name.toLowerCase() === name); + return field === undefined ? undefined : decoder.decode(field.value); +} + +describe("node:https request", () => { + test.concurrent("sends the https scheme to the implementation", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com/a?b=1").end(); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + method: "GET", + scheme: "https", + authority: "example.com", + pathWithQuery: "/a?b=1", + }); + }); + + test.concurrent("elides the default port 443 from the authority and Host header", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com:443/").end(); + https.request("https://example.com:8443/").end(); + expect(requests[0].authority).toBe("example.com"); + expect(header(requests[0], "host")).toBe("example.com"); + expect(requests[1].authority).toBe("example.com:8443"); + expect(header(requests[1], "host")).toBe("example.com:8443"); + }); + + test.concurrent("keeps port 80 in the authority, unlike node:http", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", port: 80 }).end(); + expect(requests[0].authority).toBe("example.com:80"); + }); + + test.concurrent("defaults to port 443 when the options carry none", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com" }).end(); + expect(requests[0].authority).toBe("example.com"); + expect(header(requests[0], "host")).toBe("example.com"); + }); + + test.concurrent("honours an explicit defaultPort for elision", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", defaultPort: 8443 }).end(); + expect(requests[0].authority).toBe("example.com"); + }); + + test.concurrent("takes the default port from an explicitly supplied agent", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", agent: new Agent({ defaultPort: 8443 }) }).end(); + expect(requests[0].authority).toBe("example.com"); + }); + + test.concurrent("defaults the agent to the https global agent", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(request.agent).toBe(https.globalAgent); + expect(request.agent?.protocol).toBe("https:"); + request.end(); + }); + + test.concurrent("gives agent: false a fresh https agent", () => { + const { https } = recordingImplementation(); + const request = https.request({ host: "example.com", agent: false }); + expect(request.agent).toBeInstanceOf(https.Agent); + expect(request.agent).not.toBe(https.globalAgent); + expect(request.agent.protocol).toBe("https:"); + expect(request.agent.keepAlive).toBe(false); + request.end(); + }); + + test.concurrent("reports the https protocol on the request", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(request.protocol).toBe("https:"); + request.end(); + }); + + test.concurrent("carries basic auth from the URL", () => { + const { https, requests } = recordingImplementation(); + https.request("https://user:pass@example.com/").end(); + expect(header(requests[0], "authorization")).toBe(`Basic ${btoa("user:pass")}`); + }); + + test.concurrent("buffers a request body and frames it", async () => { + const { https, requests } = recordingImplementation(); + const response = new Promise((resolve) => { + const request = https.request("https://example.com/", { method: "post" }, resolve); + request.write("hello "); + request.end("world"); + }); + const message = await response; + expect(decoder.decode(requests[0].body)).toBe("hello world"); + expect(header(requests[0], "content-length")).toBe("11"); + expect(requests[0].method).toBe("POST"); + expect(message.statusCode).toBe(200); + }); + + test.concurrent("carries client TLS options to the implementation", () => { + const { https, requests } = recordingImplementation(); + https + .request({ + host: "example.com", + ca: ["A", "B"], + cert: "C", + key: "K", + rejectUnauthorized: false, + servername: "sni.example.com", + minVersion: "TLSv1.3", + ALPNProtocols: ["http/1.1"], + }) + .end(); + const tls = requests[0].tls!; + expect(tls.ca!.map((entry) => decoder.decode(entry))).toEqual(["A", "B"]); + expect(tls.cert!.map((entry) => decoder.decode(entry))).toEqual(["C"]); + expect(tls.key!.map((entry) => decoder.decode(entry))).toEqual(["K"]); + expect(tls).toMatchObject({ + rejectUnauthorized: false, + servername: "sni.example.com", + minVersion: "TLSv1.3", + alpnProtocols: ["http/1.1"], + }); + }); + + test.concurrent("omits the TLS record when no TLS option is given", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com/").end(); + https.request({ host: "example.com", timeout: 5, headers: { a: "b" } }).end(); + expect("tls" in requests[0]).toBe(false); + expect("tls" in requests[1]).toBe(false); + }); + + test.concurrent("refuses unrepresentable client TLS options before sending anything", () => { + const { https, requests } = recordingImplementation(); + for (const [name, value] of [ + ["checkServerIdentity", () => undefined], + ["secureContext", {}], + ["session", new Uint8Array(8)], + ["pskCallback", () => undefined], + ] as const) { + expect(() => https.request({ host: "example.com", [name]: value })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`https.request option ${name}`), + }), + ); + } + expect(requests).toHaveLength(0); + }); + + test.concurrent("labels its refusals as https", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(() => request.setNoDelay()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.ClientRequest.setNoDelay"), + }), + ); + expect(() => request.abort()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + message: expect.stringContaining("https.ClientRequest.abort"), + }), + ); + request.end(); + }); +}); + +describeDifferential("node:https request differential", () => { + for (const protocol of ["http:", "ftp:", "wss:"]) { + test.concurrent(`rejects the ${protocol} protocol the way Node does`, () => { + const { https } = recordingImplementation(); + const options = { host: "example.com", protocol }; + let native: unknown; + try { + nodeHttps.request(options).destroy(); + } catch (error) { + native = error; + } + expect(native).toMatchObject({ + code: "ERR_INVALID_PROTOCOL", + message: `Protocol "${protocol}" not supported. Expected "https:"`, + }); + expect(() => https.request(options)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_PROTOCOL", + message: (native as Error).message, + }), + ); + }); + } + + test.concurrent("rejects an http URL the way Node does", () => { + const { https } = recordingImplementation(); + expect(() => https.request("http://example.com/")).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_PROTOCOL", + message: 'Protocol "http:" not supported. Expected "https:"', + }), + ); + }); + + test.concurrent("rejects unescaped characters in the path", () => { + const { https } = recordingImplementation(); + expect(() => https.request({ host: "example.com", path: "/a b" })).toThrow( + expect.objectContaining({ code: "ERR_UNESCAPED_CHARACTERS" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts new file mode 100644 index 000000000..765b9dc1c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, test } from "vitest"; + +import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; +import { servingImplementation } from "./helpers/index.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const FIXTURES = new URL("../../../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = readFileSync(new URL("localhost.crt", FIXTURES)); +const key = readFileSync(new URL("localhost.key", FIXTURES)); + +describe("node:https Server", () => { + test("hands normalized TLS material to the implementation", () => { + const { https, options } = servingImplementation(); + https.createServer({ key, cert, passphrase: "pw", ALPNProtocols: ["http/1.1"] }); + expect(options).toHaveLength(1); + expect(options[0].tls).toEqual({ + key: [new Uint8Array(key)], + cert: [new Uint8Array(cert)], + passphrase: "pw", + alpnProtocols: ["http/1.1"], + }); + // The HTTP-level half of the bag still reaches the implementation untouched. + expect(options[0].passphrase).toBe("pw"); + }); + + test("keeps key, cert, and ca arrays as lists", () => { + const { https, options } = servingImplementation(); + https.createServer({ key: [key, "second"], cert: [cert], ca: ["a", "b"] }); + expect(options[0].tls!.key!.map((entry) => decoder.decode(entry))).toEqual([ + key.toString(), + "second", + ]); + expect(options[0].tls!.ca!.map((entry) => decoder.decode(entry))).toEqual(["a", "b"]); + }); + + test("always carries a TLS record, even when no TLS option was supplied", () => { + // Node constructs an https.Server without a certificate and fails each handshake; the + // record's presence is what tells an implementation without a TLS stack to refuse. + const { https, options } = servingImplementation(); + https.createServer(); + https.createServer(() => undefined); + https.createServer({ requestTimeout: 1_000 }); + expect(options.map(({ tls }) => tls)).toEqual([{}, {}, {}]); + expect(options[2].requestTimeout).toBe(1_000); + }); + + test("passes no TLS record for a node:http server, whatever the bag contains", () => { + const { implementation, options } = servingImplementation(); + createHttp(implementation).createServer({ key, cert }); + expect(options[0].tls).toBeUndefined(); + expect(options[0].key).toBe(key); + }); + + test("refuses unrepresentable TLS options by name before creating the server", () => { + const { https, options } = servingImplementation(); + for (const [name, value] of [ + ["SNICallback", () => undefined], + ["ALPNCallback", () => undefined], + ["secureContext", {}], + ["ticketKeys", new Uint8Array(48)], + ] as const) { + expect(() => https.createServer({ key, cert, [name]: value })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`https.createServer option ${name}`), + }), + ); + } + expect(options).toHaveLength(0); + }); + + test("labels HTTP-level refusals as https", () => { + const { https } = servingImplementation(); + expect(() => https.createServer({ insecureHTTPParser: true })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server option insecureHTTPParser"), + }), + ); + const server = https.createServer({ key, cert }); + expect(() => server.listen({ port: 0, signal: new AbortController().signal })).toThrow( + expect.objectContaining({ message: expect.stringContaining("https.Server.listen signal") }), + ); + }); + + test("rejects a non-object options argument the way Node does", () => { + const { https } = servingImplementation(); + expect(() => https.createServer("8443" as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); + + test("dispatches inbound requests through the implementation", async () => { + const { https, request } = servingImplementation(); + const server = https.createServer({ key, cert }, async (incoming, outgoing) => { + let body = ""; + incoming.setEncoding("utf8"); + for await (const chunk of incoming) { + body += chunk; + } + outgoing.writeHead(201, "Created", { "X-Method": incoming.method! }); + outgoing.end(`${incoming.url}:${body}`); + }); + server.listen(8443, "127.0.0.1"); + await Promise.resolve(); + expect(server.listening).toBe(true); + expect(server.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 8443 }); + const response = await request({ + method: "POST", + url: "/items", + httpVersion: "1.1", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode("hello"), + }); + expect(response).toMatchObject({ statusCode: 201, statusMessage: "Created" }); + expect(decoder.decode(response.body)).toBe("/items:hello"); + server.close(); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts new file mode 100644 index 000000000..b0de3ffdf --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "vitest"; + +import { tlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/tls.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const API = "https.createServer option"; + +function text(value: Uint8Array | undefined): string | undefined { + return value === undefined ? undefined : decoder.decode(value); +} + +describe("node:https TLS option normalization", () => { + test.concurrent("returns undefined when no carried option is present", () => { + expect(tlsMaterial({}, API)).toBeUndefined(); + expect(tlsMaterial({ requestTimeout: 5 } as never, API)).toBeUndefined(); + }); + + test.concurrent("encodes string material as UTF-8 and keeps every field a list", () => { + const material = tlsMaterial({ key: "K", cert: "C", pfx: "P", ca: "A", crl: "R" }, API)!; + expect(material.key!.map(text)).toEqual(["K"]); + expect(material.cert!.map(text)).toEqual(["C"]); + expect(material.pfx!.map(text)).toEqual(["P"]); + expect(material.ca!.map(text)).toEqual(["A"]); + expect(material.crl!.map(text)).toEqual(["R"]); + }); + + test.concurrent("preserves arrays entry by entry instead of joining them", () => { + // OpenSSL reads only the first key out of a concatenated PEM, so a joined bundle would + // silently lose the second key. + const material = tlsMaterial({ key: ["rsa", "ecdsa"], cert: ["leaf", "chain"] }, API)!; + expect(material.key!.map(text)).toEqual(["rsa", "ecdsa"]); + expect(material.cert!.map(text)).toEqual(["leaf", "chain"]); + }); + + test.concurrent("copies binary material out of its source buffer", () => { + const source = new Uint8Array([1, 2, 3, 4]); + const view = new DataView(source.buffer, 1, 2); + const material = tlsMaterial( + { key: source, cert: view, pfx: source.buffer, dhparam: source.subarray(2) }, + API, + )!; + expect([...material.key![0]]).toEqual([1, 2, 3, 4]); + expect([...material.cert![0]]).toEqual([2, 3]); + expect([...material.pfx![0]]).toEqual([1, 2, 3, 4]); + expect([...material.dhparam!]).toEqual([3, 4]); + source.fill(0); + expect([...material.key![0]]).toEqual([1, 2, 3, 4]); + }); + + test.concurrent("carries every scalar the record has a field for", () => { + expect( + tlsMaterial( + { + passphrase: "pw", + ciphers: "AES", + ecdhCurve: "auto", + sigalgs: "ecdsa_secp256r1_sha256", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + secureOptions: 4, + sessionIdContext: "ctx", + honorCipherOrder: true, + servername: "example.com", + rejectUnauthorized: false, + requestCert: true, + }, + API, + ), + ).toEqual({ + passphrase: "pw", + ciphers: "AES", + ecdhCurve: "auto", + sigalgs: "ecdsa_secp256r1_sha256", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + secureOptions: 4, + sessionIdContext: "ctx", + honorCipherOrder: true, + servername: "example.com", + rejectUnauthorized: false, + requestCert: true, + }); + }); + + test.concurrent("accepts ALPN protocols as an array or in wire form", () => { + expect(tlsMaterial({ ALPNProtocols: ["h2", "http/1.1"] }, API)).toEqual({ + alpnProtocols: ["h2", "http/1.1"], + }); + const wire = new Uint8Array([2, ...encoder.encode("h2"), 8, ...encoder.encode("http/1.1")]); + expect(tlsMaterial({ ALPNProtocols: wire }, API)).toEqual({ + alpnProtocols: ["h2", "http/1.1"], + }); + }); + + test.concurrent("rejects malformed ALPN input", () => { + for (const value of [ + new Uint8Array([0]), + new Uint8Array([5, 104]), + [1], + "h2", + 42, + ] as unknown[]) { + expect(() => tlsMaterial({ ALPNProtocols: value as never }, API)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + }); + + test.concurrent("validates scalar types the way Node's validators do", () => { + for (const options of [ + { passphrase: 1 }, + { ciphers: ["AES"] }, + { minVersion: 1.2 }, + { honorCipherOrder: "yes" }, + { rejectUnauthorized: 0 }, + { requestCert: "true" }, + { secureOptions: "4" }, + { key: 42 }, + { ca: [null] }, + { dhparam: {} }, + ]) { + expect(() => tlsMaterial(options as never, API)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + for (const secureOptions of [-1, 1.5, 2 ** 32]) { + expect(() => tlsMaterial({ secureOptions }, API)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + } + }); + + test.concurrent("refuses per-entry passphrases rather than dropping them", () => { + expect(() => tlsMaterial({ key: ["a", { pem: "b", passphrase: "p" }] } as never, API)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`${API} key[1]`), + }), + ); + expect(() => tlsMaterial({ pfx: [{ buf: "b", passphrase: "p" }] } as never, API)).toThrow( + expect.objectContaining({ message: expect.stringContaining(`${API} pfx[0]`) }), + ); + }); + + test.concurrent("refuses every option that cannot cross the boundary, by name", () => { + const refused = [ + "ALPNCallback", + "SNICallback", + "checkServerIdentity", + "pskCallback", + "secureContext", + "session", + "ticketKeys", + "clientCertEngine", + "privateKeyEngine", + "privateKeyIdentifier", + ]; + for (const name of refused) { + expect(() => tlsMaterial({ key: "K", [name]: () => undefined } as never, API)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`${API} ${name}`), + }), + ); + } + // The label is the caller's, so a client refusal names the request API. + expect(() => + tlsMaterial({ checkServerIdentity: () => undefined } as never, "https.request option"), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining("https.request option checkServerIdentity"), + }), + ); + }); + + test.concurrent("checks for refused options before touching any material", () => { + let read = false; + const options = { + SNICallback: () => undefined, + get key(): string { + read = true; + return "K"; + }, + }; + expect(() => tlsMaterial(options as never, API)).toThrow(); + expect(read).toBe(false); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts new file mode 100644 index 000000000..0c445c89c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; + +import { + createWasiHttpImplementation, + type WasiHttpProvider, + type WasiHttpScheme, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; + +/** A provider that records the scheme and then refuses the connection. */ +function refusingProvider(): { provider: WasiHttpProvider; schemes: WasiHttpScheme[] } { + const schemes: WasiHttpScheme[] = []; + const provider = { + outgoingHandler: { + handle() { + return { + subscribe: () => ({ block: () => undefined }), + get: () => ({ + tag: "ok" as const, + val: { tag: "err" as const, val: { tag: "connection-refused" } }, + }), + }; + }, + }, + types: { + Fields: { fromList: () => ({ entries: () => [] }) }, + IncomingBody: { finish: () => undefined }, + OutgoingBody: { finish: () => undefined }, + OutgoingRequest: class { + body() { + return { write: () => ({ blockingWriteAndFlush: () => undefined }) }; + } + + setMethod(): void {} + + setScheme(scheme: WasiHttpScheme | undefined): void { + if (scheme) { + schemes.push(scheme); + } + } + + setAuthority(): void {} + + setPathWithQuery(): void {} + }, + RequestOptions: class { + setConnectTimeout(): void {} + + setFirstByteTimeout(): void {} + + setBetweenBytesTimeout(): void {} + }, + }, + } satisfies WasiHttpProvider; + return { provider, schemes }; +} + +describe("node:https wasi:http implementation", () => { + test.concurrent("sets the HTTPS scheme variant rather than an `other` string", async () => { + const { provider, schemes } = refusingProvider(); + const https = createHttps(createWasiHttpImplementation(provider)); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ code: "ECONNREFUSED" }); + expect(schemes).toEqual([{ tag: "HTTPS" }]); + }); + + test.concurrent("refuses per-request TLS options, which outgoing-handler cannot honour", async () => { + const { provider, schemes } = refusingProvider(); + const https = createHttps(createWasiHttpImplementation(provider)); + const request = https.request({ host: "example.com", rejectUnauthorized: false }); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining( + "https.request TLS options with the wasi-http implementation", + ), + }); + expect(schemes).toEqual([]); + }); + + test.concurrent("rejects server construction immediately", () => { + const https = createHttps(createWasiHttpImplementation({} as never)); + expect(() => https.createServer()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server"), + }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts new file mode 100644 index 000000000..34ac89664 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { + createWasiSocketsHttpImplementation, + type WasiSocketsProvider, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; + +/** A provider that fails loudly if the implementation ever reaches the network. */ +function untouchedProvider(): WasiSocketsProvider { + return { + instanceNetwork: { + instanceNetwork: () => { + throw new Error("network touched"); + }, + }, + ipNameLookup: { + resolveAddresses: () => { + throw new Error("resolver touched"); + }, + }, + tcpCreateSocket: { + createTcpSocket: () => { + throw new Error("socket created"); + }, + }, + }; +} + +describe("node:https wasi:sockets implementation", () => { + test.concurrent("refuses client requests before touching the network", async () => { + const https = createHttps(createWasiSocketsHttpImplementation(untouchedProvider())); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringMatching(/https: requests with the wasi-sockets implementation.*TLS/), + }); + }); + + test.concurrent("refuses https servers instead of serving plaintext", () => { + const https = createHttps(createWasiSocketsHttpImplementation(untouchedProvider())); + for (const create of [ + () => https.createServer(), + () => https.createServer({ key: "K", cert: "C" }, () => undefined), + () => new https.Server(), + ]) { + expect(create).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringMatching(/https\.Server with the wasi-sockets implementation.*TLS/), + }), + ); + } + }); +}); From 9a9e74b90b88c8855af8af96c2c8c741533b58ca Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:35:10 +0000 Subject: [PATCH 06/68] feat(jco): resolve node:https through the builtin plugin Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- .../lib/wit/builtin/jco-node-0.1.0/http.wit | 32 +++++ packages/jco/src/jco.ts | 5 +- packages/jco/src/node-builtins.ts | 136 +++++++++++++----- packages/jco/src/node-wit.ts | 20 +++ 4 files changed, 153 insertions(+), 40 deletions(-) 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 3e17b780e..07869a7fe 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 @@ -81,6 +81,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -91,6 +93,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -105,6 +135,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index 6f9eadfbd..efd39dd46 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -78,7 +78,10 @@ program .option("--bundle", "bundle source and its dependencies before componentization (automatic for TypeScript)") .option("--bundle-config ", "merge a Rolldown configuration module into the component bundle") .addOption( - new Option("--with-nodejs-http-via ", "implementation used by bundled node:http code") + new Option( + "--with-nodejs-http-via ", + "implementation used by bundled node:http and node:https code", + ) .choices(["direct", "wasi-sockets", "wasi-http"]) .default("direct"), ) diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 925d5f8e1..90f58837c 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -15,6 +15,10 @@ import { HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, HTTP_WIT_REQUIREMENT, + HTTPS_WASI_HTTP_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, + HTTPS_WIT_REQUIREMENT, HTTP2_WIT_REQUIREMENT, INSPECTOR_PROMISES_WIT_REQUIREMENT, INSPECTOR_WIT_REQUIREMENT, @@ -56,6 +60,7 @@ const STREAM_CONSUMERS_SPECIFIER = "node:stream/consumers"; const STREAM_ITER_SPECIFIER = "node:stream/iter"; const DNS_SPECIFIERS = new Set(["node:dns", "node:dns/promises"]); const HTTP_SPECIFIER = "node:http"; +const HTTPS_SPECIFIER = "node:https"; export const HTTP_CALLBACKS_SPECIFIER = "jco:node-http-callbacks"; const HTTP2_SPECIFIER = "node:http2"; export const HTTP2_CALLBACKS_SPECIFIER = "jco:node-http2-callbacks"; @@ -303,6 +308,9 @@ export interface NodeBuiltinOptions { httpCoreModule?: string; httpWasiSocketsImplementationModule?: string; httpWasiHttpImplementationModule?: string; + /** Paths to jco-std's HTTPS modules (overridable for tests). */ + httpsModule?: string; + httpsCoreModule?: string; /** Implementation used for `node:http2` host operations. */ nodejsHttp2Via?: NodejsHttp2Via; /** WASI socket module version supplied by the selected component engine. */ @@ -697,18 +705,32 @@ const HTTP_EXPORTS = [ "validateHeaderValue", ] as const; -function httpExports(moduleExpression: string): string { +/** `node:https` at the pinned release: six exports, no deprecated members. */ +const HTTPS_EXPORTS = ["Agent", "Server", "createServer", "get", "globalAgent", "request"] as const; + +/** The two protocol modules share one core, one implementation set, and one host interface. */ +type HttpProtocol = "http" | "https"; + +const PROTOCOL_EXPORTS: Record = { + http: HTTP_EXPORTS, + https: HTTPS_EXPORTS, +}; + +/** Factory exported by the protocol's core module (`createHttp` / `createHttps`). */ +const PROTOCOL_FACTORY: Record = { http: "createHttp", https: "createHttps" }; + +function protocolExports(protocol: HttpProtocol, moduleExpression: string): string { return ` -const http = ${moduleExpression}; -export default http; -export const { ${HTTP_EXPORTS.join(", ")} } = http; +const ${protocol} = ${moduleExpression}; +export default ${protocol}; +export const { ${PROTOCOL_EXPORTS[protocol].join(", ")} } = ${protocol}; `; } -function httpDirectAdapter(httpModule: string): string { +function protocolDirectAdapter(protocol: HttpProtocol, entryModule: string): string { return ` -import directHttp from ${JSON.stringify(httpModule)}; -${httpExports("directHttp")} +import direct from ${JSON.stringify(entryModule)}; +${protocolExports(protocol, "direct")} `; } @@ -717,28 +739,54 @@ function httpCallbacksAdapter(httpModule: string): string { return `export { httpCallbacks } from ${JSON.stringify(httpModule)};`; } -function httpWasiSocketsAdapter(coreModule: string, implementationModule: string, version: string): string { +function protocolWasiSocketsAdapter( + protocol: HttpProtocol, + coreModule: string, + implementationModule: string, + version: string, +): string { + const factory = PROTOCOL_FACTORY[protocol]; const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; return ` import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; -import { createHttp } from ${JSON.stringify(coreModule)}; +import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiSocketsHttpImplementation } from ${JSON.stringify(implementationModule)}; -${httpExports(`createHttp(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule} }))`)} +${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule} }))`)} `; } -function httpWasiHttpAdapter(coreModule: string, implementationModule: string): string { +function protocolWasiHttpAdapter(protocol: HttpProtocol, coreModule: string, implementationModule: string): string { + const factory = PROTOCOL_FACTORY[protocol]; return ` import * as outgoingHandler from "wasi:http/outgoing-handler@0.2.12"; import * as types from "wasi:http/types@0.2.12"; -import { createHttp } from ${JSON.stringify(coreModule)}; +import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiHttpImplementation } from ${JSON.stringify(implementationModule)}; -${httpExports("createHttp(createWasiHttpImplementation({ outgoingHandler, types }))")} +${protocolExports(protocol, `${factory}(createWasiHttpImplementation({ outgoingHandler, types }))`)} `; } +/** WIT requirements for one protocol module under one `--with-nodejs-http-via` selection. */ +function protocolWitRequirements( + protocol: HttpProtocol, + via: NodejsHttpVia, + wasiSocketsVersion: string, +): readonly NodeWitRequirement[] { + const https = protocol === "https"; + if (via === "direct") { + return [https ? HTTPS_WIT_REQUIREMENT : HTTP_WIT_REQUIREMENT]; + } + if (via === "wasi-sockets") { + if (wasiSocketsVersion === "0.2.12") { + return https ? HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_WIT_REQUIREMENTS; + } + return https ? HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS; + } + return https ? HTTPS_WASI_HTTP_WIT_REQUIREMENTS : HTTP_WASI_HTTP_WIT_REQUIREMENTS; +} + const HTTP2_EXPORTS = [ "Http2ServerRequest", "Http2ServerResponse", @@ -803,6 +851,7 @@ ${http2Exports(`createHttp2(${factory}(${factoryArguments}))`)} function requireWasiHttpVersion( worldMetadata: WorldMetadata, + specifier: string, via: Exclude, version = "0.2.12", ): void { @@ -818,7 +867,7 @@ function requireWasiHttpVersion( if (incompatible) { const { major, minor, patch } = incompatible.version!; throw new Error( - `node:http via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, + `${specifier} via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, ); } } @@ -1026,8 +1075,31 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui stdModule(options.httpWasiSocketsImplementationModule, "http/impl/wasi-sockets"); const httpWasiHttpImplementationModule = () => stdModule(options.httpWasiHttpImplementationModule, "http/impl/wasi-http"); + const httpsModule = () => stdModule(options.httpsModule, "https"); + const httpsCoreModule = () => stdModule(options.httpsCoreModule, "https/core"); const httpVia = options.nodejsHttpVia ?? "direct"; const wasiSocketsVersion = options.wasiSocketsVersion ?? "0.2.12"; + const protocolOf = (specifier: string): HttpProtocol | undefined => + specifier === HTTP_SPECIFIER ? "http" : specifier === HTTPS_SPECIFIER ? "https" : undefined; + /** + * Facade for `node:http` or `node:https` under the selected implementation. Each jco-std + * path is resolved only on the branch that emits it, so a build never touches an entry point + * it does not use. + */ + const protocolAdapter = (protocol: HttpProtocol): string => { + if (httpVia === "direct") { + return protocolDirectAdapter(protocol, protocol === "http" ? httpModule() : httpsModule()); + } + const coreModule = protocol === "http" ? httpCoreModule() : httpsCoreModule(); + return httpVia === "wasi-sockets" + ? protocolWasiSocketsAdapter( + protocol, + coreModule, + httpWasiSocketsImplementationModule(), + wasiSocketsVersion, + ) + : protocolWasiHttpAdapter(protocol, coreModule, httpWasiHttpImplementationModule()); + }; const http2Module = () => options.http2Module ?? fileURLToPath(import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2")); @@ -1137,24 +1209,18 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui options.onWitRequirement?.(FS_WIT_REQUIREMENT); return `${VIRTUAL_PREFIX}${id}`; } - if (id === HTTP_SPECIFIER) { - if (httpVia === "direct") { - options.onWitRequirement?.(HTTP_WIT_REQUIREMENT); - } else { + const protocol = protocolOf(id); + if (protocol !== undefined) { + if (httpVia !== "direct") { requireWasiHttpVersion( worldMetadata, + id, httpVia, httpVia === "wasi-sockets" ? wasiSocketsVersion : "0.2.12", ); - const requirements = - httpVia === "wasi-sockets" - ? wasiSocketsVersion === "0.2.12" - ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS - : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS - : HTTP_WASI_HTTP_WIT_REQUIREMENTS; - for (const requirement of requirements) { - options.onWitRequirement?.(requirement); - } + } + for (const requirement of protocolWitRequirements(protocol, httpVia, wasiSocketsVersion)) { + options.onWitRequirement?.(requirement); } return `${VIRTUAL_PREFIX}${id}`; } @@ -1162,7 +1228,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (http2Via === "direct") { options.onWitRequirement?.(HTTP2_WIT_REQUIREMENT); } else if (http2Via === "wasi-sockets") { - requireWasiHttpVersion(worldMetadata, http2Via, wasiSocketsVersion); + requireWasiHttpVersion(worldMetadata, HTTP2_SPECIFIER, http2Via, wasiSocketsVersion); for (const requirement of wasiSocketsVersion === "0.2.12" ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS) { @@ -1254,17 +1320,9 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (FS_SPECIFIERS.has(value)) { return fsAdapter(value, fsModule(), fsPromisesModule()); } - if (value === HTTP_SPECIFIER) { - if (httpVia === "direct") { - return httpDirectAdapter(httpModule()); - } - return httpVia === "wasi-sockets" - ? httpWasiSocketsAdapter( - httpCoreModule(), - httpWasiSocketsImplementationModule(), - wasiSocketsVersion, - ) - : httpWasiHttpAdapter(httpCoreModule(), httpWasiHttpImplementationModule()); + const protocol = protocolOf(value); + if (protocol !== undefined) { + return protocolAdapter(protocol); } if (value === HTTP2_SPECIFIER) { if (http2Via === "direct") { diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 446a342be..352944c6d 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -114,6 +114,16 @@ export const HTTP_WIT_REQUIREMENT = nodeRequirement("node:http", "http", { ], }); +/** + * `node:https` is `node:http` with TLS terminated by the same host interface, so it shares the + * import and the callback export; only the comment naming the importing builtin differs, and + * injection dedupes by `witImport` when a guest uses both. + */ +export const HTTPS_WIT_REQUIREMENT: NodeWitRequirement = { + ...HTTP_WIT_REQUIREMENT, + nodeSpecifier: "node:https", +}; + export const HTTP2_WIT_REQUIREMENT: NodeWitRequirement = { nodeSpecifier: "node:http2", witImport: "jco:node/http2@0.1.0", @@ -197,6 +207,16 @@ export const HTTP_WASI_HTTP_WIT_REQUIREMENTS = [ wasiRequirement("wasi:http/types@0.2.12", WASI_HTTP_DEPENDENCIES), ] as const; +function forHttps(requirements: readonly NodeWitRequirement[]): NodeWitRequirement[] { + return requirements.map((requirement) => ({ ...requirement, nodeSpecifier: "node:https" })); +} + +export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS); + +export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS); + +export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forHttps(HTTP_WASI_HTTP_WIT_REQUIREMENTS); + export interface WitInjectionResult { witPath: string; worldFile: string; From 35022e4fb1591071ba2ff5fc3ab38d083da67bc8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 14:03:09 +0000 Subject: [PATCH 07/68] docs(std): document node:https Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- docs/src/interop/nodejs-builtins.md | 147 ++++++++++++++++++---------- packages/jco-std/README.md | 42 +++++--- 2 files changed, 124 insertions(+), 65 deletions(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index f8f7549a1..c918ec12e 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -106,29 +106,30 @@ is planned. > It is the only such alias: modules added after the split, including `node:assert`, are > available only under a versioned entry point. -| Imports | Implementation | Notes | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | -| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | -| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | -| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | -| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | +| Imports | Implementation | Notes | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | +| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | +| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | +| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | +| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | | `node:module` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module` | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API` -- see below. Requires no WIT capability. | -| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | -| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | -| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | -| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | -| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | -| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | -| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | -| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Outbound client API over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP transport. Server listening is explicitly unsupported. | -| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | -| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | -| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | -| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | -| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | -| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | -| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | +| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | +| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | +| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | +| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | +| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | +| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | +| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | +| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation -- see below. Servers need `direct` or `wasi-sockets`. | +| `node:https` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https` | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS is terminated by the `direct` host only -- see below. | +| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | +| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | +| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | +| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | +| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | +| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | +| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | ### Stream consumers and iterable streams @@ -270,14 +271,13 @@ need this import. Bundled source can use the documented Node 24 streaming decoder directly: ```js -import { Buffer } from "node:buffer"; -import { StringDecoder } from "node:string_decoder"; +import { Buffer } from 'node:buffer'; +import { StringDecoder } from 'node:string_decoder'; -const decoder = new StringDecoder("utf8"); +const decoder = new StringDecoder('utf8'); export function decode() { - return decoder.write(Buffer.from([0xf0, 0x9f])) + - decoder.end(Buffer.from([0x8c, 0x8d])); + return decoder.write(Buffer.from([0xf0, 0x9f])) + decoder.end(Buffer.from([0x8c, 0x8d])); } ``` @@ -612,12 +612,12 @@ jco transpile component.wasm \ The guest code is ordinary Node: ```js -import { Session } from "node:inspector/promises"; +import { Session } from 'node:inspector/promises'; const session = new Session(); session.connect(); -const { result } = await session.post("Runtime.evaluate", { expression: "6 * 7" }); -result.value; // 42, evaluated in the host isolate +const { result } = await session.post('Runtime.evaluate', { expression: '6 * 7' }); +result.value; // 42, evaluated in the host isolate ``` Argument validation, session state, the `EventEmitter` surface, and error reconstruction all run @@ -629,7 +629,7 @@ which is best-effort for functions, symbols, and cycles. #### The host calls back into the component The inspector's two callbacks -- a `post` response and a session notification -- run the other way, -from host to guest. A component cannot implement a resource declared on an *imported* interface (its +from host to guest. A component cannot implement a resource declared on an _imported_ interface (its methods would run host-side), so the callbacks are a guest-**exported** interface, `jco:node/inspector-callbacks@0.1.0`, holding one resource per callback kind: a one-shot `post-callback` and a long-lived `notification-listener`. When bundled source imports @@ -640,8 +640,8 @@ alongside the entry -- neither is written by hand. The embedder wires the exported interface to the host adapter after instantiation: ```js -import * as inspectorHost from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node"; -import * as component from "./transpiled/component.js"; +import * as inspectorHost from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node'; +import * as component from './transpiled/component.js'; inspectorHost.attachCallbacks(component.inspectorCallbacks); ``` @@ -672,15 +672,15 @@ internals (`_load`, `_resolveFilename`, `_findPath`, `_nodeModulePaths`, and the **Everything else is real**, because it is classification or arithmetic: -| Surface | Behavior | -| --- | --- | -| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | -| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | -| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | -| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | -| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | -| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | -| `new Module(id)` | Constructs, with Node's own-property shape. Its *methods* are what need a loader | +| Surface | Behavior | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | +| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | +| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | +| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | +| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | +| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | +| `new Module(id)` | Constructs, with Node's own-property shape. Its _methods_ are what need a loader | #### `createRequire` @@ -693,10 +693,10 @@ a refusal -- it answers truthfully: ```js const require = createRequire(import.meta.url); -require.resolve("node:path"); // "node:path", exactly as Node answers -require.resolve("lodash"); // throws MODULE_NOT_FOUND -- which is the truth here -require.cache; // genuinely empty -require.main; // genuinely undefined +require.resolve('node:path'); // "node:path", exactly as Node answers +require.resolve('lodash'); // throws MODULE_NOT_FOUND -- which is the truth here +require.cache; // genuinely empty +require.main; // genuinely undefined ``` #### A caveat on `builtinModules` @@ -780,7 +780,10 @@ room for a future browser implementation. The `node:http` adapter implements both client and server NodeJS HTTP APIs, with outbound `request()` and `get()` calls with Node-style `ClientRequest` -and buffered `IncomingMessage` objects along with `http.Server`. +and buffered `IncomingMessage` objects along with `http.Server`. `node:https` +is the same core driven with the `https:` protocol, port 443, and a TLS-aware +`Agent`, exactly as `lib/https.js` reuses `_http_client` and `_http_server` +upstream; it shares the implementation selection below. As this API obviously requires access to the outside world of some sort, and there are actually many ways to achieve that on the host side, you must select @@ -838,10 +841,47 @@ applications can continue mapping the Node provider module without this factory. > [!WARNING] > All modes currently buffer complete request and response bodies. -Connection pooling, upgrades, CONNECT tunnels, and HTTPS are explicit gaps. +Connection pooling, upgrades, and CONNECT proxy tunnels are explicit gaps. Unavailable operations throw `ERR_JCO_UNSUPPORTED_NODE_API` rather than silently doing nothing. +#### HTTPS + +`node:https` exposes Node 24's six exports: `Agent`, `globalAgent`, `Server`, +`createServer`, `get`, and `request`. `https.Agent` subclasses `http.Agent` on +both prototype chains, keeps Node's `defaultPort`/`protocol`/`maxCachedSessions` +defaults and its TLS session cache, and produces the same 23-field `getName()` +key as Node, so option bags pool the way they would natively. Requests reject +non-`https:` protocols with `ERR_INVALID_PROTOCOL` and elide `:443` from the +authority, and `https.get()` ends the request itself. + +TLS crosses the component boundary as a typed `tls-options` record on the +`jco:node/http@0.1.0` request and server options. It carries the serializable +subset of Node's `tls.connect` / `tls.createServer` options: `key`, `cert`, +`pfx`, `passphrase`, `ca`, `crl`, `dhparam`, `ciphers`, `ecdhCurve`, `sigalgs`, +`minVersion`, `maxVersion`, `secureProtocol`, `secureOptions`, +`sessionIdContext`, `honorCipherOrder`, `ALPNProtocols`, `servername`, +`rejectUnauthorized`, and `requestCert`. Material fields stay lists, so a +`key: [rsa, ecdsa]` bundle reaches the host intact. Options with no typed +representation -- `checkServerIdentity`, `SNICallback`, `ALPNCallback`, +`pskCallback`, `secureContext`, `session`, `ticketKeys`, and the OpenSSL engine +options -- throw `ERR_JCO_UNSUPPORTED_NODE_API` naming the option rather than +being dropped. + +| Value | `node:https` behaviour | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `direct` | Clients and servers. The opt-in Node provider routes `https` requests to `node:https.request` with the carried TLS options, and a server carrying a `tls` record to `node:https.createServer`, so the host's own TLS stack terminates the connection. | +| `wasi-sockets` | Refused for both directions: Preview 2 sockets carry no TLS stack, so the implementation throws instead of speaking plaintext on an `https` URL or `https.Server`. | +| `wasi-http` | Clients only, with the `HTTPS` scheme. `wasi:http/outgoing-handler` owns certificate validation, so any per-request TLS option is refused; servers are rejected as for `node:http`. | + +An `https.Server` always carries its `tls` record, even when no material was +supplied, so an implementation without a TLS stack refuses it; the `direct` +host then behaves like Node, which constructs the server and fails each +handshake. Because `jco:node/http@0.1.0` gained the record in place, a project +whose `wit/deps/jco-node-0.1.0/http.wit` predates it must delete that file so +the next `jco componentize` reinstalls the current interface: injection never +overwrites an existing dependency file. + ### HTTP/2 Client and server code uses Node's normal session and stream APIs: @@ -869,12 +909,11 @@ jco componentize component.js --wit wit --bundle \ --with-nodejs-http2-via direct -o component.wasm ``` -| Value | Behavior | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | +| Value | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | | `wasi-sockets` | Cleartext prior-knowledge HTTP/2 (`h2c`) clients and TCP servers, with guest-side framing, HPACK, settings, ping, reset, and stream/connection flow control. | -| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | - +| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | By default, the provider rejects both `connect()` and server construction with `ERR_JCO_HTTP2_ADAPTER_REQUIRED`. @@ -988,7 +1027,7 @@ These modules contain useful portable pieces, but their complete public surfaces also require operating-system access, Node internals, an event loop, or a larger set of coordinated shims: -`node:crypto`, `node:dgram`, `node:http2`, `node:https`, `node:net`, +`node:crypto`, `node:dgram`, `node:http2`, `node:net`, `node:perf_hooks`, `node:process`, `node:repl`, `node:sqlite`, `node:stream`, `node:stream/promises`, `node:stream/web`, `node:timers`, `node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 2aeeeec04..f7adcbcee 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -36,6 +36,7 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/fs` | `node:fs` and `node:fs/promises` over an explicit host capability | | `wasi/0.2.x/node/24.x.x/http` | `node:http` API with direct, WASI sockets, and WASI HTTP implementations | | `wasi/0.2.x/node/24.x.x/http2` | `node:http2` API with direct and cleartext WASI sockets implementations | +| `wasi/0.2.x/node/24.x.x/https` | `node:https` API sharing the `node:http` core and implementations | | `wasi/0.2.x/node/24.x.x/os` | `node:os` guest adapter over an explicit host capability | | `wasi/0.2.x/node/24.x.x/path` | `node:path` adapter, Node 24 on WASI p2 | | `wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local `node:string_decoder` implementation for Node 24 | @@ -71,7 +72,8 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets` | `node:http` implementation over WASI Preview 2 sockets | | `wasi/0.2.x/node/24.x.x/http/impl/wasi-http` | `node:http` implementation over WASI Preview 2 HTTP | | `wasi/0.2.x/node/24.x.x/http/host` | Deny-by-default host for `jco:node/http` | -| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` | +| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` and `node:https` | +| `wasi/0.2.x/node/24.x.x/https/core` | `node:https` core shared by the selectable implementations | | `wasi/0.2.x/node/24.x.x/os/host` | Deny-by-default host for `jco:node/os` | | `wasi/0.2.x/node/24.x.x/os/host/node` | Opt-in host over the runtime's real `node:os` | | `node/path` | Legacy unversioned alias for `wasi/0.2.x/node/24.x.x/path` | @@ -154,8 +156,8 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` and the application-provided `jco:node/inspector@0.1.0` capability, with the host calling back into the component through a guest-exported callbacks interface; -- the `node:http` API, with selectable direct, `wasi:sockets`, and `wasi:http` - implementations; +- the `node:http` and `node:https` APIs, with selectable direct, + `wasi:sockets`, and `wasi:http` implementations; - `node:buffer`, with its modern core provided by Jco's audited unenv compatibility layer; - `node:querystring`, provided by Jco's audited unenv compatibility layer; @@ -585,7 +587,19 @@ const server = createServer((request, response) => { server.listen(8080, "127.0.0.1"); ``` -Bundle it and select how `node:http` reaches the host: +`node:https` is the same core with the `https:` profile, port 443, and a +TLS-aware `Agent`. Servers take Node's TLS options and clients take the +`tls.connect` subset (`ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, +`ALPNProtocols`, and so on), which cross the boundary as a typed record: + +```js +import { createServer, get } from "node:https"; + +createServer({ key, cert }, (request, response) => response.end("secure")).listen(8443); +get("https://localhost:8443/", { ca: cert }, (response) => response.resume()); +``` + +Bundle it and select how `node:http` and `node:https` reach the host: ```console jco componentize component.js --wit wit --bundle \ @@ -597,12 +611,17 @@ jco componentize component.js --wit wit --bundle \ - `direct` (the default), which adds `jco:node/http@0.1.0`; its default provider throws `ERR_JCO_HTTP_ADAPTER_REQUIRED`, and a Node application can explicitly map `wasi/0.2.x/node/24.x.x/http/host/node` when transpiling. It supports - clients and servers through real `node:http`; + clients and servers through real `node:http`, and terminates TLS for + `node:https` through real `node:https`; - `wasi-sockets`, which implements HTTP/1.1 in the guest using only Preview 2 - socket and IO capabilities, including TCP servers; and + socket and IO capabilities, including TCP servers. It has no TLS stack, so + `node:https` clients and servers are refused rather than served in + plaintext; and - `wasi-http`, which translates requests to Preview 2 - `wasi:http/outgoing-handler`. It rejects `Server` construction immediately - because an outgoing-handler cannot listen for arbitrary inbound connections. + `wasi:http/outgoing-handler`, including `https` URLs, though per-request TLS + options are refused because the outgoing-handler owns certificate + validation. It rejects `Server` construction immediately because an + outgoing-handler cannot listen for arbitrary inbound connections. When the selected world is missing a required import or callback export, Jco edits that world in place, adds generated comments and declarations, installs @@ -619,9 +638,10 @@ WIT package defines multiple worlds. The initial implementation buffers each request and response at the implementation boundary. Client and server objects retain Node-style callbacks -and events inside the guest. Connection pooling, upgrades, CONNECT tunnels, -HTTPS, and persistent HTTP/1.1 connections in the `wasi-sockets` implementation -are not implemented; unavailable operations throw explicit errors. +and events inside the guest. Connection pooling, upgrades, CONNECT proxy +tunnels, and persistent HTTP/1.1 connections in the `wasi-sockets` +implementation are not implemented; unavailable operations throw explicit +errors. ### HTTP/2 From 97acf6cc1f52bfe73a4408d2aa6f8ac6f299c099 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 14:03:47 +0000 Subject: [PATCH 08/68] test(jco): add node:https plugin and guest tests Claude-Session: https://claude.ai/code/session_01Uu7kbCb7Sr885jheB32hMg --- .../node-https-server/component.js | 22 ++ .../componentize/node-https-server/run.js | 36 +++ .../node-https-server/wit/component.wit | 6 + .../componentize/node-https/component.js | 26 +++ .../componentize/node-https/run-probe.mjs | 29 +++ .../fixtures/componentize/node-https/run.js | 28 +++ .../componentize/node-https/wit/component.wit | 11 + packages/jco/test/node/http.js | 8 + packages/jco/test/node/https.js | 209 ++++++++++++++++++ 9 files changed, 375 insertions(+) create mode 100644 packages/jco/test/fixtures/componentize/node-https-server/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-https-server/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit create mode 100644 packages/jco/test/fixtures/componentize/node-https/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-https/run-probe.mjs create mode 100644 packages/jco/test/fixtures/componentize/node-https/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-https/wit/component.wit create mode 100644 packages/jco/test/node/https.js diff --git a/packages/jco/test/fixtures/componentize/node-https-server/component.js b/packages/jco/test/fixtures/componentize/node-https-server/component.js new file mode 100644 index 000000000..f962ec0e9 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/component.js @@ -0,0 +1,22 @@ +import { createServer } from "node:https"; + +let server; + +export function start(key, cert) { + server = createServer({ key, cert }, async (request, response) => { + request.setEncoding("utf8"); + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + response.setHeader("Content-Type", "text/plain"); + response.end(`${request.method} ${request.url}: ${chunks.join("")}`); + }); + server.listen(0, "127.0.0.1"); + return server.address().port; +} + +export function stop() { + server.closeAllConnections(); + server.close(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https-server/run.js b/packages/jco/test/fixtures/componentize/node-https-server/run.js new file mode 100644 index 000000000..0b2acc4a9 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/run.js @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +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(key, cert); + +try { + const body = await new Promise((resolve, reject) => { + const request = https.request( + `https://127.0.0.1:${port}/items`, + { method: "POST", ca: cert, servername: "localhost" }, + (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"); + }); + stdout.write(`${body}\n`); +} finally { + await instance.stop(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit new file mode 100644 index 000000000..38533bf7c --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit @@ -0,0 +1,6 @@ +package jco-fixtures:node-https-server; + +world component { + export start: func(key: string, cert: string) -> u16; + export stop: func(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/component.js b/packages/jco/test/fixtures/componentize/node-https/component.js new file mode 100644 index 000000000..62dbc67b4 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/component.js @@ -0,0 +1,26 @@ +import { get } from "node:https"; + +function fetchText(url, ca) { + return new Promise((resolve, reject) => { + // The fixture certificate names `localhost`; the connection goes to the loopback + // address, so SNI and identity checks are pinned to the certificate's name. + const request = get(url, { ca, servername: "localhost" }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => { + resolve({ + statusCode: response.statusCode, + contentType: response.headers["content-type"], + body: chunks.join(""), + }); + }); + }); + request.once("error", reject); + }); +} + +export async function run(url, ca) { + return fetchText(url, ca); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs b/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs new file mode 100644 index 000000000..a9f2cbc5b --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +const server = https.createServer({ key, cert }, (_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("hello from node:https"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + +try { + const address = server.address(); + const { instantiate } = await import(pathToFileURL(argv[2])); + const imports = new WASIShim().getImportObject(); + imports[argv[3]] = await import(argv[3]); + imports["jco:node/http-callbacks"] = { RequestListener: class {} }; + const instance = await instantiate(undefined, imports); + stdout.write(`${JSON.stringify(await instance.run(`https://127.0.0.1:${address.port}/`, cert))}\n`); +} finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/run.js b/packages/jco/test/fixtures/componentize/node-https/run.js new file mode 100644 index 000000000..f51a0c560 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/run.js @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +const server = https.createServer({ key, cert }, (_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("hello from node:https"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + +try { + const address = server.address(); + 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); + stdout.write(`${JSON.stringify(await instance.run(`https://127.0.0.1:${address.port}/`, cert))}\n`); +} finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https/wit/component.wit new file mode 100644 index 000000000..29b72428f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/wit/component.wit @@ -0,0 +1,11 @@ +package jco-fixtures:node-https; + +world component { + record report { + status-code: u16, + content-type: string, + body: string, + } + + export run: func(url: string, ca: string) -> report; +} diff --git a/packages/jco/test/node/http.js b/packages/jco/test/node/http.js index 2c4034d42..62d055936 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -235,7 +235,15 @@ describe.skipIf(!hasJspi)("node:http in a component", () => { copy: true, extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", implementation], }); + // `--with-nodejs-http-via` selects which capability is injected; a wrong mode still + // injects *something*, so the assertion names the interface the mode must add. + const injected = { + direct: "jco:node/http@0.1.0", + "wasi-sockets": "wasi:sockets/instance-network@0.2.12", + "wasi-http": "wasi:http/outgoing-handler@0.2.12", + }[implementation]; expect(stderr).toContain("Jco added generated WIT import"); + expect(stderr).toContain(injected); const map = implementation === "direct" ? { "jco:node/http@0.1.0": NODE_HOST } : undefined; const { esModuleOutputPath, cleanup } = await setupAsyncTest({ component: { name: `node-http-${implementation}`, path: componentPath, skipInstantiation: true }, diff --git a/packages/jco/test/node/https.js b/packages/jco/test/node/https.js new file mode 100644 index 000000000..108331eef --- /dev/null +++ b/packages/jco/test/node/https.js @@ -0,0 +1,209 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { describe, expect, test, vi } from "vitest"; + +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins.js"; +import { + HTTP_WIT_REQUIREMENT, + HTTPS_WASI_HTTP_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, + HTTPS_WIT_REQUIREMENT, + injectNodeWitImports, +} from "../../src/node-wit.js"; +import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; + +const modulePaths = { + httpModule: "/jco/http.js", + httpCoreModule: "/jco/http/core.js", + httpWasiSocketsImplementationModule: "/jco/http/wasi-sockets.js", + httpWasiHttpImplementationModule: "/jco/http/wasi-http.js", + httpsModule: "/jco/https.js", + httpsCoreModule: "/jco/https/core.js", +}; + +const HTTPS_EXPORTS = ["Agent", "Server", "createServer", "get", "globalAgent", "request"]; + +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; + +describe("node:https builtin adapter", () => { + test.each([ + ["direct", "jco:node/http@0.1.0", "/jco/https.js"], + ["wasi-sockets", "wasi:sockets/instance-network@0.2.12", "/jco/https/core.js"], + ["wasi-http", "wasi:http/outgoing-handler@0.2.12", "/jco/https/core.js"], + ])("generates the %s implementation facade", (nodejsHttpVia, capability, implementationModule) => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { ...modulePaths, nodejsHttpVia, onWitRequirement }, + ); + const id = plugin.resolveId("node:https"); + expect(id).toBe("\0jco-node-builtin:node:https"); + const source = plugin.load(id); + expect(source).toContain(implementationModule); + expect(source).toContain("export default https"); + for (const name of HTTPS_EXPORTS) { + expect(source).toMatch(new RegExp(`\\b${name}\\b`)); + } + // The six-export surface must not leak node:http-only names. + expect(source).not.toContain("validateHeaderValue"); + expect(source).not.toContain("STATUS_CODES"); + if (nodejsHttpVia !== "direct") { + expect(source).toContain("createHttps("); + } + expect(onWitRequirement).toHaveBeenCalledWith( + expect.objectContaining({ witImport: capability, nodeSpecifier: "node:https" }), + ); + }); + + test.concurrent("shares the direct host interface and callback export with node:http", () => { + expect(HTTPS_WIT_REQUIREMENT.witImport).toBe(HTTP_WIT_REQUIREMENT.witImport); + expect(HTTPS_WIT_REQUIREMENT.guestExports).toEqual(HTTP_WIT_REQUIREMENT.guestExports); + expect(HTTPS_WIT_REQUIREMENT.dependencySources).toEqual(HTTP_WIT_REQUIREMENT.dependencySources); + for (const [https, http] of [ + [HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, "wasi:sockets/instance-network@0.2.12"], + [HTTPS_WASI_HTTP_WIT_REQUIREMENTS, "wasi:http/outgoing-handler@0.2.12"], + ]) { + expect(https.map(({ witImport }) => witImport)).toContain(http); + expect(https.every(({ nodeSpecifier }) => nodeSpecifier === "node:https")).toBe(true); + } + }); + + test.concurrent("resolves node:https without touching the node:http entry points", () => { + // Only the https path is configured; resolving the real package for node:http would fail. + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { httpsModule: "/jco/https.js" }); + const id = plugin.resolveId("node:https"); + expect(plugin.load(id)).toContain('from "/jco/https.js"'); + }); + + test.concurrent("does not intercept the bare https specifier", () => { + expect(nodeBuiltinPlugin({ imports: [], exports: [] }, modulePaths).resolveId("https")).toBeNull(); + }); + + test.each([ + ["wasi-sockets", "sockets"], + ["wasi-http", "http"], + ])("rejects an incompatible Preview 2 package for %s, naming node:https", (nodejsHttpVia, packageName) => { + const plugin = nodeBuiltinPlugin( + { + imports: [ + { + namespace: "wasi", + package: packageName, + interface: "types", + version: { major: 0n, minor: 2n, patch: 10n }, + }, + ], + exports: [], + }, + { ...modulePaths, nodejsHttpVia }, + ); + expect(() => plugin.resolveId("node:https")).toThrow(/node:https via .* requires wasi:.*@0\.2\.12/); + }); +}); + +describe("node:https WIT installation", () => { + test.concurrent("injects one shared import when a guest uses both protocol modules", async () => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:https;\nworld component {}\n"); + const result = await injectNodeWitImports(root, undefined, [HTTP_WIT_REQUIREMENT, HTTPS_WIT_REQUIREMENT]); + expect(result).toMatchObject({ + imports: ["jco:node/http@0.1.0"], + exports: ["jco:node/http-callbacks@0.1.0"], + }); + const worldSource = await readFile(world, "utf8"); + expect(worldSource.match(/import jco:node\/http@0\.1\.0;/g)).toHaveLength(1); + expect(worldSource.match(/export jco:node\/http-callbacks@0\.1\.0;/g)).toHaveLength(1); + const source = await readFile(join(root, "deps/jco-node-0.1.0/http.wit"), "utf8"); + expect(source).toContain("record tls-options"); + expect(source).toContain("tls: option"); + expect(await injectNodeWitImports(root, undefined, [HTTPS_WIT_REQUIREMENT])).toBeUndefined(); + }); + + test.concurrent("names node:https in the generated comment for an https-only guest", async () => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:https;\nworld component {}\n"); + await injectNodeWitImports(root, undefined, [HTTPS_WIT_REQUIREMENT]); + const worldSource = await readFile(world, "utf8"); + expect(worldSource).toContain("bundled source imports node:https"); + expect(worldSource).toContain("import jco:node/http@0.1.0;"); + }); +}); + +// The direct mode's guest tests need two things before they can run: a published jco-std that +// carries the node:https exports, and a working direct round trip. Today a transpiled component +// also imports `jco:node/http-callbacks` (the `http` interface `use`s it, so the world imports it +// transitively) and the JSPI-suspended `request` import hands `undefined` back to the guest; the +// same happens for plain node:http, see the sibling tests in http.js. +describe("node:https in a component", () => { + // TODO(unskip): use the published jco-std node:https exports once a release containing them + // is available, and remove the callbacks/JSPI blockers described above. + test.skip("terminates TLS for a guest server through the host node:https", async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-https-server", + bundle: true, + copy: true, + extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", "direct"], + }); + expect(stderr).toContain("Jco added generated WIT import jco:node/http@0.1.0"); + expect(stderr).toContain("jco:node/http-callbacks@0.1.0"); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-https-server", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + // The same defaults the CLI applies: JSPI plus the async host imports. + extraArgs: withDefaultNodeCapabilities({ + asyncExports: ["*"], + map: { "jco:node/http@0.1.0": NODE_HOST }, + }), + }, + }, + }); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-https-server/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, NODE_HOST); + expect(output.stdout.trim()).toBe("POST /items: hello"); + } finally { + await cleanup(); + } + }, 600_000); + + // TODO(unskip): same blockers as above. + test.skip("performs a verified HTTPS request from a guest through the host node:https", async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-https", + bundle: true, + copy: true, + extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", "direct"], + }); + expect(stderr).toContain("Jco added generated WIT import jco:node/http@0.1.0"); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-https-direct", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: withDefaultNodeCapabilities({ + asyncExports: ["run"], + map: { "jco:node/http@0.1.0": NODE_HOST }, + }), + }, + }, + }); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-https/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, NODE_HOST); + expect(JSON.parse(output.stdout)).toEqual({ + statusCode: 200, + contentType: "text/plain", + body: "hello from node:https", + }); + } finally { + await cleanup(); + } + }, 600_000); +}); From 9ad28c1f94de6faaf5ec58bb1720ac4ae9b6046c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:21:38 +0000 Subject: [PATCH 09/68] fix(p2-shim): correct DNS records and network exports --- .../preview2-shim/src/io/worker-sockets.ts | 10 ++++----- packages/preview2-shim/src/nodejs/sockets.ts | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/preview2-shim/src/io/worker-sockets.ts b/packages/preview2-shim/src/io/worker-sockets.ts index 2b42637f7..3286f1f74 100644 --- a/packages/preview2-shim/src/io/worker-sockets.ts +++ b/packages/preview2-shim/src/io/worker-sockets.ts @@ -101,12 +101,10 @@ export function socketResolveAddress(name: string) { (addresses) => { return (Array.isArray(addresses) ? addresses : [addresses]).map( ({ address, family }) => { - return [ - { - tag: "ipv" + family, - val: (family === 4 ? ipv4ToTuple : ipv6ToTuple)(address), - }, - ]; + return { + tag: "ipv" + family, + val: (family === 4 ? ipv4ToTuple : ipv6ToTuple)(address), + }; }, ); }, diff --git a/packages/preview2-shim/src/nodejs/sockets.ts b/packages/preview2-shim/src/nodejs/sockets.ts index 5ac2b909d..16f519400 100644 --- a/packages/preview2-shim/src/nodejs/sockets.ts +++ b/packages/preview2-shim/src/nodejs/sockets.ts @@ -65,6 +65,8 @@ const symbolDispose = Symbol.dispose || Symbol.for("dispose"); // Network class privately stores capabilities class Network implements NetworkNamespace.Network { + // Compatibility with the resource placeholder in the bundled WASI 0.2.10 WIT. + noop(): void {} #allowDnsLookup = true; #allowTcp = true; #allowUdp = true; @@ -121,8 +123,26 @@ export const instanceNetwork: typeof InstanceNetworkNamespace = { }, }; -export const network: typeof NetworkNamespace = { +export const network: typeof NetworkNamespace & { + networkErrorCode(error: { toDebugString(): string }): NetworkNamespace.ErrorCode | undefined; +} = { Network, + networkErrorCode(error): NetworkNamespace.ErrorCode | undefined { + const payload: unknown = "payload" in error ? error.payload : undefined; + if (typeof payload !== "object" || payload === null || !("code" in payload)) { + return undefined; + } + const codes: Partial> = { + ECONNRESET: "connection-reset", + ECONNREFUSED: "connection-refused", + ECONNABORTED: "connection-aborted", + ETIMEDOUT: "timeout", + EACCES: "access-denied", + EPERM: "access-denied", + ENETUNREACH: "remote-unreachable", + }; + return typeof payload.code === "string" ? codes[payload.code] : undefined; + }, }; class ResolveAddressStream implements IpNameLookupNamespace.ResolveAddressStream { From 2ccee9d3bf80d6ecde8fa00f5625251838ebb773 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:21:53 +0000 Subject: [PATCH 10/68] feat(p2-shim): add opt-in TLS over owned IO streams --- packages/preview2-shim/package.json | 4 + packages/preview2-shim/src/io/calls.ts | 7 + .../preview2-shim/src/io/worker-thread.ts | 35 ++- packages/preview2-shim/src/io/worker-tls.ts | 112 +++++++++ packages/preview2-shim/src/nodejs/tls.ts | 216 ++++++++++++++++++ 5 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 packages/preview2-shim/src/io/worker-tls.ts create mode 100644 packages/preview2-shim/src/nodejs/tls.ts diff --git a/packages/preview2-shim/package.json b/packages/preview2-shim/package.json index f3937b177..1ad888685 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -54,6 +54,10 @@ }, "./interfaces/*": { "types": "./types/interfaces/*.d.ts" + }, + "./tls": { + "types": "./dist/nodejs/tls.d.ts", + "node": "./dist/nodejs/tls.js" } }, "scripts": { diff --git a/packages/preview2-shim/src/io/calls.ts b/packages/preview2-shim/src/io/calls.ts index fba71242b..dd3d6bbab 100644 --- a/packages/preview2-shim/src/io/calls.ts +++ b/packages/preview2-shim/src/io/calls.ts @@ -130,6 +130,13 @@ export const SOCKET_RESOLVE_ADDRESS_TAKE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_SUBSCRIBE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_DISPOSE_REQUEST = ++call_id << CALL_SHIFT; +// Opt-in wasi:tls provider operations, executed on the existing IO worker. +export const TLS_START = ++call_id << CALL_SHIFT; +export const TLS_CLOSE_OUTPUT = ++call_id << CALL_SHIFT; +export const TLS_DISPOSE = ++call_id << CALL_SHIFT; +export const TLS_STREAMS = ++call_id << CALL_SHIFT; +export const TLS_RESOURCE_COUNTS = ++call_id << CALL_SHIFT; + export const reverseMap = {}; import * as calls from "./calls.js"; diff --git a/packages/preview2-shim/src/io/worker-thread.ts b/packages/preview2-shim/src/io/worker-thread.ts index 1ef97cc9b..2dd666762 100644 --- a/packages/preview2-shim/src/io/worker-thread.ts +++ b/packages/preview2-shim/src/io/worker-thread.ts @@ -1,3 +1,17 @@ +import { + TLS_START, + TLS_CLOSE_OUTPUT, + TLS_DISPOSE, + TLS_STREAMS, + TLS_RESOURCE_COUNTS, +} from "./calls.js"; +import { + tlsStart, + tlsCloseOutput, + tlsDispose, + tlsStreams, + tlsConnectionCount, +} from "./worker-tls.js"; import { createReadStream, createWriteStream, PathLike } from "node:fs"; import { hrtime, stderr, stdout } from "node:process"; import { PassThrough } from "node:stream"; @@ -332,6 +346,22 @@ function handle(call, id, payload) { throw uncaughtException; } switch (call) { + case TLS_START: + return tlsStart(payload); + case TLS_STREAMS: + return tlsStreams(id); + case TLS_RESOURCE_COUNTS: + return { + tls: tlsConnectionCount(), + streams: streams.size, + futures: futures.size, + polls: polls.size, + sockets: tcpSockets.size, + }; + case TLS_CLOSE_OUTPUT: + return tlsCloseOutput(id); + case TLS_DISPOSE: + return tlsDispose(id); // Http case HTTP_CREATE_REQUEST: { const { @@ -958,10 +988,7 @@ function handle(call, id, payload) { return futureTakeValue(id); case FUTURE_SUBSCRIBE: { - const { pollState } = futures.get(id); - const pollId = ++pollCnt; - polls.set(pollId, pollState); - return pollId; + return createPoll(futures.get(id).pollState); } case FUTURE_DISPOSE: return void futureDispose(id, true); diff --git a/packages/preview2-shim/src/io/worker-tls.ts b/packages/preview2-shim/src/io/worker-tls.ts new file mode 100644 index 000000000..878900e23 --- /dev/null +++ b/packages/preview2-shim/src/io/worker-tls.ts @@ -0,0 +1,112 @@ +/** Native TLS over the streams already owned by the Preview 2 IO worker. */ +import { Duplex, Readable, Writable } from "node:stream"; +import { connect, checkServerIdentity, type TLSSocket } from "node:tls"; +import { isIP } from "node:net"; +import { + createFuture, + createReadableStream, + createWritableStream, + getStreamOrThrow, +} from "./worker-thread.js"; + +export interface TlsStartOptions { + serverName: string; + input: number; + output: number; + ca?: string[]; + handshakeTimeoutMs: number; +} +interface Connection { + socket: TLSSocket; + timer: ReturnType; +} +const connections = new Map(); +let nextId = 0; + +export function tlsStart(options: TlsStartOptions): { connection: number; future: number } { + const readable: unknown = getStreamOrThrow(options.input).stream; + const writable: unknown = getStreamOrThrow(options.output).stream; + if (!(readable instanceof Readable) || !(writable instanceof Writable)) { + throw new Error("wasi:tls requires Node-backed readable and writable streams"); + } + // This Duplex consumes precisely the supplied streams. No DNS lookup or replacement + // TCP connection is possible: tls.connect receives an already-connected transport. + const transport = Duplex.from({ readable, writable }); + const socket = connect({ + socket: transport, + servername: isIP(options.serverName) ? undefined : options.serverName, + rejectUnauthorized: true, + checkServerIdentity: (_host, certificate) => + checkServerIdentity(options.serverName, certificate), + ALPNProtocols: ["http/1.1"], + ca: options.ca, + }); + // Duplex.from may emit AbortError when TLS destroys an incomplete transport. + // Keep an error listener for the whole transport lifetime, including shutdown. + transport.on("error", (error: Error): void => { + socket.destroy(error); + }); + const connection = ++nextId; + const timer = setTimeout( + () => socket.destroy(new Error("TLS handshake timed out")), + options.handshakeTimeoutMs, + ); + connections.set(connection, { socket, timer }); + const future = createFuture( + new Promise((resolve, reject) => { + const fail = (error: Error): void => { + clearTimeout(timer); + reject({ + message: error.message, + code: "code" in error ? String(error.code) : "ERR_TLS_HANDSHAKE", + }); + }; + socket.on("error", fail); + socket.once("close", () => fail(new Error("TLS connection closed during handshake"))); + socket.once("secureConnect", () => { + clearTimeout(timer); + if (socket.alpnProtocol && socket.alpnProtocol !== "http/1.1") { + socket.destroy(new Error("TLS peer negotiated a protocol other than HTTP/1.1")); + return; + } + resolve(); + }); + }), + undefined, + ); + return { connection, future }; +} + +export function tlsCloseOutput(id: number): Promise { + const connection = connections.get(id); + if (!connection) { + throw new Error("wasi:tls connection was disposed"); + } + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error); + connection.socket.once("error", onError); + connection.socket.end(() => { + connection.socket.off("error", onError); + resolve(); + }); + }); +} + +export function tlsDispose(id: number): void { + const connection = connections.get(id); + if (!connection) { + return; + } + clearTimeout(connection.timer); + connection.socket.destroy(); + connections.delete(id); +} + +export function tlsStreams(id: number): [number, number] { + const socket = connections.get(id)!.socket; + return [createReadableStream(socket), createWritableStream(socket)]; +} + +export function tlsConnectionCount(): number { + return connections.size; +} diff --git a/packages/preview2-shim/src/nodejs/tls.ts b/packages/preview2-shim/src/nodejs/tls.ts new file mode 100644 index 000000000..d4cd65306 --- /dev/null +++ b/packages/preview2-shim/src/nodejs/tls.ts @@ -0,0 +1,216 @@ +/** + * Opt-in host implementation of WebAssembly/wasi-tls wit/types.wit at + * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). + * The upstream interface is unchanged; host trust and deadlines are local policy. + */ +import { + ioCall, + inputStreamId, + outputStreamId, + inputStreamCreate, + outputStreamCreate, + pollableCreate, + error, +} from "../io/worker-io.js"; +import { + TLS_START, + TLS_STREAMS, + TLS_CLOSE_OUTPUT, + TLS_DISPOSE, + TLS_RESOURCE_COUNTS, + SOCKET_TCP, + FUTURE_TAKE_VALUE, + FUTURE_SUBSCRIBE, + FUTURE_DISPOSE, +} from "../io/calls.js"; +import type { InputStream, OutputStream } from "../../types/interfaces/wasi-io-streams.js"; +import type { Pollable } from "../../types/interfaces/wasi-io-poll.js"; + +export interface TlsHostOptions { + ca?: string[]; + handshakeTimeoutMs?: number; +} +export interface IoError { + toDebugString(): string; + [Symbol.dispose]?(): void; +} +export type ClientStreamsResult = + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "err"; val: IoError } + | { tag: "ok"; val: [ClientConnection, InputStream, OutputStream] }; + }; +interface OwnedTransport { + input: InputStream; + output: OutputStream; +} +function disposeStream(stream: InputStream | OutputStream): void { + const drop: unknown = Symbol.dispose in stream ? stream[Symbol.dispose] : undefined; + if (typeof drop !== "function") { + throw new TypeError("wasi:tls requires disposable IO resources"); + } + drop.call(stream); +} + +export class ClientConnection { + readonly #id: number; + readonly #transport: OwnedTransport; + #disposed = false; + constructor(id: number, transport: OwnedTransport) { + this.#id = id; + this.#transport = transport; + } + closeOutput(): void { + ioCall(TLS_CLOSE_OUTPUT, this.#id, undefined); + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + ioCall(TLS_DISPOSE, this.#id, undefined); + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } +} + +export class FutureClientStreams { + readonly #id: number; + readonly #connectionId: number; + readonly #connection: ClientConnection; + #taken = false; + #disposed = false; + constructor(id: number, connectionId: number, transport: OwnedTransport) { + this.#id = id; + this.#connectionId = connectionId; + this.#connection = new ClientConnection(connectionId, transport); + } + subscribe(): Pollable { + return pollableCreate(ioCall(FUTURE_SUBSCRIBE, this.#id, undefined), this); + } + get(): ClientStreamsResult | undefined { + const value: + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "ok"; val: undefined } + | { tag: "err"; val: { message: string; code: string } }; + } + | undefined = ioCall(FUTURE_TAKE_VALUE, this.#id, undefined); + if (!value) { + return undefined; + } + if (value.tag === "err") { + return { tag: "err", val: undefined }; + } + if (value.val.tag === "err") { + return { tag: "ok", val: { tag: "err", val: new error.Error(value.val.val.message) } }; + } + const [input, output]: [number, number] = ioCall( + TLS_STREAMS, + this.#connectionId, + undefined, + ); + this.#taken = true; + return { + tag: "ok", + val: { + tag: "ok", + val: [ + this.#connection, + inputStreamCreate(SOCKET_TCP, input), + outputStreamCreate(SOCKET_TCP, output), + ], + }, + }; + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + ioCall(FUTURE_DISPOSE, this.#id, undefined); + this.#disposed = true; + if (!this.#taken) { + this.#connection[Symbol.dispose](); + } + } +} + +export interface ClientHandshakeResource { + [Symbol.dispose](): void; +} +export interface TlsProvider { + ClientHandshake: { + new (serverName: string, input: InputStream, output: OutputStream): ClientHandshakeResource; + finish(handshake: ClientHandshakeResource): FutureClientStreams; + }; + ClientConnection: typeof ClientConnection; + FutureClientStreams: typeof FutureClientStreams; +} + +export function createTlsProvider(options: TlsHostOptions = {}): TlsProvider { + const ca = options.ca?.slice(); + const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; + if (!Number.isSafeInteger(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) { + throw new RangeError("handshakeTimeoutMs must be a positive safe integer"); + } + class ClientHandshake implements ClientHandshakeResource { + #transport: OwnedTransport | undefined; + readonly #serverName: string; + constructor(serverName: string, input: InputStream, output: OutputStream) { + this.#serverName = serverName; + this.#transport = { input, output }; + } + static finish(value: ClientHandshakeResource): FutureClientStreams { + if (!(value instanceof ClientHandshake) || !value.#transport) { + throw new Error( + "wasi:tls handshake already consumed or belongs to another provider", + ); + } + const transport = value.#transport; + const result: { connection: number; future: number } = ioCall(TLS_START, null, { + serverName: value.#serverName, + input: inputStreamId(transport.input), + output: outputStreamId(transport.output), + ca, + handshakeTimeoutMs, + }); + value.#transport = undefined; + return new FutureClientStreams(result.future, result.connection, transport); + } + [Symbol.dispose](): void { + if (this.#transport) { + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } + this.#transport = undefined; + } + } + return { ClientHandshake, ClientConnection, FutureClientStreams }; +} + +export const { ClientHandshake } = createTlsProvider(); + +/** Own-to-own version conversion: preserve the actual stream resources and connection. */ +export function adapt(input: InputStream, output: OutputStream): [InputStream, OutputStream] { + return [input, output]; +} + +/** Host diagnostics for detecting owned IO resource leaks; not a WIT operation. */ +export function _resourceCounts(): { + tls: number; + streams: number; + futures: number; + polls: number; + sockets: number; +} { + return ioCall(TLS_RESOURCE_COUNTS, null, undefined); +} + +/** Jco IO version bridge capability check; not a wasi:tls operation. */ +export function isAvailable(): boolean { + return true; +} From 55eecb4cc975c714ebbbcb7116ca5512e3c4c460 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:24:02 +0000 Subject: [PATCH 11/68] test(p2-shim): cover TLS ownership and DNS address records --- .../test/fixtures/tls/lifecycle.ts | 56 +++++++++++++++++++ .../preview2-shim/test/socket-addresses.ts | 11 +++- packages/preview2-shim/test/tls.ts | 46 +++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/preview2-shim/test/fixtures/tls/lifecycle.ts create mode 100644 packages/preview2-shim/test/tls.ts diff --git a/packages/preview2-shim/test/fixtures/tls/lifecycle.ts b/packages/preview2-shim/test/fixtures/tls/lifecycle.ts new file mode 100644 index 000000000..23626060a --- /dev/null +++ b/packages/preview2-shim/test/fixtures/tls/lifecycle.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { tcpCreateSocket, instanceNetwork } from "../../../dist/nodejs/sockets.js"; +import { createTlsProvider, _resourceCounts } from "../../../dist/nodejs/tls.js"; + +function dispose(resource: object): void { + assert(Symbol.dispose in resource); + const drop: unknown = resource[Symbol.dispose]; + assert(typeof drop === "function"); + drop.call(resource); +} +const mode = process.argv[3]; +const ca = await readFile(new URL("./localhost.crt", import.meta.url), "utf8"); +const provider = createTlsProvider({ ca: [ca], handshakeTimeoutMs: 1000 }); +const before = _resourceCounts(); +const socket = tcpCreateSocket.createTcpSocket("ipv4"); +socket.startConnect(instanceNetwork.instanceNetwork(), { + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: Number(process.argv[2]) }, +}); +const poll = socket.subscribe(); +poll.block(); +dispose(poll); +const [input, output] = socket.finishConnect(); +const handshake = new provider.ClientHandshake("localhost", input, output); +if (mode === "unstarted") { + handshake[Symbol.dispose](); +} else { + const future = provider.ClientHandshake.finish(handshake); + assert.throws(() => provider.ClientHandshake.finish(handshake), /consumed/); + handshake[Symbol.dispose](); // consuming finish transfers ownership out of it + const ready = future.subscribe(); + assert.throws(() => future[Symbol.dispose](), /child poll/); + if (mode === "pending") { + assert.equal(future.get(), undefined); + dispose(ready); + future[Symbol.dispose](); + } else { + ready.block(); + dispose(ready); + const result = future.get(); + assert.equal(result?.tag, "ok"); + assert(result?.tag === "ok" && result.val.tag === "ok"); + assert.deepEqual(future.get(), { tag: "err", val: undefined }); + const [connection, plaintextInput, plaintextOutput] = result.val.val; + future[Symbol.dispose](); + connection.closeOutput(); + dispose(plaintextOutput); + dispose(plaintextInput); + connection[Symbol.dispose](); + connection[Symbol.dispose](); + } +} +dispose(socket); +assert.deepEqual(_resourceCounts(), before); +console.log("clean"); diff --git a/packages/preview2-shim/test/socket-addresses.ts b/packages/preview2-shim/test/socket-addresses.ts index 5636b56e5..f4a907a3c 100644 --- a/packages/preview2-shim/test/socket-addresses.ts +++ b/packages/preview2-shim/test/socket-addresses.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { suite, test } from "vitest"; -import { ipSocketAddress } from "../src/io/worker-sockets.js"; +import { ipSocketAddress, socketResolveAddress } from "../src/io/worker-sockets.js"; import { checkTcpAddresses, checkUdpAddresses } from "./fixtures/sockets/address-families.mjs"; suite("socket address families", () => { @@ -30,3 +30,12 @@ suite("socket address families", () => { test.each(["ipv4", "ipv6"])("TCP worker addresses (%s)", checkTcpAddresses); test.each(["ipv4", "ipv6"])("UDP worker addresses (%s)", checkUdpAddresses); }); + +test.concurrent("DNS lookup returns individual WASI address records", async (): Promise => { + const addresses = await socketResolveAddress("localhost"); + assert(addresses.length > 0); + for (const address of addresses) { + assert(address.tag === "ipv4" || address.tag === "ipv6"); + assert.equal(address.val.length, address.tag === "ipv4" ? 4 : 8); + } +}); diff --git a/packages/preview2-shim/test/tls.ts b/packages/preview2-shim/test/tls.ts new file mode 100644 index 000000000..c46186525 --- /dev/null +++ b/packages/preview2-shim/test/tls.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createServer as tcpServer, type Socket } from "node:net"; +import { createServer as tlsServer } from "node:tls"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); +const fixture = new URL("./fixtures/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", fixture)); +const key = await readFile(new URL("localhost.key", fixture)); +test.concurrent.each(["unstarted", "pending", "completed"])( + "TLS resource ownership: %s", + async (mode: string): Promise => { + const peers = new Set(); + const server = mode === "completed" ? tlsServer({ cert, key }) : tcpServer(); + server.on("connection", (socket: Socket): void => { + peers.add(socket); + socket.once("close", (): void => { + peers.delete(socket); + }); + }); + server.on("tlsClientError", (): void => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP address"); + } + const result = await exec( + process.execPath, + [fileURLToPath(new URL("lifecycle.ts", fixture)), String(address.port), mode], + { timeout: 5000 }, + ); + expect(result.stdout.trim()).toBe("clean"); + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, +); From d0468937175b79cda03f1fbf1ef1cb2e0c7a26d3 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:25:22 +0000 Subject: [PATCH 12/68] feat(std): support HTTPS over wasi:tls --- packages/jco-std/package.json | 12 +- .../jco-std/scripts/generate-tls-bindings.mjs | 17 + .../types/tls/interfaces/wasi-io-error.d.ts | 20 ++ .../types/tls/interfaces/wasi-io-poll.d.ts | 46 +++ .../types/tls/interfaces/wasi-io-streams.d.ts | 247 +++++++++++++++ .../types/tls/interfaces/wasi-tls-types.d.ts | 35 +++ .../0.2.6/generated/types/tls/tls-0.2.d.ts | 10 + .../node/24.x.x/http/impl/wasi-sockets.ts | 78 ++++- .../0.2.x/node/24.x.x/http/impl/wasi-tls.ts | 127 ++++++++ .../src/wasi/0.2.x/node/24.x.x/http/tls.ts | 6 + .../src/wasi/0.2.x/node/24.x.x/tls-host.ts | 37 +++ .../jco-std/wit/tls-0.2.0-draft/LICENSE.md | 8 + .../jco-std/wit/tls-0.2.0-draft/PROVENANCE.md | 7 + .../jco-std/wit/tls-0.2.0-draft/deps.lock | 4 + .../jco-std/wit/tls-0.2.0-draft/deps.toml | 1 + .../wit/tls-0.2.0-draft/deps/io/error.wit | 34 ++ .../wit/tls-0.2.0-draft/deps/io/poll.wit | 47 +++ .../wit/tls-0.2.0-draft/deps/io/streams.wit | 290 ++++++++++++++++++ .../wit/tls-0.2.0-draft/deps/io/world.wit | 10 + .../jco-std/wit/tls-0.2.0-draft/types.wit | 33 ++ .../jco-std/wit/tls-0.2.0-draft/world.wit | 7 + 21 files changed, 1057 insertions(+), 19 deletions(-) create mode 100644 packages/jco-std/scripts/generate-tls-bindings.mjs create mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps.lock create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps.toml create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/types.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/world.wit diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 378d917b0..52f6077db 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -24,7 +24,8 @@ }, "files": [ "dist", - "wit/node-0.1.0" + "wit/node-0.1.0", + "wit/tls-0.2.0-draft" ], "type": "module", "exports": { @@ -356,6 +357,10 @@ "types": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.d.ts", "browser": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.js", "default": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls-host.d.ts", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls-host.js" } }, "scripts": { @@ -367,11 +372,12 @@ "build:bindings:wasi:http:0.2.6": "WIT_PATH=wit/http-v0m2p6 OUTPUT_DIR_PATH=src/wasi/0.2.6/generated/types node scripts/generate-wasi-bindings.mjs", "build:bindings:wasi:http:0.2.12": "WIT_PATH=wit/http-v0m2p12 OUTPUT_DIR_PATH=src/wasi/0.2.12/generated/types node scripts/generate-wasi-bindings.mjs", "build:bindings:wasi:http:0.2.3": "WIT_PATH=wit/http-v0m2p3 OUTPUT_DIR_PATH=src/wasi/0.2.3/generated/types node scripts/generate-wasi-bindings.mjs", - "build:bindings": "pnpm run build:bindings:wasi:http:0.2.3 && pnpm run build:bindings:wasi:http:0.2.6 && pnpm run build:bindings:wasi:http:0.2.12", + "build:bindings": "pnpm run build:bindings:wasi:http:0.2.3 && pnpm run build:bindings:wasi:http:0.2.6 && pnpm run build:bindings:wasi:http:0.2.12 && pnpm run build:bindings:wasi:tls", "build:ts": "tsc", "build": "pnpm run setup:jco-transpile:build && pnpm run build:bindings && pnpm run build:ts", "test": "vitest run -c test/vitest.ts", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "build:bindings:wasi:tls": "node scripts/generate-tls-bindings.mjs" }, "dependencies": { "minimatch": "10.2.6" diff --git a/packages/jco-std/scripts/generate-tls-bindings.mjs b/packages/jco-std/scripts/generate-tls-bindings.mjs new file mode 100644 index 000000000..5b9809fdf --- /dev/null +++ b/packages/jco-std/scripts/generate-tls-bindings.mjs @@ -0,0 +1,17 @@ +// Resolve the unmodified upstream draft with its explicit `tls` feature enabled. +import { generateGuestTypes, writeFiles } from "@bytecodealliance/jco-transpile"; +const files = await generateGuestTypes("wit/tls-0.2.0-draft", { + features: ["tls"], + outDir: "src/wasi/0.2.6/generated/types/tls", +}); +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); +for (const [path, contents] of Object.entries(files)) { + files[path] = encoder.encode( + decoder + .decode(contents) + .replace(/[ \t]+$/gm, "") + .trimEnd() + "\n", + ); +} +await writeFiles(files); diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts new file mode 100644 index 000000000..577436f7c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts @@ -0,0 +1,20 @@ +declare module 'wasi:io/error@0.2.6' { + + export class Error implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + /** + * Returns a string that is suitable to assist humans in debugging + * this error. + * + * WARNING: The returned string should not be consumed mechanically! + * It may change across platforms, hosts, or other implementation + * details. Parsing this string is a major platform-compatibility + * hazard. + */ + toDebugString(): string; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts new file mode 100644 index 000000000..10169ff96 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts @@ -0,0 +1,46 @@ +declare module 'wasi:io/poll@0.2.6' { + /** + * Poll for completion on a set of pollables. + * + * This function takes a list of pollables, which identify I/O sources of + * interest, and waits until one or more of the events is ready for I/O. + * + * The result `list` contains one or more indices of handles in the + * argument list that is ready for I/O. + * + * This function traps if either: + * - the list is empty, or: + * - the list contains more elements than can be indexed with a `u32` value. + * + * A timeout can be implemented by adding a pollable from the + * wasi-clocks API to the list. + * + * This function does not return a `result`; polling in itself does not + * do any I/O so it doesn't fail. If any of the I/O sources identified by + * the pollables has an error, it is indicated by marking the source as + * being ready for I/O. + */ + export function poll(in_: Array): Uint32Array; + + export class Pollable implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + /** + * Return the readiness of a pollable. This function never blocks. + * + * Returns `true` when the pollable is ready, and `false` otherwise. + */ + ready(): boolean; + /** + * `block` returns immediately if the pollable is ready, and otherwise + * blocks until ready. + * + * This function is equivalent to calling `poll.poll` on a list + * containing only this pollable. + */ + block(): void; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts new file mode 100644 index 000000000..dd3499530 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts @@ -0,0 +1,247 @@ +/// +/// +declare module 'wasi:io/streams@0.2.6' { + export type Error = import('wasi:io/error@0.2.6').Error; + export type Pollable = import('wasi:io/poll@0.2.6').Pollable; + /** + * An error for input-stream and output-stream operations. + */ + export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; + /** + * The last operation (a write or flush) failed before completion. + * + * More information is available in the `error` payload. + * + * After this, the stream will be closed. All future operations return + * `stream-error::closed`. + */ + export interface StreamErrorLastOperationFailed { + tag: 'last-operation-failed', + val: Error, + } + /** + * The stream is closed: no more input will be accepted by the + * stream. A closed output-stream will return this error on all + * future operations. + */ + export interface StreamErrorClosed { + tag: 'closed', + } + + export class InputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + /** + * Perform a non-blocking read from the stream. + * + * When the source of a `read` is binary data, the bytes from the source + * are returned verbatim. When the source of a `read` is known to the + * implementation to be text, bytes containing the UTF-8 encoding of the + * text are returned. + * + * This function returns a list of bytes containing the read data, + * when successful. The returned list will contain up to `len` bytes; + * it may return fewer than requested, but not more. The list is + * empty when no bytes are available for reading at this time. The + * pollable given by `subscribe` will be ready when more bytes are + * available. + * + * This function fails with a `stream-error` when the operation + * encounters an error, giving `last-operation-failed`, or when the + * stream is closed, giving `closed`. + * + * When the caller gives a `len` of 0, it represents a request to + * read 0 bytes. If the stream is still open, this call should + * succeed and return an empty list, or otherwise fail with `closed`. + * + * The `len` parameter is a `u64`, which could represent a list of u8 which + * is not possible to allocate in wasm32, or not desirable to allocate as + * as a return value by the callee. The callee may return a list of bytes + * less than `len` in size while more bytes are available for reading. + */ + read(len: bigint): Uint8Array; + /** + * Read bytes from a stream, after blocking until at least one byte can + * be read. Except for blocking, behavior is identical to `read`. + */ + blockingRead(len: bigint): Uint8Array; + /** + * Skip bytes from a stream. Returns number of bytes skipped. + * + * Behaves identical to `read`, except instead of returning a list + * of bytes, returns the number of bytes consumed from the stream. + */ + skip(len: bigint): bigint; + /** + * Skip bytes from a stream, after blocking until at least one byte + * can be skipped. Except for blocking behavior, identical to `skip`. + */ + blockingSkip(len: bigint): bigint; + /** + * Create a `pollable` which will resolve once either the specified stream + * has bytes available to read or the other end of the stream has been + * closed. + * The created `pollable` is a child resource of the `input-stream`. + * Implementations may trap if the `input-stream` is dropped before + * all derived `pollable`s created with this function are dropped. + */ + subscribe(): Pollable; + [Symbol.dispose](): void; + } + + export class OutputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + /** + * Check readiness for writing. This function never blocks. + * + * Returns the number of bytes permitted for the next call to `write`, + * or an error. Calling `write` with more bytes than this function has + * permitted will trap. + * + * When this function returns 0 bytes, the `subscribe` pollable will + * become ready when this function will report at least 1 byte, or an + * error. + */ + checkWrite(): bigint; + /** + * Perform a write. This function never blocks. + * + * When the destination of a `write` is binary data, the bytes from + * `contents` are written verbatim. When the destination of a `write` is + * known to the implementation to be text, the bytes of `contents` are + * transcoded from UTF-8 into the encoding of the destination and then + * written. + * + * Precondition: check-write gave permit of Ok(n) and contents has a + * length of less than or equal to n. Otherwise, this function will trap. + * + * returns Err(closed) without writing if the stream has closed since + * the last call to check-write provided a permit. + */ + write(contents: Uint8Array): void; + /** + * Perform a write of up to 4096 bytes, and then flush the stream. Block + * until all of these operations are complete, or an error occurs. + * + * This is a convenience wrapper around the use of `check-write`, + * `subscribe`, `write`, and `flush`, and is implemented with the + * following pseudo-code: + * + * ```text + * let pollable = this.subscribe(); + * while !contents.is_empty() { + * // Wait for the stream to become writable + * pollable.block(); + * let Ok(n) = this.check-write(); // eliding error handling + * let len = min(n, contents.len()); + * let (chunk, rest) = contents.split_at(len); + * this.write(chunk ); // eliding error handling + * contents = rest; + * } + * this.flush(); + * // Wait for completion of `flush` + * pollable.block(); + * // Check for any errors that arose during `flush` + * let _ = this.check-write(); // eliding error handling + * ``` + */ + blockingWriteAndFlush(contents: Uint8Array): void; + /** + * Request to flush buffered output. This function never blocks. + * + * This tells the output-stream that the caller intends any buffered + * output to be flushed. the output which is expected to be flushed + * is all that has been passed to `write` prior to this call. + * + * Upon calling this function, the `output-stream` will not accept any + * writes (`check-write` will return `ok(0)`) until the flush has + * completed. The `subscribe` pollable will become ready when the + * flush has completed and the stream can accept more writes. + */ + flush(): void; + /** + * Request to flush buffered output, and block until flush completes + * and stream is ready for writing again. + */ + blockingFlush(): void; + /** + * Create a `pollable` which will resolve once the output-stream + * is ready for more writing, or an error has occurred. When this + * pollable is ready, `check-write` will return `ok(n)` with n>0, or an + * error. + * + * If the stream is closed, this pollable is always ready immediately. + * + * The created `pollable` is a child resource of the `output-stream`. + * Implementations may trap if the `output-stream` is dropped before + * all derived `pollable`s created with this function are dropped. + */ + subscribe(): Pollable; + /** + * Write zeroes to a stream. + * + * This should be used precisely like `write` with the exact same + * preconditions (must use check-write first), but instead of + * passing a list of bytes, you simply pass the number of zero-bytes + * that should be written. + */ + writeZeroes(len: bigint): void; + /** + * Perform a write of up to 4096 zeroes, and then flush the stream. + * Block until all of these operations are complete, or an error + * occurs. + * + * This is a convenience wrapper around the use of `check-write`, + * `subscribe`, `write-zeroes`, and `flush`, and is implemented with + * the following pseudo-code: + * + * ```text + * let pollable = this.subscribe(); + * while num_zeroes != 0 { + * // Wait for the stream to become writable + * pollable.block(); + * let Ok(n) = this.check-write(); // eliding error handling + * let len = min(n, num_zeroes); + * this.write-zeroes(len); // eliding error handling + * num_zeroes -= len; + * } + * this.flush(); + * // Wait for completion of `flush` + * pollable.block(); + * // Check for any errors that arose during `flush` + * let _ = this.check-write(); // eliding error handling + * ``` + */ + blockingWriteZeroesAndFlush(len: bigint): void; + /** + * Read from one stream and write to another. + * + * The behavior of splice is equivalent to: + * 1. calling `check-write` on the `output-stream` + * 2. calling `read` on the `input-stream` with the smaller of the + * `check-write` permitted length and the `len` provided to `splice` + * 3. calling `write` on the `output-stream` with that read data. + * + * Any error reported by the call to `check-write`, `read`, or + * `write` ends the splice and reports that error. + * + * This function returns the number of bytes transferred; it may be less + * than `len`. + */ + splice(src: InputStream, len: bigint): bigint; + /** + * Read from one stream and write to another, with blocking. + * + * This is similar to `splice`, except that it blocks until the + * `output-stream` is ready for writing, and the `input-stream` + * is ready for reading, before performing the `splice`. + */ + blockingSplice(src: InputStream, len: bigint): bigint; + [Symbol.dispose](): void; + } + } diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts new file mode 100644 index 000000000..04f9fea2e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts @@ -0,0 +1,35 @@ +/// +/// +/// +declare module 'wasi:tls/types@0.2.0-draft' { + export type InputStream = import('wasi:io/streams@0.2.6').InputStream; + export type OutputStream = import('wasi:io/streams@0.2.6').OutputStream; + export type Pollable = import('wasi:io/poll@0.2.6').Pollable; + export type IoError = import('wasi:io/error@0.2.6').Error; + export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; + + export class ClientConnection implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + closeOutput(): void; + [Symbol.dispose](): void; + } + + export class ClientHandshake implements Disposable { + constructor(serverName: string, input: InputStream, output: OutputStream) + static finish(this_: ClientHandshake): FutureClientStreams; + [Symbol.dispose](): void; + } + + export class FutureClientStreams implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + subscribe(): Pollable; + get(): Result, void> | undefined; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts new file mode 100644 index 000000000..3a8f9a6c1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts @@ -0,0 +1,10 @@ +/// +/// +/// +/// +declare module 'wasi:tls/imports@0.2.0-draft' { + export type * as WasiIoError026 from 'wasi:io/error@0.2.6'; // import wasi:io/error@0.2.6 + export type * as WasiIoPoll026 from 'wasi:io/poll@0.2.6'; // import wasi:io/poll@0.2.6 + export type * as WasiIoStreams026 from 'wasi:io/streams@0.2.6'; // import wasi:io/streams@0.2.6 + export type * as WasiTlsTypes020Draft from 'wasi:tls/types@0.2.0-draft'; // import wasi:tls/types@0.2.0-draft +} 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 46ac41e5e..45295d460 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,3 +1,10 @@ +import { + handshake, + validateTlsOptions, + type WasiTlsProvider, + type WasiTlsStreamBridge, + type WasiTlsConnection, +} from "./wasi-tls.js"; import { concatBytes } from "../body.js"; import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../errors.js"; import { @@ -76,6 +83,8 @@ export interface WasiNetwork { } export interface WasiSocketsProvider { + tls?: WasiTlsProvider; + tlsStreamBridge?: WasiTlsStreamBridge; instanceNetwork: { instanceNetwork(): WasiNetwork; }; @@ -178,10 +187,13 @@ export function nodeAddress(address: WasiIpSocketAddress): Exclude> = { clientCertEngine: ENGINE, privateKeyEngine: ENGINE, privateKeyIdentifier: ENGINE, + allowPartialTrustChain: "partial-chain trust policy is not carried by the TLS boundary", + enableTrace: "native TLS tracing is not exposed by the TLS boundary", + requestOCSP: "OCSP negotiation is not exposed by the TLS boundary", + minDHSize: "the TLS boundary cannot configure a minimum Diffie-Hellman key size", + handshakeTimeout: "handshake deadlines are host policy at the TLS boundary", + sessionTimeout: "session lifetime is host policy at the TLS boundary", }; const encoder = new TextEncoder(); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts new file mode 100644 index 000000000..0130bd497 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts @@ -0,0 +1,37 @@ +/** Deny-by-default TLS capability; importing this module performs no IO. */ +function denied(): never { + throw Object.assign( + new Error( + "HTTPS over wasi:sockets requires an explicitly configured wasi:tls/types@0.2.0-draft host provider and IO version bridge", + ), + { code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }, + ); +} +export class ClientHandshake { + constructor(_serverName: string, _input: unknown, _output: unknown) { + denied(); + } + static finish(_handshake: ClientHandshake): never { + return denied(); + } +} +export class ClientConnection { + closeOutput(): never { + return denied(); + } +} +export class FutureClientStreams { + get(): never { + return denied(); + } + subscribe(): never { + return denied(); + } +} +/** Jco bridge operation, not part of the upstream wasi:tls interface. */ +export function isAvailable(): boolean { + return false; +} +export function adapt(_input: unknown, _output: unknown): never { + return denied(); +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md b/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md new file mode 100644 index 000000000..475309577 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md @@ -0,0 +1,8 @@ +Copyright © 2019-2023 the Contributors to the WASI Specification, published +by the [WebAssembly Community Group][cg] under the +[W3C Community Contributor License Agreement (CLA)][cla]. A human-readable +[summary][summary] is available. + +[cg]: https://www.w3.org/community/webassembly/ +[cla]: https://www.w3.org/community/about/agreements/cla/ +[summary]: https://www.w3.org/community/about/agreements/cla-deed/ diff --git a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md new file mode 100644 index 000000000..e98c57fb1 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md @@ -0,0 +1,7 @@ +Vendored unchanged from WebAssembly/wasi-tls, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. +Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. +The upstream dependency archive checksums are in `deps.lock`. +License: W3C Community Contributor License Agreement; see LICENSE.md. +The `tls` unstable WIT feature must be enabled. Client-only: no server, +trust configuration, verification bypass, cipher or ALPN configuration. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps.lock b/packages/jco-std/wit/tls-0.2.0-draft/deps.lock new file mode 100644 index 000000000..5384c4070 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps.lock @@ -0,0 +1,4 @@ +[io] +url = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" +sha256 = "671761f464d312e6c26bcaab5e79fe14ac876b72267867579d5c65e053fe2301" +sha512 = "57e5ed34fa85f35899b324ac7a2473c5fa5cece51d07e6f077637191fadd3c8b6f79324d31a8d497a6ce7b92cfb2a2505ab894337e2c82889f1bdb21f4f24634" diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps.toml b/packages/jco-std/wit/tls-0.2.0-draft/deps.toml new file mode 100644 index 000000000..b178cb257 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps.toml @@ -0,0 +1 @@ +io = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit new file mode 100644 index 000000000..784f74a53 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit @@ -0,0 +1,34 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit new file mode 100644 index 000000000..7f711836c --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit @@ -0,0 +1,47 @@ +package wasi:io@0.2.6; + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit new file mode 100644 index 000000000..c5da38c86 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit @@ -0,0 +1,290 @@ +package wasi:io@0.2.6; + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func( + contents: list + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + } +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit new file mode 100644 index 000000000..84c85c08e --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit @@ -0,0 +1,10 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import streams; + + @since(version = 0.2.0) + import poll; +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/types.wit b/packages/jco-std/wit/tls-0.2.0-draft/types.wit new file mode 100644 index 000000000..f6b69b3f0 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/types.wit @@ -0,0 +1,33 @@ +@unstable(feature = tls) +interface types { + @unstable(feature = tls) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @unstable(feature = tls) + use wasi:io/poll@0.2.6.{pollable}; + @unstable(feature = tls) + use wasi:io/error@0.2.6.{error as io-error}; + + @unstable(feature = tls) + resource client-handshake { + @unstable(feature = tls) + constructor(server-name: string, input: input-stream, output: output-stream); + + @unstable(feature = tls) + finish: static func(this: client-handshake) -> future-client-streams; + } + + @unstable(feature = tls) + resource client-connection { + @unstable(feature = tls) + close-output: func(); + } + + @unstable(feature = tls) + resource future-client-streams { + @unstable(feature = tls) + subscribe: func() -> pollable; + + @unstable(feature = tls) + get: func() -> option, io-error>>>; + } +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/world.wit b/packages/jco-std/wit/tls-0.2.0-draft/world.wit new file mode 100644 index 000000000..8efd9593d --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/world.wit @@ -0,0 +1,7 @@ +package wasi:tls@0.2.0-draft; + +@unstable(feature = tls) +world imports { + @unstable(feature = tls) + import types; +} From 655de36285b65af631e18eb030fc7d7c374a543b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:25:38 +0000 Subject: [PATCH 13/68] test(std): cover TLS capability and handshake semantics --- .../0.2.x/node/24.x.x/https/wasi-sockets.ts | 4 +- .../wasi/0.2.x/node/24.x.x/https/wasi-tls.ts | 188 ++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts index 34ac89664..0ac6746f3 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts @@ -28,13 +28,13 @@ function untouchedProvider(): WasiSocketsProvider { } describe("node:https wasi:sockets implementation", () => { - test.concurrent("refuses client requests before touching the network", async () => { + test.concurrent("reports missing TLS capability before touching the network", async () => { const https = createHttps(createWasiSocketsHttpImplementation(untouchedProvider())); const request = https.request("https://example.com/"); const error = new Promise((resolve) => request.once("error", resolve)); request.end(); await expect(error).resolves.toMatchObject({ - code: "ERR_JCO_UNSUPPORTED_NODE_API", + code: "ERR_JCO_TLS_ADAPTER_REQUIRED", message: expect.stringMatching(/https: requests with the wasi-sockets implementation.*TLS/), }); }); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts new file mode 100644 index 000000000..c4d7b46d1 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts @@ -0,0 +1,188 @@ +import { tlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/tls.js"; +import { expect, test } from "vitest"; +import { + authority, + errorCode, + createWasiSocketsHttpImplementation, + type WasiInputStream, + type WasiOutputStream, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import { + handshake, + validateTlsOptions, + type WasiTlsProvider, + type WasiTlsResult, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.js"; +import type { HttpTlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; +import * as denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls-host.js"; + +test.concurrent("HTTPS authority defaults to 443 and preserves explicit ports", () => { + expect(authority("example.com", "https")).toEqual({ hostname: "example.com", port: 443 }); + expect(authority("example.com:8443", "https")).toEqual({ hostname: "example.com", port: 8443 }); + expect(authority("example.com")).toEqual({ hostname: "example.com", port: 80 }); +}); + +test.concurrent("recognizes ComponentError payloads for socket polling and stream closure", () => { + expect(errorCode(Object.assign(new Error("would-block"), { payload: "would-block" }))).toBe( + "would-block", + ); + expect(errorCode(Object.assign(new Error("closed"), { payload: { tag: "closed" } }))).toBe( + "closed", + ); + expect(errorCode(new Error("would-block"))).toBeUndefined(); +}); + +const options: HttpTlsMaterial[] = [ + { ca: [] }, + { key: [] }, + { cert: [] }, + { pfx: [] }, + { passphrase: "x" }, + { crl: [] }, + { dhparam: new Uint8Array() }, + { ciphers: "x" }, + { ecdhCurve: "x" }, + { sigalgs: "x" }, + { minVersion: "TLSv1.2" }, + { maxVersion: "TLSv1.3" }, + { secureProtocol: "x" }, + { secureOptions: 0 }, + { sessionIdContext: "x" }, + { honorCipherOrder: true }, + { alpnProtocols: ["http/1.1"] }, + { rejectUnauthorized: false }, + { requestCert: false }, + { servername: "" }, +]; +test.concurrent.each(options)( + "rejects an unexpressible TLS option %j", + (option: HttpTlsMaterial) => { + expect(() => validateTlsOptions(option)).toThrow(/wasi:tls/); + }, +); +test.concurrent("accepts servername and explicitly enabled verification", () => { + expect(() => + validateTlsOptions({ servername: "localhost", rejectUnauthorized: true }), + ).not.toThrow(); +}); + +test.concurrent("polls a pending handshake and drops the poll before its future", () => { + const events: string[] = []; + let ready = false; + const input = { blockingRead: (): Uint8Array => new Uint8Array() }; + const output = { blockingWriteAndFlush: (): void => {} }; + const connection = { closeOutput: (): void => {} }; + const provider: WasiTlsProvider = { + ClientHandshake: class { + constructor(name: string, incoming: WasiInputStream, outgoing: WasiOutputStream) { + expect(name).toBe("localhost"); + expect(incoming).toBe(input); + expect(outgoing).toBe(output); + } + static finish(): ReturnType { + return { + get: (): WasiTlsResult | undefined => + ready ? { tag: "ok", val: { tag: "ok", val: [connection, input, output] } } : undefined, + subscribe: () => ({ + block: (): void => { + ready = true; + }, + [Symbol.dispose]: (): void => { + events.push("poll"); + }, + }), + [Symbol.dispose]: (): void => { + events.push("future"); + }, + }; + } + }, + }; + expect(handshake(provider, undefined, "localhost", input, output)).toEqual([ + connection, + input, + output, + ]); + expect(events).toEqual(["poll", "future"]); +}); + +test.concurrent("drops a failed handshake's IO error and future", () => { + const events: string[] = []; + const input = { blockingRead: (): Uint8Array => new Uint8Array() }; + const output = { blockingWriteAndFlush: (): void => {} }; + const provider: WasiTlsProvider = { + ClientHandshake: class { + static finish(): ReturnType { + return { + get: (): WasiTlsResult => ({ + tag: "ok", + val: { + tag: "err", + val: { + toDebugString: (): string => "untrusted certificate", + [Symbol.dispose]: (): void => { + events.push("error"); + }, + }, + }, + }), + subscribe: (): never => { + throw new Error("unexpected poll"); + }, + [Symbol.dispose]: (): void => { + events.push("future"); + }, + }; + } + }, + }; + expect(() => handshake(provider, undefined, "localhost", input, output)).toThrow( + /untrusted certificate/, + ); + expect(events).toEqual(["error", "future"]); +}); + +test.concurrent("default denial is lazy and refuses before acquiring TCP resources", () => { + const implementation = createWasiSocketsHttpImplementation({ + tls: denied, + tlsStreamBridge: denied, + instanceNetwork: { + instanceNetwork: (): never => { + throw new Error("network touched"); + }, + }, + ipNameLookup: { + resolveAddresses: (): never => { + throw new Error("DNS touched"); + }, + }, + tcpCreateSocket: { + createTcpSocket: (): never => { + throw new Error("TCP touched"); + }, + }, + }); + expect(() => + implementation.request({ + scheme: "https", + method: "GET", + authority: "example.com", + pathWithQuery: "/", + headers: [], + body: new Uint8Array(), + }), + ).toThrow(expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" })); +}); + +test.concurrent.each([ + "allowPartialTrustChain", + "enableTrace", + "requestOCSP", + "minDHSize", + "handshakeTimeout", + "sessionTimeout", +])("rejects uncarried TLS setting %s instead of dropping it", (name: string): void => { + expect(() => + tlsMaterial({ servername: "localhost", ...{ [name]: true } }, "https.request option"), + ).toThrow(name); +}); From 7760786b40a7d5836bc36cac047ccecaeb1cc32f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:01 +0000 Subject: [PATCH 14/68] feat(jco): wire wasi:tls into HTTPS componentization --- .../builtin/tls-streams-0-2-10/package.wit | 9 + .../builtin/tls-streams-0-2-12/package.wit | 9 + .../builtin/wasi-tls-0.2.0-draft/LICENSE.md | 8 + .../wasi-tls-0.2.0-draft/PROVENANCE.md | 7 + .../builtin/wasi-tls-0.2.0-draft/deps.lock | 4 + .../builtin/wasi-tls-0.2.0-draft/deps.toml | 1 + .../wasi-tls-0.2.0-draft/deps/io/error.wit | 34 ++ .../wasi-tls-0.2.0-draft/deps/io/poll.wit | 47 +++ .../wasi-tls-0.2.0-draft/deps/io/streams.wit | 290 ++++++++++++++++++ .../wasi-tls-0.2.0-draft/deps/io/world.wit | 10 + .../builtin/wasi-tls-0.2.0-draft/types.wit | 33 ++ .../builtin/wasi-tls-0.2.0-draft/world.wit | 7 + packages/jco/src/cmd/componentize.ts | 62 +++- packages/jco/src/cmd/transpile.ts | 3 + packages/jco/src/node-builtins.ts | 8 +- packages/jco/src/node-wit.ts | 36 ++- packages/jco/src/wit-features.ts | 45 +++ 17 files changed, 599 insertions(+), 14 deletions(-) create mode 100644 packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit create mode 100644 packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit create mode 100644 packages/jco/src/wit-features.ts diff --git a/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit b/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit new file mode 100644 index 000000000..64ad51c72 --- /dev/null +++ b/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit @@ -0,0 +1,9 @@ +// Jco-owned resource version bridge; this is not part of wasi:tls. +package jco:tls-streams-0-2-10@0.1.0; +interface bridge { + use wasi:io/streams@0.2.10.{input-stream, output-stream}; + use wasi:io/streams@0.2.6.{input-stream as tls-input, output-stream as tls-output}; + // Query before connecting so a denied TLS capability never sends plaintext. + is-available: func() -> bool; + adapt: func(input: input-stream, output: output-stream) -> tuple; +} diff --git a/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit b/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit new file mode 100644 index 000000000..492c26671 --- /dev/null +++ b/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit @@ -0,0 +1,9 @@ +// Jco-owned resource version bridge; this is not part of wasi:tls. +package jco:tls-streams-0-2-12@0.1.0; +interface bridge { + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/streams@0.2.6.{input-stream as tls-input, output-stream as tls-output}; + // Query before connecting so a denied TLS capability never sends plaintext. + is-available: func() -> bool; + adapt: func(input: input-stream, output: output-stream) -> tuple; +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md new file mode 100644 index 000000000..475309577 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md @@ -0,0 +1,8 @@ +Copyright © 2019-2023 the Contributors to the WASI Specification, published +by the [WebAssembly Community Group][cg] under the +[W3C Community Contributor License Agreement (CLA)][cla]. A human-readable +[summary][summary] is available. + +[cg]: https://www.w3.org/community/webassembly/ +[cla]: https://www.w3.org/community/about/agreements/cla/ +[summary]: https://www.w3.org/community/about/agreements/cla-deed/ diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md new file mode 100644 index 000000000..e98c57fb1 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md @@ -0,0 +1,7 @@ +Vendored unchanged from WebAssembly/wasi-tls, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. +Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. +The upstream dependency archive checksums are in `deps.lock`. +License: W3C Community Contributor License Agreement; see LICENSE.md. +The `tls` unstable WIT feature must be enabled. Client-only: no server, +trust configuration, verification bypass, cipher or ALPN configuration. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock new file mode 100644 index 000000000..5384c4070 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock @@ -0,0 +1,4 @@ +[io] +url = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" +sha256 = "671761f464d312e6c26bcaab5e79fe14ac876b72267867579d5c65e053fe2301" +sha512 = "57e5ed34fa85f35899b324ac7a2473c5fa5cece51d07e6f077637191fadd3c8b6f79324d31a8d497a6ce7b92cfb2a2505ab894337e2c82889f1bdb21f4f24634" diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml new file mode 100644 index 000000000..b178cb257 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml @@ -0,0 +1 @@ +io = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit new file mode 100644 index 000000000..784f74a53 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit @@ -0,0 +1,34 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit new file mode 100644 index 000000000..7f711836c --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit @@ -0,0 +1,47 @@ +package wasi:io@0.2.6; + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit new file mode 100644 index 000000000..c5da38c86 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit @@ -0,0 +1,290 @@ +package wasi:io@0.2.6; + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func( + contents: list + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + } +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit new file mode 100644 index 000000000..84c85c08e --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit @@ -0,0 +1,10 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import streams; + + @since(version = 0.2.0) + import poll; +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit new file mode 100644 index 000000000..f6b69b3f0 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit @@ -0,0 +1,33 @@ +@unstable(feature = tls) +interface types { + @unstable(feature = tls) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @unstable(feature = tls) + use wasi:io/poll@0.2.6.{pollable}; + @unstable(feature = tls) + use wasi:io/error@0.2.6.{error as io-error}; + + @unstable(feature = tls) + resource client-handshake { + @unstable(feature = tls) + constructor(server-name: string, input: input-stream, output: output-stream); + + @unstable(feature = tls) + finish: static func(this: client-handshake) -> future-client-streams; + } + + @unstable(feature = tls) + resource client-connection { + @unstable(feature = tls) + close-output: func(); + } + + @unstable(feature = tls) + resource future-client-streams { + @unstable(feature = tls) + subscribe: func() -> pollable; + + @unstable(feature = tls) + get: func() -> option, io-error>>>; + } +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit new file mode 100644 index 000000000..8efd9593d --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit @@ -0,0 +1,7 @@ +package wasi:tls@0.2.0-draft; + +@unstable(feature = tls) +world imports { + @unstable(feature = tls) + import types; +} diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index f385ce00a..395ab2cd7 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -1,3 +1,4 @@ +import { resolveWitFeatures } from "../wit-features.js"; import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve, basename, dirname, extname, join } from "node:path"; @@ -106,7 +107,24 @@ const STARLINGMONKEY_OPTIONS: Array = [ */ export async function worldMetadataFor(witPath: string, worldName?: string): Promise { const path = (isWindows ? "//?/" : "") + resolve(witPath); - return (await componentWitMetadataForWorld({ tag: "path", val: path }, worldName)) as WorldMetadata; + try { + return (await componentWitMetadataForWorld({ tag: "path", val: path }, worldName)) as WorldMetadata; + } catch (error) { + // wasi:tls is an explicitly imported unstable proposal. Resolve its feature + // without stripping annotations from the user's or vendored WIT. + if (!String(error).includes("interface not found in package")) { + throw error; + } + const resolved = await resolveWitFeatures(path, worldName, ["tls"]); + try { + return (await componentWitMetadataForWorld( + { tag: "path", val: resolved.witPath }, + resolved.worldName, + )) as WorldMetadata; + } finally { + await resolved.cleanup(); + } + } } /** Re-bundle an entry wrapper that explicitly implements guest callback interface exports. */ @@ -139,6 +157,7 @@ async function usesOlderWasiHTTP(witPath: string, worldName?: string) { const exportsOldIncomingHandler = worldMetadata.exports.some((iface) => { return ( iface.namespace === "wasi" && + iface.package === "http" && iface.version != null && iface.version.major === 0n && iface.version.minor < 3n && @@ -149,6 +168,7 @@ async function usesOlderWasiHTTP(witPath: string, worldName?: string) { const importsOldFetch = worldMetadata.imports.some((iface) => { return ( iface.namespace === "wasi" && + iface.package === "http" && iface.version != null && iface.version.major === 0n && iface.version.minor < 3n && @@ -236,7 +256,15 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): // Build the component let component; - const backendArgs = { source, sourceName, jsSource, witPath, opts }; + const requiresTls = source.includes("wasi:tls/types@0.2.0-draft"); + const resolvedWit = requiresTls ? await resolveWitFeatures(witPath, opts.worldName, ["tls"]) : undefined; + const backendArgs = { + source, + sourceName, + jsSource, + witPath: resolvedWit?.witPath ?? witPath, + opts: resolvedWit ? { ...opts, worldName: resolvedWit.worldName } : opts, + }; // componentize-js reads the process working directory to decide the path prefix baked into // the component and the directory it preopens. Pin it so the build does not depend on where // the command ran, restoring the caller's directory afterwards. @@ -268,6 +296,7 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): throw err; } finally { process.chdir(callerCwd); + await resolvedWit?.cleanup(); } // Write out the component @@ -356,14 +385,27 @@ function calculateFeatureSet(opts: ComponentizeOptions) { async function componentizeQJS(args: BackendComponentizeArgs) { const { source, jsSource, opts, witPath } = args; const componentizeQJSModule = await eval('import("componentize-qjs")'); - const result = await componentizeQJSModule.componentize({ - witPath, - jsSource: source, - jsPath: resolve(jsSource), - world: opts.worldName, - sync: opts.backendQjsDisableAysnc, - }); - return result.component; + try { + const result = await componentizeQJSModule.componentize({ + witPath, + jsSource: source, + jsPath: resolve(jsSource), + world: opts.worldName, + sync: opts.backendQjsDisableAysnc, + }); + return result.component; + } catch (error) { + if ( + String(error).includes("wasi:tls/types@0.2.0-draft") && + String(error).includes("mismatched resource types") + ) { + throw new Error( + "QuickJS's built-in wasi:tls uses incompatible IO resource types for the pinned wasi:tls@0.2.0-draft (wasi:io@0.2.6). Use --backend starlingmonkey for HTTPS over wasi-sockets.", + { cause: error }, + ); + } + throw error; + } } /** Componentize with componentize-js (StarlingMonkey) */ diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index 1a4e20b1b..b74e71f4e 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -45,6 +45,9 @@ const HTTP2_ASYNC_IMPORTS = [ `${HTTP2_CAPABILITY}#[method]server.close`, ]; const DEFAULT_NODE_CAPABILITY_MAP = { + "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/console@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host", diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 90f58837c..03c22ffe3 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -747,13 +747,19 @@ function protocolWasiSocketsAdapter( ): string { const factory = PROTOCOL_FACTORY[protocol]; const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; + const tlsImports = + protocol === "https" + ? `import * as tls from "wasi:tls/types@0.2.0-draft"; +import * as tlsStreamBridge from "jco:tls-streams-${version.replaceAll(".", "-")}/bridge@0.1.0";` + : ""; return ` import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; +${tlsImports} import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiSocketsHttpImplementation } from ${JSON.stringify(implementationModule)}; -${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule} }))`)} +${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule}${protocol === "https" ? ", tls, tlsStreamBridge" : ""} }))`)} `; } diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 352944c6d..f95c19d0e 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -211,9 +211,39 @@ function forHttps(requirements: readonly NodeWitRequirement[]): NodeWitRequireme return requirements.map((requirement) => ({ ...requirement, nodeSpecifier: "node:https" })); } -export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS); - -export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS); +function tlsRequirements(version: "0.2.10" | "0.2.12"): NodeWitRequirement[] { + const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); + const bridge = `tls-streams-${version.replaceAll(".", "-")}`; + const dependencies: WitDependencyPackage[] = [ + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), + }, + { + dependencyDirectory: "wasi-io-0.2.6", + dependencySources: ["world.wit", "streams.wit", "poll.wit", "error.wit"].map((name) => + fileURLToPath(new URL(`deps/io/${name}`, tlsRoot)), + ), + }, + wasiDependency("wasi-io", version), + { + dependencyDirectory: bridge, + dependencySources: [fileURLToPath(new URL(`../lib/wit/builtin/${bridge}/package.wit`, import.meta.url))], + }, + ]; + return forHttps([ + wasiRequirement("wasi:tls/types@0.2.0-draft", dependencies), + wasiRequirement(`jco:${bridge}/bridge@0.1.0`, dependencies), + ]); +} +export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ + ...forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS), + ...tlsRequirements("0.2.12"), +]; +export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ + ...forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS), + ...tlsRequirements("0.2.10"), +]; export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forHttps(HTTP_WASI_HTTP_WIT_REQUIREMENTS); diff --git a/packages/jco/src/wit-features.ts b/packages/jco/src/wit-features.ts new file mode 100644 index 000000000..bb2ae6a1f --- /dev/null +++ b/packages/jco/src/wit-features.ts @@ -0,0 +1,45 @@ +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { componentEmbed, componentNew, componentWit } from "@bytecodealliance/jco-transpile/wasm-tools"; + +export interface ResolvedWit { + witPath: string; + worldName: string; + cleanup(): Promise; +} + +/** + * Resolve opt-in WIT features before handing the graph to component backends that + * cannot enable them. Binary WIT dependencies preserve resource identities and + * feature metadata. The source WIT, including upstream annotations, is untouched. + */ +export async function resolveWitFeatures( + witPath: string, + worldName: string | undefined, + features: string[], +): Promise { + const core = await componentEmbed({ + witPath: resolve(witPath), + world: worldName, + dummy: true, + features: { tag: "list", val: features }, + }); + const sections = WebAssembly.Module.customSections(new WebAssembly.Module(new Uint8Array(core)), "component-type"); + if (sections.length !== 1) { + throw new Error("Expected one resolved WIT component-type section"); + } + // The dummy component gives us a root world referring to the original graph. + // Keep that graph once, in binary form, so multiple IO versions are not re-added. + const world = await componentWit(await componentNew(core, [])); + const root = await mkdtemp(join(tmpdir(), "jco-wit-features-")); + try { + await mkdir(join(root, "deps")); + await writeFile(join(root, "deps", "resolved.wasm"), new Uint8Array(sections[0])); + await writeFile(join(root, "world.wit"), world.replace("package root:component;", "package jco:resolved-wit;")); + } catch (error) { + await rm(root, { recursive: true, force: true }); + throw error; + } + return { witPath: root, worldName: "root", cleanup: () => rm(root, { recursive: true, force: true }) }; +} From cae32f73708e23ee784aaf98d3e7d7dd11bd4851 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:18 +0000 Subject: [PATCH 15/68] test(jco): cover TLS mappings and WIT feature resolution --- packages/jco/test/node/builtins.js | 6 ++++ packages/jco/test/node/tls-wit.test.ts | 43 ++++++++++++++++++++++++++ packages/jco/test/vitest.ts | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/jco/test/node/tls-wit.test.ts diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 88ea13135..afd5052e0 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -34,6 +34,9 @@ const unenvAliases = { describe("Node builtin adapters", () => { test.concurrent("maps host-backed Node APIs to deny providers unless the application opts in", () => { expect(withDefaultNodeCapabilityMap()).toEqual({ + "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", @@ -52,6 +55,9 @@ describe("Node builtin adapters", () => { "jco:node/os@0.1.0": "/application/os-host.js", }), ).toEqual({ + "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", + "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", diff --git a/packages/jco/test/node/tls-wit.test.ts b/packages/jco/test/node/tls-wit.test.ts new file mode 100644 index 000000000..069033160 --- /dev/null +++ b/packages/jco/test/node/tls-wit.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { injectNodeWitImports, HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS } from "../../src/node-wit.js"; +import { worldMetadataFor } from "../../src/cmd/componentize.js"; +import { resolveWitFeatures } from "../../src/wit-features.js"; + +test.concurrent("TLS WIT injection is idempotent and feature resolution preserves upstream sources", async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "jco-tls-wit-")); + try { + const source = "package tests:tls; world untouched {} world component { export run: func(); }\n"; + await writeFile(join(root, "world.wit"), source); + await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS); + const tlsPath = join(root, "deps/wasi-tls-0.2.0-draft/types.wit"); + const upstream = await readFile(tlsPath, "utf8"); + expect(upstream).toContain("@unstable(feature = tls)"); + expect(upstream).toContain("wasi:io/streams@0.2.6"); + expect(await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS)).toBeUndefined(); + const world = await readFile(join(root, "world.wit"), "utf8"); + expect(world).toContain("world untouched {}"); + const resolved = await resolveWitFeatures(root, "component", ["tls"]); + try { + const metadata = await worldMetadataFor(resolved.witPath, resolved.worldName); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "tls", interface: "types" }), + ); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "sockets", interface: "tcp" }), + ); + expect(metadata.exports).toEqual([]); // Free-standing functions are not interface metadata. + expect(await readFile(tlsPath, "utf8")).toBe(upstream); + expect(await readFile(join(root, "world.wit"), "utf8")).toBe(world); + } finally { + await resolved.cleanup(); + } + expect((await worldMetadataFor(root, "component")).imports).toContainEqual( + expect.objectContaining({ package: "tls" }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/jco/test/vitest.ts b/packages/jco/test/vitest.ts index b33820caf..958813779 100644 --- a/packages/jco/test/vitest.ts +++ b/packages/jco/test/vitest.ts @@ -16,7 +16,7 @@ export default defineConfig({ printConsoleTrace: true, passWithNoTests: false, setupFiles: ["test/meta-resolve-stub.ts"], - include: ["test/**/*.js"], + include: ["test/**/*.js", "test/node/**/*.test.ts"], exclude: [ "test/extended/*", "test/output/*", From 305059d8860e121aa621ea7bb292176fc9249188 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:38 +0000 Subject: [PATCH 16/68] test(jco): execute HTTPS components over WASI TLS --- .../componentize/node-https-wasi-tls/build.ts | 69 +++++ .../node-https-wasi-tls/certs/README.md | 5 + .../node-https-wasi-tls/certs/ca.crt | 18 ++ .../node-https-wasi-tls/certs/localhost.crt | 18 ++ .../node-https-wasi-tls/certs/localhost.key | 28 ++ .../node-https-wasi-tls/component.js | 30 ++ .../componentize/node-https-wasi-tls/run.ts | 64 ++++ .../node-https-wasi-tls/wit/component.wit | 5 + packages/jco/test/node/https-wasi-tls.test.ts | 279 ++++++++++++++++++ 9 files changed, 516 insertions(+) create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts create mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit create mode 100644 packages/jco/test/node/https-wasi-tls.test.ts diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts new file mode 100644 index 000000000..901f1e41a --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts @@ -0,0 +1,69 @@ +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundleComponentSource } from "../../../../dist/bundle.js"; +import { componentize } from "../../../../dist/cmd/componentize.js"; +import { nodeBuiltinPlugin } from "../../../../dist/node-builtins.js"; +import { injectNodeWitImports, type NodeWitRequirement } from "../../../../dist/node-wit.js"; +import { transpileBytes, writeFiles } from "../../../../../jco-transpile/dist/index.js"; +import { componentWit } from "../../../../../jco-transpile/dist/wasm-tools.js"; + +const root = resolve(process.argv[2]); +const backend = process.argv[3]; +if (backend !== "starlingmonkey" && backend !== "quickjs") { + throw new Error("unknown backend"); +} +const fixture = fileURLToPath(new URL("./", import.meta.url)); +const std = fileURLToPath(new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/", import.meta.url)); +await mkdir(root, { recursive: true }); +await cp(join(fixture, "wit"), join(root, "wit"), { recursive: true }); +const requirements: NodeWitRequirement[] = []; +const source = await bundleComponentSource(join(fixture, "component.js"), { + external: [/^jco:/], + plugins: [ + nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + nodejsHttpVia: "wasi-sockets", + wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", + httpsCoreModule: join(std, "https/core.js"), + httpCoreModule: join(std, "http/core.js"), + httpWasiSocketsImplementationModule: join(std, "http/impl/wasi-sockets.js"), + onWitRequirement: (requirement: NodeWitRequirement): void => { + requirements.push(requirement); + }, + }, + ), + ], +}); +await injectNodeWitImports(join(root, "wit"), "component", requirements); +await writeFile(join(root, "bundle.js"), source); +await componentize(join(root, "bundle.js"), { + wit: join(root, "wit"), + worldName: "component", + backend, + backendQjsDisableAysnc: false, + ...(backend === "starlingmonkey" ? { disable: ["http"] } : {}), + out: join(root, "component.wasm"), +}); +const bytes = await readFile(join(root, "component.wasm")); +await writeFile(join(root, "imports.wit"), await componentWit(bytes)); +const { files } = await transpileBytes(bytes, { + name: "guest", + instantiation: "async", + base64Cutoff: 0, + map: { + ...Object.fromEntries( + ["cli", "clocks", "filesystem", "http", "io", "random", "sockets"].map((name) => [ + `wasi:${name}/*`, + `${name}#*`, + ]), + ), + "wasi:tls/types@0.2.0-draft": "tls", + "jco:tls-streams-0-2-10/bridge@0.1.0": "tls", + "jco:tls-streams-0-2-12/bridge@0.1.0": "tls", + }, +}); +await writeFiles(Object.fromEntries(Object.entries(files).map(([name, bytes]) => [join(root, name), bytes]))); +await writeFile(join(root, "package.json"), '{"type":"module"}\n'); +console.log("built", backend); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md new file mode 100644 index 000000000..a5f4a43a0 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md @@ -0,0 +1,5 @@ +These certificates and the unencrypted server key are public test fixtures only. +`ca.crt` is a self-signed RSA test CA; `localhost.crt` is signed by that CA, +with SAN `DNS:localhost`, CA:FALSE, and serverAuth EKU. Both expire in 2126. +The CA private key is not stored. The fixture deliberately does not match an +IP address or `wrong.example`, and is never added to system trust. diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt new file mode 100644 index 000000000..2a9340bd8 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIUeGV1KXtVGjlGttolUiclm/uDZicwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPSmNvIFRMUyBUZXN0IENBMCAXDTI2MDkwNzEyMDAyMloY +DzIxMjYwODE0MTIwMDIyWjAaMRgwFgYDVQQDDA9KY28gVExTIFRlc3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCuJT/1jsFlflBFPHHfRMkjq8iB +riWb1X0+6kDjTMNd8fYMveOKM+eMbEn8fQt5+Lgyb/b7iJHcdCtnu2fmxo7JFi0j +2T3Gw57FGcYlpQVNxtTGy9r+Qj1fm4NPE8e8W7kmy+NaLiOXNujFMS1ytXGi6JKa +f2aI9KBPxVbvG76zkLl4nDK+Pv7HQI2A6fPjOnGykFeJuYOmqhoNox5dERfzqvuc +C2PYgimcP3N5NFrkjphQn+CID3hfnT1OlwCyYRMs8K/sOsW9fMwu59Ej7KnEOeqx +wysVmKa/1bjcNPz9h04LBFZk5qQHfPVbOdrrJhyvdKijtWanBVaW8ED1546pAgMB +AAGjMjAwMB0GA1UdDgQWBBTORQpiaFBkvTOKHjTCSJkC4r2jTDAPBgNVHRMBAf8E +BTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQArCsL4CcNO0Tdlhkdf/a2fc7fF0LE8 +5entoI+BkB5wNssaWFqUgUX6SGTDIwWAuasYddNpbLEQloOCK1T3ypEraL14JGl6 +kITTrQ4eXViALUkA0F91FIheabbMzcFoK5KRA9HP3ZFKpTXOGuQZk3gKSbOIH1vz +0wb6EQbWSLnQRWVWZUQWScrpztqVGneGDZI8VHboeeF5r1PDDoa08n643bkUwl0z +KBvLJwVXUr+v16dsDU2KS1Bo7WOuZwtuCQqUAJCDqUAAqZUl4xJHSiZe+Z25L5eq +WOpn8zgbGpzcDMCIob7X4YDwwmwOMgA4PgJXcMFwcg+Uzi1D5Tl3kfqw +-----END CERTIFICATE----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt new file mode 100644 index 000000000..2570fbb8c --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIBATANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9KY28g +VExTIFRlc3QgQ0EwIBcNMjYwOTA3MTIwMDIyWhgPMjEyNjA4MTQxMjAwMjJaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAMUhqhklK7PXjE+GSpTybgZGkWKPUwDnVw/DR0GeBIAz5WaI9gztuUiWOR2W +zchnRRlKY/gaIM3rNE/kT4xJrujDa4/gbH55RreFQ7rsSp6mDaWZDiYGmRtZOfv1 +gWJHH2+865lhikYy6oM8XFdWm27UpgC8wPT9X6HzAOH7c8uBS2p9LninE5gN3MdU +Ifep40zkfjRsNEFIbRiixgDnRtz/rruh58kLfoGUEoZ5VEMDI60ggnjTkjYCcw2m +IVy0mZLWJMArw4XHLjJp1xBWpQTGKwrU+UCJzr6jwEIOp/eYwzuFeDFCV+fY8/z6 +DiWYZaB+sZpEUScX1isSWo9Frb8CAwEAAaNLMEkwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsG +AQUFBwMBMA0GCSqGSIb3DQEBCwUAA4IBAQCD/b+erxEvbzTtSA2j+9fwhP+HGEVn +cx1fP9x37Iy/oZH0NTrp/813vFIL6YSPEUHBQNirRGwMrCO7ZVXFLDMtskAak1A8 +iZ8tbyrzTn4E5dwUEVIPdVl5uityDqm0y3lLDzfn6tO3XhYZLzNm1OYY2qyiX2n+ +n/qGexMc8sua1IaMD2U61RqHIj5jQt1W+SvyFsc8rsWfjH/cg3hPjGTzbqObs3Tf +DEWzWhl58lxYGzN4LBSpRZD6GjkY53plf1VOHSWJCkIC1f2AL3ApmivI0FzPQd7j +EV35wknUjQ2x2cx9i4Ey8mX5KhR6VarOG+Fe3s7I84zHE6p0xbOAs+sl +-----END CERTIFICATE----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key new file mode 100644 index 000000000..2ab9a5350 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDFIaoZJSuz14xP +hkqU8m4GRpFij1MA51cPw0dBngSAM+VmiPYM7blIljkdls3IZ0UZSmP4GiDN6zRP +5E+MSa7ow2uP4Gx+eUa3hUO67Eqepg2lmQ4mBpkbWTn79YFiRx9vvOuZYYpGMuqD +PFxXVptu1KYAvMD0/V+h8wDh+3PLgUtqfS54pxOYDdzHVCH3qeNM5H40bDRBSG0Y +osYA50bc/667oefJC36BlBKGeVRDAyOtIIJ405I2AnMNpiFctJmS1iTAK8OFxy4y +adcQVqUExisK1PlAic6+o8BCDqf3mMM7hXgxQlfn2PP8+g4lmGWgfrGaRFEnF9Yr +ElqPRa2/AgMBAAECggEAAkHo70HINtaEklKQ3xTJosPDHXRTuIJtsk4DrmIvXgJ6 +IYr2+l3sjcK+o7Ka560bEveRnoE6F/GWF0YfjRU47gxy2mJxC5+66hYaGPVkw11W +cauHiHLx5OjIK7T7htMWrpJkxkxiJ3ykx9z0l8FzpTjFL+P5d7TBGBsuyue0w0NS +GK4rJ5z5vjhlTK0vXiJbax6JYVv70XZnlQPU9CgRlq3V1fPj3gVnsAoYLWWN5A+4 +7tzvqw5FfhHGzX7gtEqEj5WIprpJcgdrweBgmP4NqQmo3jFmQpT4Zj+2Soct23rY +Sk1gJIFscMRKMnYbBISNcz0o6lTEOwGXWrWXsIY9gQKBgQD+wKHr+udTeQmxmhyj +a3fUYy+M9QZtmw6i5klDgiUNT7uLYQYrbMHkBdbinQptveTrkITAICxwuJuThn4h +6iKBjtA7Xb/QyRi1wfcy1CMXFj8/ofHTOw4kne0+nRP736WcoLsYT8ZWIgrbsoQ7 +GwA7Na+EHSpoKkCeGtObvhT0gQKBgQDGGMvLsLMtuEvLTR2OIS6hFbmMJS3GJ/DH +J3avWsOtGis7eWbBVjmrumgB3ZYHkVv2FCAY/W13rGH4gaMRo5068b3bpqn4bZct +dw2u2pf1QXopB5S1zc3goUWWMw9ZJoxiwnkKIhn1Fjo838Lr5mnJRe6taA+n6fha +lUtmmfSCPwKBgHtZ4M13lsznPZdebOGAJuyS/jI9blhiDQs5gF4MxU4VvlS1rRwX +tCZp4Wum6KbMnOym9HBm473M1Z/wLmDTktOyyAcG1NsOlEVl3wEgkMEcB5ITIxnJ +bYazZW289zEtUG5vsUgLUJjiMOnCHZ7U6x7AVvUcfi0j0Ff921p9Bn6BAoGASLLC +366qIwY2cpaLWSSeSymA3YirYsQ3na7C5JmHpBgtc3cbGaq+IWKYVs7uBzr2J7m9 +Cc6/hKKzlZJluMx1oDMlPN3OFMiLKXk+gUPhbnUoErSgg5PSkTQ+KF/2qv31mSzL +ZMedBQ+yMbLggtgdTGsoq2S8EiBQL1YIxM+NJtsCgYAU2uSY9UmXvTanvzK9vKin +hqkwYtJ049nkm9PPbpiICscWX/4QTRICzxOju2/mHaG8a+Ss1s8Ez6vd0UHOEsUd +I0wUxI5gAW66vMRcHJeB87YRPz6AgXmGD8ia43OmmN4vt94Jgd9ZRkXuHMFGnH8O +SRYiBvPGTofzItiRgGFzGQ== +-----END PRIVATE KEY----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js new file mode 100644 index 000000000..fc6c6d77f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js @@ -0,0 +1,30 @@ +import https from "node:https"; +import http from "node:http"; + +export async function run(url, body, servername) { + try { + return await new Promise((resolve, reject) => { + const protocol = url.startsWith("http:") ? http : https; + const request = protocol.request( + url, + { + method: body ? "POST" : "GET", + ...(servername ? { servername } : {}), + }, + (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + text += chunk; + }); + response.once("error", reject); + response.once("end", () => resolve({ status: response.statusCode, body: text, error: "" })); + }, + ); + request.once("error", reject); + request.end(body); + }); + } catch (error) { + return { status: 0, body: "", error: `${error.code ?? "Error"}: ${error.message}` }; + } +} diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts new file mode 100644 index 000000000..914102566 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts @@ -0,0 +1,64 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as sockets from "../../../../../preview2-shim/dist/nodejs/sockets.js"; +import * as tls from "../../../../../preview2-shim/dist/nodejs/tls.js"; +import * as denied from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host.js"; + +const root = resolve(process.argv[2]); +const url = process.argv[3]; +const policy = process.argv[4] ?? "trusted"; +const body = process.argv[5] === "large" ? "a".repeat(150_000) : ""; +const servername = process.argv[6] ?? ""; +const ca = policy === "trusted" ? [await readFile(new URL("./certs/ca.crt", import.meta.url), "utf8")] : undefined; +let handshakes = 0; +let connections = 0; +const provider = tls.createTlsProvider({ ca, handshakeTimeoutMs: 1500 }); +class CountedHandshake extends provider.ClientHandshake { + constructor(...args: ConstructorParameters) { + super(...args); + handshakes++; + } +} +const imports: Record = {}; +for (const name of ["cli", "clocks", "filesystem", "http", "io", "random"]) { + imports[name] = await import(new URL(`../../../../../preview2-shim/dist/nodejs/${name}.js`, import.meta.url).href); +} +imports.sockets = { + ...sockets, + tcpCreateSocket: { + createTcpSocket: (family: "ipv4" | "ipv6"): ReturnType => { + connections++; + return sockets.tcpCreateSocket.createTcpSocket(family); + }, + }, +}; +imports.tls = + policy === "denied" + ? denied + : { ...provider, ClientHandshake: CountedHandshake, adapt: tls.adapt, isAvailable: tls.isAvailable }; +interface Report { + status: number; + body: string; + error: string; +} +interface Guest { + run(url: string, body: string, servername: string): Report | Promise; +} +const { + instantiate, +}: { + instantiate: ( + load: (path: string) => Promise, + imports: Record, + ) => Promise; +} = await import(pathToFileURL(join(root, "guest.js")).href); +const guest = await instantiate( + async (path: string): Promise => + WebAssembly.compile(new Uint8Array(await readFile(join(root, path)))), + imports, +); +const before = tls._resourceCounts(); +const report = await guest.run(url, body, servername); +const after = tls._resourceCounts(); +console.log(JSON.stringify({ report, handshakes, connections, before, after })); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit new file mode 100644 index 000000000..be4b991f2 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit @@ -0,0 +1,5 @@ +package jco-fixtures:https-wasi-tls; +world component { + record report { status: u16, body: string, error: string } + export run: func(url: string, body: string, servername: string) -> report; +} diff --git a/packages/jco/test/node/https-wasi-tls.test.ts b/packages/jco/test/node/https-wasi-tls.test.ts new file mode 100644 index 000000000..e3eea0328 --- /dev/null +++ b/packages/jco/test/node/https-wasi-tls.test.ts @@ -0,0 +1,279 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer as createTlsServer } from "node:tls"; +import { createServer as createTcpServer, type Socket } from "node:net"; +import { once } from "node:events"; +import { createServer as createHttpServer } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const exec = promisify(execFile); +const fixture = new URL("../fixtures/componentize/node-https-wasi-tls/", import.meta.url); +const build = fileURLToPath(new URL("build.ts", fixture)); +const runner = fileURLToPath(new URL("run.ts", fixture)); +const cert = await readFile(new URL("certs/localhost.crt", fixture)); +const key = await readFile(new URL("certs/localhost.key", fixture)); + +interface Resources { + tls: number; + streams: number; + futures: number; + polls: number; + sockets: number; +} +interface RunResult { + report: { status: number; body: string; error: string }; + handshakes: number; + connections: number; + before: Resources; + after: Resources; +} +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} +function resources(value: unknown): value is Resources { + return ( + record(value) && + ["tls", "streams", "futures", "polls", "sockets"].every((key) => typeof value[key] === "number") + ); +} +function parseResult(source: string): RunResult { + const value: unknown = JSON.parse(source); + if ( + !record(value) || + !record(value.report) || + typeof value.report.status !== "number" || + typeof value.report.body !== "string" || + typeof value.report.error !== "string" || + typeof value.handshakes !== "number" || + typeof value.connections !== "number" || + !resources(value.before) || + !resources(value.after) + ) { + throw new Error("Invalid guest report"); + } + return { + report: { status: value.report.status, body: value.report.body, error: value.report.error }, + handshakes: value.handshakes, + connections: value.connections, + before: value.before, + after: value.after, + }; +} +function portOf(server: { address(): string | { port: number } | null }): number { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected listening TCP server"); + } + return address.port; +} +async function run(root: string, url: string, policy = "trusted", body = "", servername = ""): Promise { + try { + const result = await exec(process.execPath, [runner, root, url, policy, body, servername], { + timeout: 20_000, + maxBuffer: 1_000_000, + }); + return parseResult(result.stdout.trim()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stderr = record(error) && "stderr" in error ? String(error.stderr) : ""; + throw new Error( + `HTTPS component execution failed for ${url}; check DNS/TCP access and TLS trust. ${message}\n${stderr}`, + { cause: error }, + ); + } +} + +for (const backend of ["starlingmonkey"]) { + describe(`node:https over wasi:sockets + wasi:tls (${backend})`, () => { + let root: string; + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "jco-https-tls-")); + await exec(process.execPath, [build, root, backend], { timeout: 180_000, maxBuffer: 2_000_000 }); + const imports = await readFile(join(root, "imports.wit"), "utf8"); + expect(imports).toContain("import wasi:tls/types@0.2.0-draft"); + expect(imports).toContain("import wasi:sockets/tcp@"); + expect(imports).not.toContain("import jco:node/http@"); + expect(imports).not.toContain("import wasi:http/outgoing-handler@"); + }, 190_000); + afterAll(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + } + }); + + test.concurrent("public network: verified GET https://example.com/", async () => { + // Intentionally enabled: this case needs public DNS and outbound TCP/443. + const result = await run(root, "https://example.com/", "public"); + expect(result.report.error, "Public endpoint requires working DNS/TCP/443 and system trust").toBe(""); + expect(result.report.status).toBe(200); + expect(result.report.body).toContain("Example Domain"); + expect(result.handshakes).toBe(1); + expect(result.connections).toBeGreaterThanOrEqual(1); + expect(result.after).toEqual(result.before); + }, 25_000); + + test.concurrent.each([ + ["verified custom host CA, SNI, ALPN, and fragmented response", "trusted", "localhost", "", true], + ["fragmented large request writes", "trusted", "localhost", "large", true], + ["untrusted certificate", "public", "localhost", "", false], + ["hostname mismatch", "trusted", "wrong.example", "", false], + ["missing TLS capability without plaintext fallback", "denied", "localhost", "", false], + ])( + "%s", + async (_name, policy, servername, body, success) => { + const peers = new Set(); + let secureConnections = 0; + let negotiated: { servername: string | false | null; alpn: string | false | null } | undefined; + let received = 0; + const server = createTlsServer({ cert, key, ALPNProtocols: ["http/1.1"] }, (socket) => { + secureConnections++; + negotiated = { servername: socket.servername, alpn: socket.alpnProtocol }; + let bytes = Buffer.alloc(0); + let responded = false; + socket.on("data", (chunk) => { + bytes = Buffer.concat([bytes, chunk]); + const end = bytes.indexOf("\r\n\r\n"); + if (end < 0) { + return; + } + received = bytes.length - end - 4; + if (responded || (body === "large" && received < 150_000)) { + return; + } + responded = true; + const response = Buffer.from( + "HTTP/1.1 200 OK\r\nContent-Length: 18\r\nConnection: close\r\n\r\nhello verified TLS", + ); + let offset = 0; + function fragment(): void { + if (socket.destroyed) { + return; + } + if (offset === response.length) { + socket.end(); + return; + } + const next = Math.min(offset + 3, response.length); + socket.write(response.subarray(offset, next)); + offset = next; + setImmediate(fragment); + } + fragment(); + }); + }); + server.on("connection", (peer) => { + peers.add(peer); + peer.on("close", () => peers.delete(peer)); + }); + server.on("tlsClientError", () => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `https://127.0.0.1:${portOf(server)}/`, policy, body, servername); + expect(result.after).toEqual(result.before); + if (success) { + expect(result.report).toEqual({ status: 200, body: "hello verified TLS", error: "" }); + expect(negotiated).toEqual({ servername: "localhost", alpn: "http/1.1" }); + expect(result.handshakes).toBe(1); + expect(secureConnections).toBe(1); + if (body) { + expect(received).toBe(150_000); + } + } else { + expect(result.report.status).toBe(0); + expect(result.report.error).toMatch( + policy === "denied" ? /wasi:tls.*TLS capability/ : /TLS handshake failed/, + ); + if (policy === "public") { + expect(result.report.error).toMatch(/certificate/i); + } + if (servername === "wrong.example") { + expect(result.report.error).toMatch(/Hostname\/IP does not match|not in the cert/); + } + expect(secureConnections).toBe(0); + expect(result.handshakes).toBe(policy === "denied" ? 0 : 1); + if (policy === "denied") { + expect(result.connections).toBe(0); + } + } + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 25_000, + ); + + test.concurrent("plain HTTP keeps using TCP without a TLS handshake", async () => { + const server = createHttpServer((_request, response) => response.end("plain HTTP")); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `http://127.0.0.1:${portOf(server)}/`, "denied"); + expect(result.report).toEqual({ status: 200, body: "plain HTTP", error: "" }); + expect(result.handshakes).toBe(0); + expect(result.after).toEqual(result.before); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test.concurrent("connection refusal releases socket resources", async () => { + const server = createTcpServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = portOf(server); + await new Promise((resolve) => server.close(() => resolve())); + const result = await run(root, `https://127.0.0.1:${port}/`); + expect(result.report.status).toBe(0); + expect(result.report.error).toContain("ECONNREFUSED"); + expect(result.handshakes).toBe(0); + expect(result.after).toEqual(result.before); + }); + + test.concurrent.each(["reset", "stalled handshake"])( + "cleans up after %s", + async (kind) => { + const peers = new Set(); + const server = createTcpServer((socket) => { + peers.add(socket); + socket.on("close", () => peers.delete(socket)); + if (kind === "reset") { + socket.destroy(); + } + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `https://127.0.0.1:${portOf(server)}/`, "trusted", "", "localhost"); + expect(result.report.status).toBe(0); + expect(result.report.error).toMatch(/TLS handshake failed/); + expect(result.after).toEqual(result.before); + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 25_000, + ); + }); +} + +test.concurrent("QuickJS reports the pinned TLS IO resource incompatibility", async () => { + const root = await mkdtemp(join(tmpdir(), "jco-https-tls-qjs-")); + try { + await expect( + exec(process.execPath, [build, root, "quickjs"], { timeout: 180_000, maxBuffer: 2_000_000 }), + ).rejects.toThrow(/QuickJS.*incompatible IO resource types.*starlingmonkey/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 190_000); From bc13f938b9e9fdba950643ab1ccf8a80b8fde0fd Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:51 +0000 Subject: [PATCH 17/68] docs(std): explain TLS capability for socket transport --- packages/jco-std/README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index f7adcbcee..8285d4072 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -613,16 +613,26 @@ jco componentize component.js --wit wit --bundle \ map `wasi/0.2.x/node/24.x.x/http/host/node` when transpiling. It supports clients and servers through real `node:http`, and terminates TLS for `node:https` through real `node:https`; -- `wasi-sockets`, which implements HTTP/1.1 in the guest using only Preview 2 - socket and IO capabilities, including TCP servers. It has no TLS stack, so - `node:https` clients and servers are refused rather than served in - plaintext; and +- `wasi-sockets`, which implements HTTP/1.1 in the guest over Preview 2 TCP. + TLS connections implicitly require the additional `wasi:tls` capability; + `node:https` adds its import automatically. Verified HTTPS clients work with + an explicit host provider; HTTPS servers remain unsupported by the pinned + client-only TLS interface; and - `wasi-http`, which translates requests to Preview 2 `wasi:http/outgoing-handler`, including `https` URLs, though per-request TLS options are refused because the outgoing-handler owns certificate validation. It rejects `Server` construction immediately because an outgoing-handler cannot listen for arbitrary inbound connections. +For HTTPS over sockets, use `--backend starlingmonkey` and explicitly map both +`wasi:tls/types@0.2.0-draft` and `jco:tls-streams-0-2-10/bridge@0.1.0` to +`@bytecodealliance/preview2-shim/tls`. The default mapping denies TLS before +connecting. The Node provider wraps the existing TCP streams, validates the +certificate chain and hostname, and offers HTTP/1.1 ALPN. The draft accepts only +`servername` (and `rejectUnauthorized: true`); other per-request TLS settings, +including `ca`, are rejected. Hosts can configure trust with `createTlsProvider`. +See the [provider example](../../docs/src/interop/nodejs-builtins.md#https). + 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 From 6530817579c56e878bcd540e5c45489679323a4d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:27:04 +0000 Subject: [PATCH 18/68] docs(p2-shim): describe opt-in TLS provider --- packages/preview2-shim/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index b8948b5e2..e3fe35ed7 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -256,3 +256,14 @@ See [LICENSE](LICENSE) for more details. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. + +### Opt-in TLS + +`@bytecodealliance/preview2-shim/tls` is a Node-only provider for the pinned +`wasi:tls/types@0.2.0-draft` interface (upstream revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5`). It wraps existing WASI TCP streams with +native TLS, verifies certificate chains and names, and offers HTTP/1.1 ALPN. +It is never enabled by the default WASI mappings. `createTlsProvider({ ca, +handshakeTimeoutMs })` configures host trust and handshake deadlines. The module +also exports the separate Jco IO version bridge (`adapt`, `isAvailable`). +The draft supports clients only; it cannot express guest CA or TLS settings. From 6d09d4400fdcafd673b48984aa43b1de6d869f29 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:27:34 +0000 Subject: [PATCH 19/68] docs(jco): document HTTPS TLS capability and provider setup --- docs/src/interop/nodejs-builtins.md | 85 ++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index c918ec12e..7938aa3ed 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -106,30 +106,30 @@ is planned. > It is the only such alias: modules added after the split, including `node:assert`, are > available only under a versioned entry point. -| Imports | Implementation | Notes | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | -| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | -| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | -| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | -| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | -| `node:module` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module` | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API` -- see below. Requires no WIT capability. | -| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | -| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | -| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | -| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | -| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | -| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | -| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | -| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation -- see below. Servers need `direct` or `wasi-sockets`. | -| `node:https` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https` | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS is terminated by the `direct` host only -- see below. | -| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | -| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | -| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | -| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | -| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | -| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | -| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | +| Imports | Implementation | Notes | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | +| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | +| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | +| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | +| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | +| `node:module` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module` | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API` -- see below. Requires no WIT capability. | +| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | +| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | +| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | +| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | +| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | +| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | +| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | +| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation -- see below. Servers need `direct` or `wasi-sockets`. | +| `node:https` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https` | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS uses the `direct` host or an explicit `wasi:tls` provider -- see below. | +| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | +| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | +| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | +| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | +| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | +| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | +| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | ### Stream consumers and iterable streams @@ -871,9 +871,44 @@ being dropped. | Value | `node:https` behaviour | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `direct` | Clients and servers. The opt-in Node provider routes `https` requests to `node:https.request` with the carried TLS options, and a server carrying a `tls` record to `node:https.createServer`, so the host's own TLS stack terminates the connection. | -| `wasi-sockets` | Refused for both directions: Preview 2 sockets carry no TLS stack, so the implementation throws instead of speaking plaintext on an `https` URL or `https.Server`. | +| `wasi-sockets` | Verified clients over the existing TCP streams. TLS connections implicitly require `wasi:tls`, imported automatically for `node:https`. HTTPS servers are unsupported by the pinned client-only draft. | | `wasi-http` | Clients only, with the `HTTPS` scheme. `wasi:http/outgoing-handler` owns certificate validation, so any per-request TLS option is refused; servers are rejected as for `node:http`. | +For `wasi-sockets`, build with `--backend starlingmonkey` and explicitly grant TLS +when transpiling: + +```sh +jco transpile component.wasm -o out \ + --map 'wasi:tls/types@0.2.0-draft=@bytecodealliance/preview2-shim/tls' \ + --map 'jco:tls-streams-0-2-10/bridge@0.1.0=@bytecodealliance/preview2-shim/tls' +``` + +The Jco bridge transfers IO resource versions and checks capability availability; +it is separate from the upstream TLS interface. Without this opt-in, HTTPS fails +before connecting, with no plaintext fallback. Plain HTTP needs no TLS capability. +The Node provider uses `node:tls` over the supplied TCP streams, system trust, +hostname verification, and HTTP/1.1 ALPN. Hosts needing private trust can map both +interfaces to a module exporting: + +```js +import { createTlsProvider } from '@bytecodealliance/preview2-shim/tls'; +export { adapt, isAvailable } from '@bytecodealliance/preview2-shim/tls'; +export const { ClientHandshake, ClientConnection, FutureClientStreams } = createTlsProvider({ + ca: [trustedCaPem], + handshakeTimeoutMs: 10_000, +}); +``` + +Pinned upstream: [`WebAssembly/wasi-tls` at `6781ae26084100c0628ef72cc44e4517c6c48ae5`](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit), +`wasi:tls@0.2.0-draft`, depending on `wasi:io@0.2.6`. It exposes client handshake, +future polling, streams, and output shutdown. It has no server handshake, +certificate configuration, or ALPN controls. Only guest `servername` and +`rejectUnauthorized: true` are supported; other TLS options, including `ca`, are +rejected. QuickJS currently fails to link the draft's IO resource types; use +StarlingMonkey. The enabled `https-wasi-tls.test.ts` suite includes deterministic +local TLS tests and a separately named public test requiring DNS and TCP/443 to +`example.com` (20-second execution deadline). + An `https.Server` always carries its `tls` record, even when no material was supplied, so an implementation without a TLS stack refuses it; the `direct` host then behaves like Node, which constructs the server and fails each From 7b60c43525d15c08178ae48d044da0503de8220f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:37:53 +0000 Subject: [PATCH 20/68] docs(jco): clarify TLS belongs to the sockets implementation --- docs/src/interop/nodejs-builtins.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index 7938aa3ed..c3aee3273 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -874,8 +874,9 @@ being dropped. | `wasi-sockets` | Verified clients over the existing TCP streams. TLS connections implicitly require `wasi:tls`, imported automatically for `node:https`. HTTPS servers are unsupported by the pinned client-only draft. | | `wasi-http` | Clients only, with the `HTTPS` scheme. `wasi:http/outgoing-handler` owns certificate validation, so any per-request TLS option is refused; servers are rejected as for `node:http`. | -For `wasi-sockets`, build with `--backend starlingmonkey` and explicitly grant TLS -when transpiling: +TLS support is part of the `wasi-sockets` implementation, which uses the +`wasi:tls` host capability for TLS connections. Build with `--backend starlingmonkey` +and explicitly grant that capability when transpiling: ```sh jco transpile component.wasm -o out \ From ee7ae864a2d27d946fd454764296a05271403cd2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:42:32 +0000 Subject: [PATCH 21/68] refactor(std): group TLS under the sockets implementation --- packages/jco-std/package.json | 6 +++--- .../impl/{wasi-sockets.ts => wasi-sockets/index.ts} | 10 +++++----- .../http/impl/{wasi-tls.ts => wasi-sockets/tls.ts} | 6 +++--- .../src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts | 2 +- .../node/24.x.x/http2/impl/wasi-sockets/client.ts | 2 +- .../0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts | 2 +- .../node/24.x.x/http2/impl/wasi-sockets/server.ts | 2 +- .../node/24.x.x/http2/impl/wasi-sockets/shared.ts | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) rename packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/{wasi-sockets.ts => wasi-sockets/index.ts} (99%) rename packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/{wasi-tls.ts => wasi-sockets/tls.ts} (96%) diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 52f6077db..94554b252 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -125,9 +125,9 @@ "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", - "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js" + "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js" }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-http": { "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.d.ts", 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/index.ts similarity index 99% rename from packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts rename to packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts index 45295d460..d7612be79 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/index.ts @@ -4,15 +4,15 @@ import { type WasiTlsProvider, type WasiTlsStreamBridge, type WasiTlsConnection, -} from "./wasi-tls.js"; -import { concatBytes } from "../body.js"; -import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../errors.js"; +} from "./tls.js"; +import { concatBytes } from "../../body.js"; +import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../../errors.js"; import { parseHttp1Request, parseHttp1Response, serializeHttp1Request, serializeHttp1Response, -} from "../http1.js"; +} from "../../http1.js"; import type { HttpImplementation, HttpImplementationRequest, @@ -23,7 +23,7 @@ import type { HttpServerAddress, HttpServerImplementation, HttpServerOptions, -} from "../types.js"; +} from "../../types.js"; export type WasiIpAddress = | { tag: "ipv4"; val: [number, number, number, number] } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts similarity index 96% rename from packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.ts rename to packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts index a854b0226..b0b256edd 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts @@ -3,14 +3,14 @@ * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). * The IO version adapter is a separate Jco interface, never an upstream extension. */ -import { fromImplementationError, unsupported } from "../errors.js"; -import type { HttpTlsMaterial } from "../types.js"; +import { fromImplementationError, unsupported } from "../../errors.js"; +import type { HttpTlsMaterial } from "../../types.js"; import { dispose, type WasiInputStream, type WasiOutputStream, type WasiPollable, -} from "./wasi-sockets.js"; +} from "./index.js"; export interface WasiTlsError { toDebugString(): string; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts index 61aadbed3..caeedc88d 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts @@ -1,4 +1,4 @@ -import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets.js"; +import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets/index.js"; export const FRAME = { data: 0, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts index 1648be640..cf20bc481 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts @@ -5,7 +5,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts index cbd68735c..865cc729c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts @@ -1,4 +1,4 @@ -import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets/index.js"; import type { Http2Implementation } from "../../types.js"; import { createWasiSocketsHttp2Client } from "./client.js"; import { createWasiSocketsHttp2Server } from "./server.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts index 00aa2f4c8..e24b74f9b 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts @@ -10,7 +10,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts index 0e8598f57..1524bbf34 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts @@ -3,7 +3,7 @@ import { type WasiInputStream, type WasiOutputStream, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import type { Http2Settings, HttpHeaderField } from "../../types.js"; import { encodeFrame, FLAG, FRAME, type Http2Frame } from "../frames.js"; import { encodeHeaders } from "../hpack.js"; From 9ab08a39894b94cefdf3a9a70fb3e915b2e6b720 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:42:49 +0000 Subject: [PATCH 22/68] test(std): update imports for sockets implementation folder --- .../jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts | 2 +- .../jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts | 4 ++-- .../jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts | 2 +- .../jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts | 2 +- .../jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts | 2 +- .../jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) 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 13bcbf7d1..a42df63ea 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 @@ -9,7 +9,7 @@ import { createWasiSocketsHttpImplementation, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import { parseHttp1Response, serializeHttp1Request, 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 1a7eb321f..cc0efb9a8 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 @@ -1,13 +1,13 @@ import { describe, expect, test } from "vitest"; import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; -import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import type { WasiInputStream, WasiOutputStream, WasiSocketsProvider, WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); 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 1e027c260..cbc24bcd2 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 @@ -14,7 +14,7 @@ import { encodeHeaders, HpackDecoder, } 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 { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import type { DirectHttp2Settings, DirectHttp2StreamListener, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts index 15519394a..10e9000c3 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; import { createWasiHttpHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-http/index.js"; import { createWasiSocketsHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; describe("node:http2 via wasi-http", () => { const createImplementation = createWasiHttpHttp2Implementation; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts index 0ac6746f3..6bc3f4d72 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createWasiSocketsHttpImplementation, type WasiSocketsProvider, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; /** A provider that fails loudly if the implementation ever reaches the network. */ diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts index c4d7b46d1..9930c50d7 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts @@ -6,13 +6,13 @@ import { createWasiSocketsHttpImplementation, type WasiInputStream, type WasiOutputStream, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import { handshake, validateTlsOptions, type WasiTlsProvider, type WasiTlsResult, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-tls.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.js"; import type { HttpTlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; import * as denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls-host.js"; From f94b3c9c0ac38ea07e2fd8051a0572581c31d38e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:43:45 +0000 Subject: [PATCH 23/68] fix(jco): identify TLS failures in the QuickJS snapshot linker --- packages/jco/src/cmd/componentize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index 395ab2cd7..e599377d4 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -400,7 +400,7 @@ async function componentizeQJS(args: BackendComponentizeArgs) { String(error).includes("mismatched resource types") ) { throw new Error( - "QuickJS's built-in wasi:tls uses incompatible IO resource types for the pinned wasi:tls@0.2.0-draft (wasi:io@0.2.6). Use --backend starlingmonkey for HTTPS over wasi-sockets.", + "QuickJS's snapshot linker cannot reconcile the shared IO resource types imported by wasi:tls@0.2.0-draft (wasi:io@0.2.6). This is a componentize-qjs build-time limitation; --backend starlingmonkey is a workaround.", { cause: error }, ); } From 073ff38d2e22e7781ab6062c94b7f64185b16555 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:02 +0000 Subject: [PATCH 24/68] test(jco): update HTTPS fixture imports and linker diagnostic --- .../test/fixtures/componentize/node-https-wasi-tls/build.ts | 2 +- packages/jco/test/node/https-wasi-tls.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts index 901f1e41a..887308541 100644 --- a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts @@ -28,7 +28,7 @@ const source = await bundleComponentSource(join(fixture, "component.js"), { wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", httpsCoreModule: join(std, "https/core.js"), httpCoreModule: join(std, "http/core.js"), - httpWasiSocketsImplementationModule: join(std, "http/impl/wasi-sockets.js"), + httpWasiSocketsImplementationModule: join(std, "http/impl/wasi-sockets/index.js"), onWitRequirement: (requirement: NodeWitRequirement): void => { requirements.push(requirement); }, diff --git a/packages/jco/test/node/https-wasi-tls.test.ts b/packages/jco/test/node/https-wasi-tls.test.ts index e3eea0328..826baaa6b 100644 --- a/packages/jco/test/node/https-wasi-tls.test.ts +++ b/packages/jco/test/node/https-wasi-tls.test.ts @@ -267,12 +267,12 @@ for (const backend of ["starlingmonkey"]) { }); } -test.concurrent("QuickJS reports the pinned TLS IO resource incompatibility", async () => { +test.concurrent("QuickJS reports its snapshot linker TLS resource incompatibility", async () => { const root = await mkdtemp(join(tmpdir(), "jco-https-tls-qjs-")); try { await expect( exec(process.execPath, [build, root, "quickjs"], { timeout: 180_000, maxBuffer: 2_000_000 }), - ).rejects.toThrow(/QuickJS.*incompatible IO resource types.*starlingmonkey/); + ).rejects.toThrow(/QuickJS.*snapshot linker.*shared IO resource types.*starlingmonkey/); } finally { await rm(root, { recursive: true, force: true }); } From ef9cf44a42f2a020e8b088ed4999744e7835a2e1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:23 +0000 Subject: [PATCH 25/68] docs(std): note the QuickJS TLS snapshot limitation --- packages/jco-std/README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 8285d4072..53239d968 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -624,15 +624,21 @@ jco componentize component.js --wit wit --bundle \ validation. It rejects `Server` construction immediately because an outgoing-handler cannot listen for arbitrary inbound connections. -For HTTPS over sockets, use `--backend starlingmonkey` and explicitly map both -`wasi:tls/types@0.2.0-draft` and `jco:tls-streams-0-2-10/bridge@0.1.0` to -`@bytecodealliance/preview2-shim/tls`. The default mapping denies TLS before +For HTTPS over sockets, explicitly map `wasi:tls/types@0.2.0-draft` and the +component's `jco:tls-streams-0-2-10/bridge@0.1.0` (or `0-2-12`) import to +`@bytecodealliance/preview2-shim/tls`. TLS support is backend-independent. +The default mapping denies TLS before connecting. The Node provider wraps the existing TCP streams, validates the certificate chain and hostname, and offers HTTP/1.1 ALPN. The draft accepts only `servername` (and `rejectUnauthorized: true`); other per-request TLS settings, including `ca`, are rejected. Hosts can configure trust with `createTlsProvider`. See the [provider example](../../docs/src/interop/nodejs-builtins.md#https). +> [!NOTE] +> `componentize-qjs` 0.4.3 currently fails to link the TLS interface's shared IO +> resources during snapshot initialization. StarlingMonkey is a workaround for +> this build-time issue. + 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 From 408db34705c83db3a7ff48b12251dd198ecabf40 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:39 +0000 Subject: [PATCH 26/68] docs(jco): treat QuickJS TLS linking as a backend limitation --- docs/src/interop/nodejs-builtins.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index c3aee3273..bbe307994 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -875,8 +875,8 @@ being dropped. | `wasi-http` | Clients only, with the `HTTPS` scheme. `wasi:http/outgoing-handler` owns certificate validation, so any per-request TLS option is refused; servers are rejected as for `node:http`. | TLS support is part of the `wasi-sockets` implementation, which uses the -`wasi:tls` host capability for TLS connections. Build with `--backend starlingmonkey` -and explicitly grant that capability when transpiling: +`wasi:tls` host capability for TLS connections. Explicitly grant that capability +and the bridge matching the component's imported IO version when transpiling: ```sh jco transpile component.wasm -o out \ @@ -884,6 +884,8 @@ jco transpile component.wasm -o out \ --map 'jco:tls-streams-0-2-10/bridge@0.1.0=@bytecodealliance/preview2-shim/tls' ``` +This example maps the IO 0.2.10 bridge; for IO 0.2.12, map +`jco:tls-streams-0-2-12/bridge@0.1.0` instead. The Jco bridge transfers IO resource versions and checks capability availability; it is separate from the upstream TLS interface. Without this opt-in, HTTPS fails before connecting, with no plaintext fallback. Plain HTTP needs no TLS capability. @@ -905,8 +907,14 @@ Pinned upstream: [`WebAssembly/wasi-tls` at `6781ae26084100c0628ef72cc44e4517c6c future polling, streams, and output shutdown. It has no server handshake, certificate configuration, or ALPN controls. Only guest `servername` and `rejectUnauthorized: true` are supported; other TLS options, including `ca`, are -rejected. QuickJS currently fails to link the draft's IO resource types; use -StarlingMonkey. The enabled `https-wasi-tls.test.ts` suite includes deterministic +rejected. TLS support is independent of the componentization backend. + +> [!NOTE] +> `componentize-qjs` 0.4.3 currently fails during snapshot initialization when linking +> the TLS interface's shared IO resources, even for an otherwise empty component. +> StarlingMonkey is a workaround for this build-time issue. + +The enabled `https-wasi-tls.test.ts` suite includes deterministic local TLS tests and a separately named public test requiring DNS and TCP/443 to `example.com` (20-second execution deadline). From 7b7e52f7ae16b3519336b5a4dc1a82f9774e2873 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:53 +0000 Subject: [PATCH 27/68] docs(jco): remove the TLS certificate fixture README --- .../componentize/node-https-wasi-tls/certs/README.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md deleted file mode 100644 index a5f4a43a0..000000000 --- a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/README.md +++ /dev/null @@ -1,5 +0,0 @@ -These certificates and the unencrypted server key are public test fixtures only. -`ca.crt` is a self-signed RSA test CA; `localhost.crt` is signed by that CA, -with SAN `DNS:localhost`, CA:FALSE, and serverAuth EKU. Both expire in 2126. -The CA private key is not stored. The fixture deliberately does not match an -IP address or `wrong.example`, and is never added to system trust. From 3f027ea103715744b2364fe05254ea4f9b105aa9 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:50:43 +0000 Subject: [PATCH 28/68] refactor(jco): generate tests without filename suffixes --- packages/jco/src/cmd/new.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jco/src/cmd/new.ts b/packages/jco/src/cmd/new.ts index 1b8f1b876..031250478 100644 --- a/packages/jco/src/cmd/new.ts +++ b/packages/jco/src/cmd/new.ts @@ -115,7 +115,9 @@ async function scaffoldFiles(args: ScaffoldFilesArgs): Promise = { ".gitignore": "node_modules/\ndist/\n", [`src/${args.host ? "plugin" : "component"}.${extension}`]: args.source, - [`test/${args.host ? "plugin" : "component"}.test.${extension}`]: args.testSource, + [`test/${args.host ? "plugin" : "component"}.${extension}`]: args.testSource, + [`vitest.config.${extension}`]: + 'import { defineConfig } from "vitest/config";\n\nexport default defineConfig({\n test: { include: ["test/**/*.{ts,js}"] },\n});\n', }; for (const [name, contents] of Object.entries(args.generatedTypes)) { files[`types/generated/${name}`] = contents; From 85ea85dafddb9cb3e02d0bffcf33a0105108eb49 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:51:00 +0000 Subject: [PATCH 29/68] test(jco): remove redundant test filename suffixes --- .../test/fixtures/wit/idl/{console.test.js => console.js} | 0 packages/jco/test/fixtures/wit/idl/{dom.test.js => dom.js} | 0 packages/jco/test/new.js | 4 +++- .../test/node/{https-wasi-tls.test.ts => https-wasi-tls.ts} | 0 packages/jco/test/node/{tls-wit.test.ts => tls-wit.ts} | 0 packages/jco/test/vitest.ts | 2 +- scripts/create-idl-component.sh | 6 +++--- 7 files changed, 7 insertions(+), 5 deletions(-) rename packages/jco/test/fixtures/wit/idl/{console.test.js => console.js} (100%) rename packages/jco/test/fixtures/wit/idl/{dom.test.js => dom.js} (100%) rename packages/jco/test/node/{https-wasi-tls.test.ts => https-wasi-tls.ts} (100%) rename packages/jco/test/node/{tls-wit.test.ts => tls-wit.ts} (100%) diff --git a/packages/jco/test/fixtures/wit/idl/console.test.js b/packages/jco/test/fixtures/wit/idl/console.js similarity index 100% rename from packages/jco/test/fixtures/wit/idl/console.test.js rename to packages/jco/test/fixtures/wit/idl/console.js diff --git a/packages/jco/test/fixtures/wit/idl/dom.test.js b/packages/jco/test/fixtures/wit/idl/dom.js similarity index 100% rename from packages/jco/test/fixtures/wit/idl/dom.test.js rename to packages/jco/test/fixtures/wit/idl/dom.js diff --git a/packages/jco/test/new.js b/packages/jco/test/new.js index bb3a3ff1b..748f3eb1d 100644 --- a/packages/jco/test/new.js +++ b/packages/jco/test/new.js @@ -160,15 +160,17 @@ suite("jco scaffold", () => { "test", "tsconfig.json", "types", + "vitest.config.ts", "wit", ]); assert.include( await readFile(join(project, "src/component.ts"), "utf8"), "export const foo1: typeof World.foo1", ); - const generatedTest = await readFile(join(project, "test/component.test.ts"), "utf8"); + const generatedTest = await readFile(join(project, "test/component.ts"), "utf8"); assert.include(generatedTest, 'component["foo1"]'); assert.include(generatedTest, '["foo"]'); + assert.include(await readFile(join(project, "vitest.config.ts"), "utf8"), '"test/**/*.{ts,js}"'); const packageJson = JSON.parse(await readFile(join(project, "package.json"), "utf8")); assert.equal(packageJson.packageManager, `pnpm@${DEFAULT_PNPM_VERSION}`); assert.equal(packageJson.scripts.check, "pnpm run check:types"); diff --git a/packages/jco/test/node/https-wasi-tls.test.ts b/packages/jco/test/node/https-wasi-tls.ts similarity index 100% rename from packages/jco/test/node/https-wasi-tls.test.ts rename to packages/jco/test/node/https-wasi-tls.ts diff --git a/packages/jco/test/node/tls-wit.test.ts b/packages/jco/test/node/tls-wit.ts similarity index 100% rename from packages/jco/test/node/tls-wit.test.ts rename to packages/jco/test/node/tls-wit.ts diff --git a/packages/jco/test/vitest.ts b/packages/jco/test/vitest.ts index 958813779..403142472 100644 --- a/packages/jco/test/vitest.ts +++ b/packages/jco/test/vitest.ts @@ -16,7 +16,7 @@ export default defineConfig({ printConsoleTrace: true, passWithNoTests: false, setupFiles: ["test/meta-resolve-stub.ts"], - include: ["test/**/*.js", "test/node/**/*.test.ts"], + include: ["test/**/*.js", "test/node/**/*.ts"], exclude: [ "test/extended/*", "test/output/*", diff --git a/scripts/create-idl-component.sh b/scripts/create-idl-component.sh index ae5b8fc22..bbf4f8fcc 100755 --- a/scripts/create-idl-component.sh +++ b/scripts/create-idl-component.sh @@ -1,11 +1,11 @@ # Generate IDL from test/fixtures/idl/*.webidl to test/fixtures/idl/*.wit cargo xtask generate idl -# Componentize the IDL test case at test/fixtures/*.test.js +# Componentize the IDL test case at test/fixtures/*.js -./dist/jco.js componentize test/fixtures/idl/dom.test.js --wit test/fixtures/idl/dom.wit -o dom.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name window-test +./dist/jco.js componentize test/fixtures/idl/dom.js --wit test/fixtures/idl/dom.wit -o dom.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name window-test ./dist/jco.js transpile dom.component.wasm -o dom-test -./dist/jco.js componentize test/fixtures/idl/console.test.js --wit test/fixtures/idl/console.wit -o console.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name console-test +./dist/jco.js componentize test/fixtures/idl/console.js --wit test/fixtures/idl/console.wit -o console.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name console-test ./dist/jco.js transpile console.component.wasm -o console-test # Test it From 3e45c2a7c87d2e129f0c0a90b1c1311431361e49 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:51:39 +0000 Subject: [PATCH 30/68] test(transpile): remove Web IDL fixture filename suffixes --- packages/jco-transpile/test/browser/index.ts | 2 +- .../test/fixtures/webidl/{console.test.js => console.js} | 0 .../jco-transpile/test/fixtures/webidl/{dom.test.js => dom.js} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/jco-transpile/test/fixtures/webidl/{console.test.js => console.js} (100%) rename packages/jco-transpile/test/fixtures/webidl/{dom.test.js => dom.js} (100%) diff --git a/packages/jco-transpile/test/browser/index.ts b/packages/jco-transpile/test/browser/index.ts index b0addbff3..af781bcb0 100644 --- a/packages/jco-transpile/test/browser/index.ts +++ b/packages/jco-transpile/test/browser/index.ts @@ -104,7 +104,7 @@ suite('Browser', () => { for (const fixture of ['dom', 'console']) { test(`runs the ${fixture} Web IDL component`, async () => { const { component } = await componentize({ - sourcePath: join(WEBIDL_FIXTURES_DIR, `${fixture}.test.js`), + sourcePath: join(WEBIDL_FIXTURES_DIR, `${fixture}.js`), disableFeatures: ['clocks', 'random', 'stdio'], witPath: join(WEBIDL_FIXTURES_DIR, `${fixture}.wit`), worldName: `${fixture === 'dom' ? 'window' : fixture}-test`, diff --git a/packages/jco-transpile/test/fixtures/webidl/console.test.js b/packages/jco-transpile/test/fixtures/webidl/console.js similarity index 100% rename from packages/jco-transpile/test/fixtures/webidl/console.test.js rename to packages/jco-transpile/test/fixtures/webidl/console.js diff --git a/packages/jco-transpile/test/fixtures/webidl/dom.test.js b/packages/jco-transpile/test/fixtures/webidl/dom.js similarity index 100% rename from packages/jco-transpile/test/fixtures/webidl/dom.test.js rename to packages/jco-transpile/test/fixtures/webidl/dom.js From ee99d28182882c917e4b872849a75916e748068b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:02 +0000 Subject: [PATCH 31/68] test(node-fs): remove redundant test filename suffixes --- packages/jco-node-fs/package.json | 2 +- packages/jco-node-fs/test/{fadvise.test.js => fadvise.js} | 0 packages/jco-node-fs/test/{rename.test.js => rename.js} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/jco-node-fs/test/{fadvise.test.js => fadvise.js} (100%) rename packages/jco-node-fs/test/{rename.test.js => rename.js} (100%) diff --git a/packages/jco-node-fs/package.json b/packages/jco-node-fs/package.json index 157aef3ec..89036b640 100644 --- a/packages/jco-node-fs/package.json +++ b/packages/jco-node-fs/package.json @@ -36,7 +36,7 @@ "lint": "cargo clippy --all-targets -- -D warnings", "prepare-release": "napi prepublish -t npm --no-gh-release --skip-optional-publish --root-publisher pnpm", "pretest": "pnpm run build:debug", - "test": "node --test test/*.test.js" + "test": "node --test test/*.js" }, "devDependencies": { "@napi-rs/cli": "^3.4.1", diff --git a/packages/jco-node-fs/test/fadvise.test.js b/packages/jco-node-fs/test/fadvise.js similarity index 100% rename from packages/jco-node-fs/test/fadvise.test.js rename to packages/jco-node-fs/test/fadvise.js diff --git a/packages/jco-node-fs/test/rename.test.js b/packages/jco-node-fs/test/rename.js similarity index 100% rename from packages/jco-node-fs/test/rename.test.js rename to packages/jco-node-fs/test/rename.js From c32e8092c33cc257874478b3e365e3156aee7eeb Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:17 +0000 Subject: [PATCH 32/68] test(p2-shim): remove the filesystem test filename suffix --- .../test/{map-filesystem.test.ts => map-filesystem.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/preview2-shim/test/{map-filesystem.test.ts => map-filesystem.ts} (100%) diff --git a/packages/preview2-shim/test/map-filesystem.test.ts b/packages/preview2-shim/test/map-filesystem.ts similarity index 100% rename from packages/preview2-shim/test/map-filesystem.test.ts rename to packages/preview2-shim/test/map-filesystem.ts From 7010a534b9fc9f1b2760cbd84da5375f7668b321 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:47 +0000 Subject: [PATCH 33/68] test(p3-shim): discover tests by folder instead of suffix --- packages/preview3-shim/package.json | 2 +- packages/preview3-shim/test/{cli.test.js => cli.js} | 0 packages/preview3-shim/test/{clocks.test.js => clocks.js} | 0 .../test/{filesystem.test.js => filesystem.js} | 0 packages/preview3-shim/test/{future.test.js => future.js} | 0 .../preview3-shim/test/http/{client.test.js => client.js} | 0 .../preview3-shim/test/http/{fields.test.js => fields.js} | 0 .../test/http/{request.test.js => request.js} | 0 .../test/http/{response.test.js => response.js} | 0 .../preview3-shim/test/http/{server.test.js => server.js} | 0 packages/preview3-shim/test/{random.test.js => random.js} | 0 .../test/{resource-worker.test.js => resource-worker.js} | 0 packages/preview3-shim/test/{stream.test.js => stream.js} | 0 packages/preview3-shim/test/{tcp.test.js => tcp.js} | 0 packages/preview3-shim/test/{udp.test.js => udp.js} | 0 packages/preview3-shim/test/vitest.ts | 8 ++++++++ 16 files changed, 9 insertions(+), 1 deletion(-) rename packages/preview3-shim/test/{cli.test.js => cli.js} (100%) rename packages/preview3-shim/test/{clocks.test.js => clocks.js} (100%) rename packages/preview3-shim/test/{filesystem.test.js => filesystem.js} (100%) rename packages/preview3-shim/test/{future.test.js => future.js} (100%) rename packages/preview3-shim/test/http/{client.test.js => client.js} (100%) rename packages/preview3-shim/test/http/{fields.test.js => fields.js} (100%) rename packages/preview3-shim/test/http/{request.test.js => request.js} (100%) rename packages/preview3-shim/test/http/{response.test.js => response.js} (100%) rename packages/preview3-shim/test/http/{server.test.js => server.js} (100%) rename packages/preview3-shim/test/{random.test.js => random.js} (100%) rename packages/preview3-shim/test/{resource-worker.test.js => resource-worker.js} (100%) rename packages/preview3-shim/test/{stream.test.js => stream.js} (100%) rename packages/preview3-shim/test/{tcp.test.js => tcp.js} (100%) rename packages/preview3-shim/test/{udp.test.js => udp.js} (100%) create mode 100644 packages/preview3-shim/test/vitest.ts diff --git a/packages/preview3-shim/package.json b/packages/preview3-shim/package.json index a6503683a..106a9c9bc 100644 --- a/packages/preview3-shim/package.json +++ b/packages/preview3-shim/package.json @@ -56,7 +56,7 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "pretest": "pnpm run build", - "test": "vitest --run", + "test": "vitest --run -c test/vitest.ts", "prebench": "pnpm run build", "bench": "vitest bench --run", "prepack": "pnpm run build" diff --git a/packages/preview3-shim/test/cli.test.js b/packages/preview3-shim/test/cli.js similarity index 100% rename from packages/preview3-shim/test/cli.test.js rename to packages/preview3-shim/test/cli.js diff --git a/packages/preview3-shim/test/clocks.test.js b/packages/preview3-shim/test/clocks.js similarity index 100% rename from packages/preview3-shim/test/clocks.test.js rename to packages/preview3-shim/test/clocks.js diff --git a/packages/preview3-shim/test/filesystem.test.js b/packages/preview3-shim/test/filesystem.js similarity index 100% rename from packages/preview3-shim/test/filesystem.test.js rename to packages/preview3-shim/test/filesystem.js diff --git a/packages/preview3-shim/test/future.test.js b/packages/preview3-shim/test/future.js similarity index 100% rename from packages/preview3-shim/test/future.test.js rename to packages/preview3-shim/test/future.js diff --git a/packages/preview3-shim/test/http/client.test.js b/packages/preview3-shim/test/http/client.js similarity index 100% rename from packages/preview3-shim/test/http/client.test.js rename to packages/preview3-shim/test/http/client.js diff --git a/packages/preview3-shim/test/http/fields.test.js b/packages/preview3-shim/test/http/fields.js similarity index 100% rename from packages/preview3-shim/test/http/fields.test.js rename to packages/preview3-shim/test/http/fields.js diff --git a/packages/preview3-shim/test/http/request.test.js b/packages/preview3-shim/test/http/request.js similarity index 100% rename from packages/preview3-shim/test/http/request.test.js rename to packages/preview3-shim/test/http/request.js diff --git a/packages/preview3-shim/test/http/response.test.js b/packages/preview3-shim/test/http/response.js similarity index 100% rename from packages/preview3-shim/test/http/response.test.js rename to packages/preview3-shim/test/http/response.js diff --git a/packages/preview3-shim/test/http/server.test.js b/packages/preview3-shim/test/http/server.js similarity index 100% rename from packages/preview3-shim/test/http/server.test.js rename to packages/preview3-shim/test/http/server.js diff --git a/packages/preview3-shim/test/random.test.js b/packages/preview3-shim/test/random.js similarity index 100% rename from packages/preview3-shim/test/random.test.js rename to packages/preview3-shim/test/random.js diff --git a/packages/preview3-shim/test/resource-worker.test.js b/packages/preview3-shim/test/resource-worker.js similarity index 100% rename from packages/preview3-shim/test/resource-worker.test.js rename to packages/preview3-shim/test/resource-worker.js diff --git a/packages/preview3-shim/test/stream.test.js b/packages/preview3-shim/test/stream.js similarity index 100% rename from packages/preview3-shim/test/stream.test.js rename to packages/preview3-shim/test/stream.js diff --git a/packages/preview3-shim/test/tcp.test.js b/packages/preview3-shim/test/tcp.js similarity index 100% rename from packages/preview3-shim/test/tcp.test.js rename to packages/preview3-shim/test/tcp.js diff --git a/packages/preview3-shim/test/udp.test.js b/packages/preview3-shim/test/udp.js similarity index 100% rename from packages/preview3-shim/test/udp.test.js rename to packages/preview3-shim/test/udp.js diff --git a/packages/preview3-shim/test/vitest.ts b/packages/preview3-shim/test/vitest.ts new file mode 100644 index 000000000..b4627ec02 --- /dev/null +++ b/packages/preview3-shim/test/vitest.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.js"], + exclude: ["test/helpers.js", "test/nop-worker.js", "test/**/*.bench.js"], + }, +}); From 46d2ec3f86d7a7b3eb4c5b36cc42f081efb8949c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:03 +0000 Subject: [PATCH 34/68] test(rolldown-plugin-jco): remove test filename suffixes --- .../rolldown-plugin-jco/test/{fixture.test.ts => fixture.ts} | 0 packages/rolldown-plugin-jco/test/{ids.test.ts => ids.ts} | 0 .../rolldown-plugin-jco/test/{plugin.test.ts => plugin.ts} | 0 packages/rolldown-plugin-jco/test/{proxy.test.ts => proxy.ts} | 0 packages/rolldown-plugin-jco/test/vitest.ts | 3 ++- 5 files changed, 2 insertions(+), 1 deletion(-) rename packages/rolldown-plugin-jco/test/{fixture.test.ts => fixture.ts} (100%) rename packages/rolldown-plugin-jco/test/{ids.test.ts => ids.ts} (100%) rename packages/rolldown-plugin-jco/test/{plugin.test.ts => plugin.ts} (100%) rename packages/rolldown-plugin-jco/test/{proxy.test.ts => proxy.ts} (100%) diff --git a/packages/rolldown-plugin-jco/test/fixture.test.ts b/packages/rolldown-plugin-jco/test/fixture.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/fixture.test.ts rename to packages/rolldown-plugin-jco/test/fixture.ts diff --git a/packages/rolldown-plugin-jco/test/ids.test.ts b/packages/rolldown-plugin-jco/test/ids.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/ids.test.ts rename to packages/rolldown-plugin-jco/test/ids.ts diff --git a/packages/rolldown-plugin-jco/test/plugin.test.ts b/packages/rolldown-plugin-jco/test/plugin.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/plugin.test.ts rename to packages/rolldown-plugin-jco/test/plugin.ts diff --git a/packages/rolldown-plugin-jco/test/proxy.test.ts b/packages/rolldown-plugin-jco/test/proxy.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/proxy.test.ts rename to packages/rolldown-plugin-jco/test/proxy.ts diff --git a/packages/rolldown-plugin-jco/test/vitest.ts b/packages/rolldown-plugin-jco/test/vitest.ts index 3c9d6972c..ec75d27aa 100644 --- a/packages/rolldown-plugin-jco/test/vitest.ts +++ b/packages/rolldown-plugin-jco/test/vitest.ts @@ -2,7 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/**/*.test.ts"], + include: ["test/**/*.ts"], + exclude: ["test/vitest.ts", "test/types.ts", "test/fixtures/**"], testTimeout: 120_000, hookTimeout: 120_000, }, From 256e1099c8118de87a330d69c0d0862c104774e1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:22 +0000 Subject: [PATCH 35/68] chore(std): omit unused wit-deps metadata from TLS WIT --- packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md | 5 +++-- packages/jco-std/wit/tls-0.2.0-draft/deps.lock | 4 ---- packages/jco-std/wit/tls-0.2.0-draft/deps.toml | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps.lock delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps.toml diff --git a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md index e98c57fb1..dbd0553d9 100644 --- a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md +++ b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md @@ -1,7 +1,8 @@ -Vendored unchanged from WebAssembly/wasi-tls, revision +WIT files vendored unchanged from WebAssembly/wasi-tls, revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. -The upstream dependency archive checksums are in `deps.lock`. +The IO WIT files are vendored in `deps/io/`; no dependency fetch is needed. +Upstream wit-deps manifests and lockfiles are omitted; Jco does not use wit-deps. License: W3C Community Contributor License Agreement; see LICENSE.md. The `tls` unstable WIT feature must be enabled. Client-only: no server, trust configuration, verification bypass, cipher or ALPN configuration. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps.lock b/packages/jco-std/wit/tls-0.2.0-draft/deps.lock deleted file mode 100644 index 5384c4070..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps.lock +++ /dev/null @@ -1,4 +0,0 @@ -[io] -url = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" -sha256 = "671761f464d312e6c26bcaab5e79fe14ac876b72267867579d5c65e053fe2301" -sha512 = "57e5ed34fa85f35899b324ac7a2473c5fa5cece51d07e6f077637191fadd3c8b6f79324d31a8d497a6ce7b92cfb2a2505ab894337e2c82889f1bdb21f4f24634" diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps.toml b/packages/jco-std/wit/tls-0.2.0-draft/deps.toml deleted file mode 100644 index b178cb257..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps.toml +++ /dev/null @@ -1 +0,0 @@ -io = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" From f843dbbfff074d4a94d753ae02256db367448895 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:38 +0000 Subject: [PATCH 36/68] chore(jco): omit unused wit-deps metadata from TLS WIT --- .../jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md | 5 +++-- packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock | 4 ---- packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md index e98c57fb1..dbd0553d9 100644 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md @@ -1,7 +1,8 @@ -Vendored unchanged from WebAssembly/wasi-tls, revision +WIT files vendored unchanged from WebAssembly/wasi-tls, revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. -The upstream dependency archive checksums are in `deps.lock`. +The IO WIT files are vendored in `deps/io/`; no dependency fetch is needed. +Upstream wit-deps manifests and lockfiles are omitted; Jco does not use wit-deps. License: W3C Community Contributor License Agreement; see LICENSE.md. The `tls` unstable WIT feature must be enabled. Client-only: no server, trust configuration, verification bypass, cipher or ALPN configuration. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock deleted file mode 100644 index 5384c4070..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.lock +++ /dev/null @@ -1,4 +0,0 @@ -[io] -url = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" -sha256 = "671761f464d312e6c26bcaab5e79fe14ac876b72267867579d5c65e053fe2301" -sha512 = "57e5ed34fa85f35899b324ac7a2473c5fa5cece51d07e6f077637191fadd3c8b6f79324d31a8d497a6ce7b92cfb2a2505ab894337e2c82889f1bdb21f4f24634" diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml deleted file mode 100644 index b178cb257..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps.toml +++ /dev/null @@ -1 +0,0 @@ -io = "https://github.com/WebAssembly/wasi-io/archive/main.tar.gz" From c3db1307bd9d95a7118e232eb3b4f5385e4b0243 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:54:02 +0000 Subject: [PATCH 37/68] docs(jco): reference the renamed HTTPS test suite --- docs/src/interop/nodejs-builtins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index bbe307994..bda15d8cb 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -914,7 +914,7 @@ rejected. TLS support is independent of the componentization backend. > the TLS interface's shared IO resources, even for an otherwise empty component. > StarlingMonkey is a workaround for this build-time issue. -The enabled `https-wasi-tls.test.ts` suite includes deterministic +The enabled `https-wasi-tls.ts` suite includes deterministic local TLS tests and a separately named public test requiring DNS and TCP/443 to `example.com` (20-second execution deadline). From 110f6276b63c4c67613603f58d316a5f124f3b76 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:10:43 +0000 Subject: [PATCH 38/68] build(tests): run WebIDL fixture builds through Node.js --- crates/xtask/src/generate/webidl_tests.rs | 11 +++-- package.json | 3 +- scripts/create-idl-component.mjs | 56 +++++++++++++++++++++++ scripts/create-idl-component.sh | 12 ----- 4 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 scripts/create-idl-component.mjs delete mode 100755 scripts/create-idl-component.sh diff --git a/crates/xtask/src/generate/webidl_tests.rs b/crates/xtask/src/generate/webidl_tests.rs index 097436975..d4e114f62 100644 --- a/crates/xtask/src/generate/webidl_tests.rs +++ b/crates/xtask/src/generate/webidl_tests.rs @@ -12,7 +12,7 @@ const IDL_VERSION_MINOR: u64 = 0; const IDL_VERSION_PATCH: u64 = 1; pub(crate) fn run() -> Result<()> { - for file in read_dir("packages/jco/test/fixtures/idl")? { + for file in read_dir("packages/jco/test/fixtures/wit/idl")? { let file = file?; let file_name = file.file_name(); let file_name_str = file_name.to_string_lossy().to_string(); @@ -55,7 +55,12 @@ pub(crate) fn run() -> Result<()> { }, )?; - let wit_str = wit.to_string(); + // Preserve the fixture's existing workaround for the window.window name + // collision until webidl2wit disambiguates resource and method names. + let wit_str = wit.to_string().replace( + " window: func() -> window-proxy;", + " get-window: func() -> window-proxy;", + ); let world_definition = if interface_name == "console" { format!( @@ -72,7 +77,7 @@ pub(crate) fn run() -> Result<()> { .to_string() }; - let output_file = format!("packages/jco/test/fixtures/idl/{name}.wit"); + let output_file = format!("packages/jco/test/fixtures/wit/idl/{name}.wit"); write( &output_file, format!( diff --git a/package.json b/package.json index 588d9da88..84191e8a6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint:fix": "pnpm run -r lint:fix", "test:setup:puppeteer": "node scripts/install-puppeteer.mjs", "test:setup:firefox": "node scripts/install-puppeteer.mjs firefox", - "test:examples": "pnpm run -r all" + "test:examples": "pnpm run -r all", + "build:test:idl": "node scripts/create-idl-component.mjs" }, "devDependencies": { "@actions/github": "^6.0.1", diff --git a/scripts/create-idl-component.mjs b/scripts/create-idl-component.mjs new file mode 100644 index 000000000..ea29349f5 --- /dev/null +++ b/scripts/create-idl-component.mjs @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const jco = fileURLToPath(new URL('../packages/jco/dist/jco.js', import.meta.url)); + +/** @param {string} command @param {string[]} args */ +function run(command, args) { + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} failed (${result.signal ?? result.status})`); + } +} + +const output = join(root, 'packages/jco/test/output/idl'); +mkdirSync(output, { recursive: true }); + +// Generate WIT from the WebIDL fixtures before building their components. +run('cargo', ['xtask', 'generate', 'webidl-tests']); +for (const [name, world] of [ + ['dom', 'window-test'], + ['console', 'console-test'], +]) { + const fixture = `packages/jco/test/fixtures/wit/idl/${name}`; + run(process.execPath, [ + jco, + 'componentize', + `${fixture}.js`, + '--wit', + `${fixture}.wit`, + '-o', + join(output, `${name}.component.wasm`), + '--disable', + 'stdio', + '--disable', + 'random', + '--disable', + 'clocks', + '--disable', + 'http', + '--world-name', + world, + ]); + run(process.execPath, [ + jco, + 'transpile', + join(output, `${name}.component.wasm`), + '-o', + join(output, `${name}-test`), + ]); +} diff --git a/scripts/create-idl-component.sh b/scripts/create-idl-component.sh deleted file mode 100755 index bbf4f8fcc..000000000 --- a/scripts/create-idl-component.sh +++ /dev/null @@ -1,12 +0,0 @@ -# Generate IDL from test/fixtures/idl/*.webidl to test/fixtures/idl/*.wit -cargo xtask generate idl -# Componentize the IDL test case at test/fixtures/*.js - -./dist/jco.js componentize test/fixtures/idl/dom.js --wit test/fixtures/idl/dom.wit -o dom.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name window-test -./dist/jco.js transpile dom.component.wasm -o dom-test - -./dist/jco.js componentize test/fixtures/idl/console.js --wit test/fixtures/idl/console.wit -o console.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name console-test -./dist/jco.js transpile console.component.wasm -o console-test - -# Test it -# node --input-type=module -e "import { test } from './dom-test/dom.component.js'; test();" From 89a59d4ab450fd33413e2e73068122a9bb06dc44 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:11:17 +0000 Subject: [PATCH 39/68] refactor(std): align the local TLS contract with WASI IO 0.2.12 --- .../jco-std/wit/tls-0.2.0-draft/PROVENANCE.md | 13 +- .../jco-std/wit/tls-0.2.0-draft/README.md | 25 ++ .../wit/tls-0.2.0-draft/deps/io/error.wit | 34 -- .../wit/tls-0.2.0-draft/deps/io/package.wit | 66 ++++ .../wit/tls-0.2.0-draft/deps/io/poll.wit | 47 --- .../wit/tls-0.2.0-draft/deps/io/streams.wit | 290 ------------------ .../wit/tls-0.2.0-draft/deps/io/world.wit | 10 - .../jco-std/wit/tls-0.2.0-draft/types.wit | 24 +- .../jco-std/wit/tls-0.2.0-draft/world.wit | 5 +- 9 files changed, 108 insertions(+), 406 deletions(-) create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/README.md delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit create mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit delete mode 100644 packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit diff --git a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md index dbd0553d9..7f1ec7bd7 100644 --- a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md +++ b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md @@ -1,8 +1,5 @@ -WIT files vendored unchanged from WebAssembly/wasi-tls, revision -`6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. -Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. -The IO WIT files are vendored in `deps/io/`; no dependency fetch is needed. -Upstream wit-deps manifests and lockfiles are omitted; Jco does not use wit-deps. -License: W3C Community Contributor License Agreement; see LICENSE.md. -The `tls` unstable WIT feature must be enabled. Client-only: no server, -trust configuration, verification bypass, cipher or ALPN configuration. +Adapted from WebAssembly/wasi-tls `wit/`, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5` (W3C Community CLA; see LICENSE.md). +Local changes: `wasi:io@0.2.12`, `is-available`, and no unstable-feature gate; see README.md. +IO WIT is copied from Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +Upstream wit-deps metadata is omitted; dependencies are vendored. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/README.md b/packages/jco-std/wit/tls-0.2.0-draft/README.md new file mode 100644 index 000000000..bce5a7393 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/README.md @@ -0,0 +1,25 @@ +# Local WASI TLS contract + +This is a slightly modified copy of [WebAssembly/wasi-tls](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit) +at revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, under the W3C Community +Contributor License Agreement (see LICENSE.md). + +It provides a provisional, shared interface for TLS implementations on Node.js, +the web, and other host platforms. It is not an unmodified upstream standard or +a claim that every host platform already has an implementation. + +Local changes: + +- Use `wasi:io@0.2.12` instead of `0.2.6`, sharing the sockets implementation's + stream resources directly without version bridging. IO WIT is copied from + Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +- Add `is-available`, a side-effect-free capability query so denied TLS requests + fail before acquiring TCP resources. +- Omit upstream unstable-feature annotations so normal WIT tooling can consume + this explicitly imported local contract without TLS-specific feature handling. + +The package retains `wasi:tls@0.2.0-draft`. +Hosts must implement this local contract. The upstream client handshake, +future polling, stream ownership, and output shutdown operations are retained. +Server TLS and guest trust/ALPN configuration remain outside the contract; +certificate verification and trust are host policy. No wit-deps tooling is used. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit deleted file mode 100644 index 784f74a53..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/error.wit +++ /dev/null @@ -1,34 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -interface error { - /// A resource which represents some error information. - /// - /// The only method provided by this resource is `to-debug-string`, - /// which provides some human-readable information about the error. - /// - /// In the `wasi:io` package, this resource is returned through the - /// `wasi:io/streams/stream-error` type. - /// - /// To provide more specific error information, other interfaces may - /// offer functions to "downcast" this error into more specific types. For example, - /// errors returned from streams derived from filesystem types can be described using - /// the filesystem's own error-code type. This is done using the function - /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` - /// parameter and returns an `option`. - /// - /// The set of functions which can "downcast" an `error` into a more - /// concrete type is open. - @since(version = 0.2.0) - resource error { - /// Returns a string that is suitable to assist humans in debugging - /// this error. - /// - /// WARNING: The returned string should not be consumed mechanically! - /// It may change across platforms, hosts, or other implementation - /// details. Parsing this string is a major platform-compatibility - /// hazard. - @since(version = 0.2.0) - to-debug-string: func() -> string; - } -} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit new file mode 100644 index 000000000..8006d6d2e --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit @@ -0,0 +1,66 @@ +package wasi:io@0.2.12; +interface error { + resource error { + to-debug-string: func() -> string; + } +} +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + poll: func(in: list>) -> list; +} +interface streams { + use error.{error}; + use poll.{pollable}; + variant stream-error { + last-operation-failed(error), + closed + } + resource input-stream { + read: func( + len: u64 + ) -> result, stream-error>; + blocking-read: func( + len: u64 + ) -> result, stream-error>; + skip: func( + len: u64, + ) -> result; + blocking-skip: func( + len: u64, + ) -> result; + subscribe: func() -> pollable; + } + resource output-stream { + check-write: func() -> result; + write: func( + contents: list + ) -> result<_, stream-error>; + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func( + len: u64 + ) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func( + len: u64 + ) -> result<_, stream-error>; + splice: func( + src: borrow, + len: u64, + ) -> result; + blocking-splice: func( + src: borrow, + len: u64, + ) -> result; + } +} +world imports { + import streams; + import poll; +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit deleted file mode 100644 index 7f711836c..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/poll.wit +++ /dev/null @@ -1,47 +0,0 @@ -package wasi:io@0.2.6; - -/// A poll API intended to let users wait for I/O events on multiple handles -/// at once. -@since(version = 0.2.0) -interface poll { - /// `pollable` represents a single I/O event which may be ready, or not. - @since(version = 0.2.0) - resource pollable { - - /// Return the readiness of a pollable. This function never blocks. - /// - /// Returns `true` when the pollable is ready, and `false` otherwise. - @since(version = 0.2.0) - ready: func() -> bool; - - /// `block` returns immediately if the pollable is ready, and otherwise - /// blocks until ready. - /// - /// This function is equivalent to calling `poll.poll` on a list - /// containing only this pollable. - @since(version = 0.2.0) - block: func(); - } - - /// Poll for completion on a set of pollables. - /// - /// This function takes a list of pollables, which identify I/O sources of - /// interest, and waits until one or more of the events is ready for I/O. - /// - /// The result `list` contains one or more indices of handles in the - /// argument list that is ready for I/O. - /// - /// This function traps if either: - /// - the list is empty, or: - /// - the list contains more elements than can be indexed with a `u32` value. - /// - /// A timeout can be implemented by adding a pollable from the - /// wasi-clocks API to the list. - /// - /// This function does not return a `result`; polling in itself does not - /// do any I/O so it doesn't fail. If any of the I/O sources identified by - /// the pollables has an error, it is indicated by marking the source as - /// being ready for I/O. - @since(version = 0.2.0) - poll: func(in: list>) -> list; -} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit deleted file mode 100644 index c5da38c86..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/streams.wit +++ /dev/null @@ -1,290 +0,0 @@ -package wasi:io@0.2.6; - -/// WASI I/O is an I/O abstraction API which is currently focused on providing -/// stream types. -/// -/// In the future, the component model is expected to add built-in stream types; -/// when it does, they are expected to subsume this API. -@since(version = 0.2.0) -interface streams { - @since(version = 0.2.0) - use error.{error}; - @since(version = 0.2.0) - use poll.{pollable}; - - /// An error for input-stream and output-stream operations. - @since(version = 0.2.0) - variant stream-error { - /// The last operation (a write or flush) failed before completion. - /// - /// More information is available in the `error` payload. - /// - /// After this, the stream will be closed. All future operations return - /// `stream-error::closed`. - last-operation-failed(error), - /// The stream is closed: no more input will be accepted by the - /// stream. A closed output-stream will return this error on all - /// future operations. - closed - } - - /// An input bytestream. - /// - /// `input-stream`s are *non-blocking* to the extent practical on underlying - /// platforms. I/O operations always return promptly; if fewer bytes are - /// promptly available than requested, they return the number of bytes promptly - /// available, which could even be zero. To wait for data to be available, - /// use the `subscribe` function to obtain a `pollable` which can be polled - /// for using `wasi:io/poll`. - @since(version = 0.2.0) - resource input-stream { - /// Perform a non-blocking read from the stream. - /// - /// When the source of a `read` is binary data, the bytes from the source - /// are returned verbatim. When the source of a `read` is known to the - /// implementation to be text, bytes containing the UTF-8 encoding of the - /// text are returned. - /// - /// This function returns a list of bytes containing the read data, - /// when successful. The returned list will contain up to `len` bytes; - /// it may return fewer than requested, but not more. The list is - /// empty when no bytes are available for reading at this time. The - /// pollable given by `subscribe` will be ready when more bytes are - /// available. - /// - /// This function fails with a `stream-error` when the operation - /// encounters an error, giving `last-operation-failed`, or when the - /// stream is closed, giving `closed`. - /// - /// When the caller gives a `len` of 0, it represents a request to - /// read 0 bytes. If the stream is still open, this call should - /// succeed and return an empty list, or otherwise fail with `closed`. - /// - /// The `len` parameter is a `u64`, which could represent a list of u8 which - /// is not possible to allocate in wasm32, or not desirable to allocate as - /// as a return value by the callee. The callee may return a list of bytes - /// less than `len` in size while more bytes are available for reading. - @since(version = 0.2.0) - read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Read bytes from a stream, after blocking until at least one byte can - /// be read. Except for blocking, behavior is identical to `read`. - @since(version = 0.2.0) - blocking-read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Skip bytes from a stream. Returns number of bytes skipped. - /// - /// Behaves identical to `read`, except instead of returning a list - /// of bytes, returns the number of bytes consumed from the stream. - @since(version = 0.2.0) - skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Skip bytes from a stream, after blocking until at least one byte - /// can be skipped. Except for blocking behavior, identical to `skip`. - @since(version = 0.2.0) - blocking-skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Create a `pollable` which will resolve once either the specified stream - /// has bytes available to read or the other end of the stream has been - /// closed. - /// The created `pollable` is a child resource of the `input-stream`. - /// Implementations may trap if the `input-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - - /// An output bytestream. - /// - /// `output-stream`s are *non-blocking* to the extent practical on - /// underlying platforms. Except where specified otherwise, I/O operations also - /// always return promptly, after the number of bytes that can be written - /// promptly, which could even be zero. To wait for the stream to be ready to - /// accept data, the `subscribe` function to obtain a `pollable` which can be - /// polled for using `wasi:io/poll`. - /// - /// Dropping an `output-stream` while there's still an active write in - /// progress may result in the data being lost. Before dropping the stream, - /// be sure to fully flush your writes. - @since(version = 0.2.0) - resource output-stream { - /// Check readiness for writing. This function never blocks. - /// - /// Returns the number of bytes permitted for the next call to `write`, - /// or an error. Calling `write` with more bytes than this function has - /// permitted will trap. - /// - /// When this function returns 0 bytes, the `subscribe` pollable will - /// become ready when this function will report at least 1 byte, or an - /// error. - @since(version = 0.2.0) - check-write: func() -> result; - - /// Perform a write. This function never blocks. - /// - /// When the destination of a `write` is binary data, the bytes from - /// `contents` are written verbatim. When the destination of a `write` is - /// known to the implementation to be text, the bytes of `contents` are - /// transcoded from UTF-8 into the encoding of the destination and then - /// written. - /// - /// Precondition: check-write gave permit of Ok(n) and contents has a - /// length of less than or equal to n. Otherwise, this function will trap. - /// - /// returns Err(closed) without writing if the stream has closed since - /// the last call to check-write provided a permit. - @since(version = 0.2.0) - write: func( - contents: list - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 bytes, and then flush the stream. Block - /// until all of these operations are complete, or an error occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write`, and `flush`, and is implemented with the - /// following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while !contents.is_empty() { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, contents.len()); - /// let (chunk, rest) = contents.split_at(len); - /// this.write(chunk ); // eliding error handling - /// contents = rest; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-and-flush: func( - contents: list - ) -> result<_, stream-error>; - - /// Request to flush buffered output. This function never blocks. - /// - /// This tells the output-stream that the caller intends any buffered - /// output to be flushed. the output which is expected to be flushed - /// is all that has been passed to `write` prior to this call. - /// - /// Upon calling this function, the `output-stream` will not accept any - /// writes (`check-write` will return `ok(0)`) until the flush has - /// completed. The `subscribe` pollable will become ready when the - /// flush has completed and the stream can accept more writes. - @since(version = 0.2.0) - flush: func() -> result<_, stream-error>; - - /// Request to flush buffered output, and block until flush completes - /// and stream is ready for writing again. - @since(version = 0.2.0) - blocking-flush: func() -> result<_, stream-error>; - - /// Create a `pollable` which will resolve once the output-stream - /// is ready for more writing, or an error has occurred. When this - /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an - /// error. - /// - /// If the stream is closed, this pollable is always ready immediately. - /// - /// The created `pollable` is a child resource of the `output-stream`. - /// Implementations may trap if the `output-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - - /// Write zeroes to a stream. - /// - /// This should be used precisely like `write` with the exact same - /// preconditions (must use check-write first), but instead of - /// passing a list of bytes, you simply pass the number of zero-bytes - /// that should be written. - @since(version = 0.2.0) - write-zeroes: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-zeroes-and-flush: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Read from one stream and write to another. - /// - /// The behavior of splice is equivalent to: - /// 1. calling `check-write` on the `output-stream` - /// 2. calling `read` on the `input-stream` with the smaller of the - /// `check-write` permitted length and the `len` provided to `splice` - /// 3. calling `write` on the `output-stream` with that read data. - /// - /// Any error reported by the call to `check-write`, `read`, or - /// `write` ends the splice and reports that error. - /// - /// This function returns the number of bytes transferred; it may be less - /// than `len`. - @since(version = 0.2.0) - splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - - /// Read from one stream and write to another, with blocking. - /// - /// This is similar to `splice`, except that it blocks until the - /// `output-stream` is ready for writing, and the `input-stream` - /// is ready for reading, before performing the `splice`. - @since(version = 0.2.0) - blocking-splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - } -} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit deleted file mode 100644 index 84c85c08e..000000000 --- a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/world.wit +++ /dev/null @@ -1,10 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import streams; - - @since(version = 0.2.0) - import poll; -} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/types.wit b/packages/jco-std/wit/tls-0.2.0-draft/types.wit index f6b69b3f0..a2a664948 100644 --- a/packages/jco-std/wit/tls-0.2.0-draft/types.wit +++ b/packages/jco-std/wit/tls-0.2.0-draft/types.wit @@ -1,33 +1,27 @@ -@unstable(feature = tls) +// Adapted from WebAssembly/wasi-tls wit/types.wit, +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local changes: wasi:io@0.2.12 and an availability query. See README.md. interface types { - @unstable(feature = tls) - use wasi:io/streams@0.2.6.{input-stream, output-stream}; - @unstable(feature = tls) - use wasi:io/poll@0.2.6.{pollable}; - @unstable(feature = tls) - use wasi:io/error@0.2.6.{error as io-error}; + /// Whether the host grants TLS connections. This query performs no IO. + is-available: func() -> bool; + + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + use wasi:io/error@0.2.12.{error as io-error}; - @unstable(feature = tls) resource client-handshake { - @unstable(feature = tls) constructor(server-name: string, input: input-stream, output: output-stream); - @unstable(feature = tls) finish: static func(this: client-handshake) -> future-client-streams; } - @unstable(feature = tls) resource client-connection { - @unstable(feature = tls) close-output: func(); } - @unstable(feature = tls) resource future-client-streams { - @unstable(feature = tls) subscribe: func() -> pollable; - @unstable(feature = tls) get: func() -> option, io-error>>>; } } diff --git a/packages/jco-std/wit/tls-0.2.0-draft/world.wit b/packages/jco-std/wit/tls-0.2.0-draft/world.wit index 8efd9593d..2c7093d93 100644 --- a/packages/jco-std/wit/tls-0.2.0-draft/world.wit +++ b/packages/jco-std/wit/tls-0.2.0-draft/world.wit @@ -1,7 +1,8 @@ +// Adapted from WebAssembly/wasi-tls wit/world.wit at +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local change: omit unstable-feature gates; see README.md. package wasi:tls@0.2.0-draft; -@unstable(feature = tls) world imports { - @unstable(feature = tls) import types; } From b3b27f959aa756560f531a6fc7be26d670d03043 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:11:56 +0000 Subject: [PATCH 40/68] build(std): regenerate TLS bindings against WASI IO 0.2.12 --- .../jco-std/scripts/generate-tls-bindings.mjs | 5 +- .../types/tls/interfaces/wasi-io-error.d.ts | 11 + .../types/tls/interfaces/wasi-io-poll.d.ts | 13 + .../types/tls/interfaces/wasi-io-streams.d.ts | 45 ++++ .../types/tls/interfaces/wasi-tls-types.d.ts | 12 +- .../generated/types/tls/tls-0.2.d.ts | 6 +- .../types/tls/interfaces/wasi-io-error.d.ts | 20 -- .../types/tls/interfaces/wasi-io-poll.d.ts | 46 ---- .../types/tls/interfaces/wasi-io-streams.d.ts | 247 ------------------ 9 files changed, 82 insertions(+), 323 deletions(-) create mode 100644 packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts rename packages/jco-std/src/wasi/{0.2.6 => 0.2.12}/generated/types/tls/interfaces/wasi-tls-types.d.ts (71%) rename packages/jco-std/src/wasi/{0.2.6 => 0.2.12}/generated/types/tls/tls-0.2.d.ts (57%) delete mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts delete mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts delete mode 100644 packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts diff --git a/packages/jco-std/scripts/generate-tls-bindings.mjs b/packages/jco-std/scripts/generate-tls-bindings.mjs index 5b9809fdf..b86c6bc4f 100644 --- a/packages/jco-std/scripts/generate-tls-bindings.mjs +++ b/packages/jco-std/scripts/generate-tls-bindings.mjs @@ -1,8 +1,7 @@ -// Resolve the unmodified upstream draft with its explicit `tls` feature enabled. +// Generate bindings for the documented local TLS contract. import { generateGuestTypes, writeFiles } from "@bytecodealliance/jco-transpile"; const files = await generateGuestTypes("wit/tls-0.2.0-draft", { - features: ["tls"], - outDir: "src/wasi/0.2.6/generated/types/tls", + outDir: "src/wasi/0.2.12/generated/types/tls", }); const decoder = new TextDecoder(); const encoder = new TextEncoder(); diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts new file mode 100644 index 000000000..71514f1a4 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts @@ -0,0 +1,11 @@ +declare module 'wasi:io/error@0.2.12' { + + export class Error implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + toDebugString(): string; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts new file mode 100644 index 000000000..e8833929a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts @@ -0,0 +1,13 @@ +declare module 'wasi:io/poll@0.2.12' { + export function poll(in_: Array): Uint32Array; + + export class Pollable implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + ready(): boolean; + block(): void; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts new file mode 100644 index 000000000..724a49a22 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts @@ -0,0 +1,45 @@ +/// +/// +declare module 'wasi:io/streams@0.2.12' { + export type Error = import('wasi:io/error@0.2.12').Error; + export type Pollable = import('wasi:io/poll@0.2.12').Pollable; + export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; + export interface StreamErrorLastOperationFailed { + tag: 'last-operation-failed', + val: Error, + } + export interface StreamErrorClosed { + tag: 'closed', + } + + export class InputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + read(len: bigint): Uint8Array; + blockingRead(len: bigint): Uint8Array; + skip(len: bigint): bigint; + blockingSkip(len: bigint): bigint; + subscribe(): Pollable; + [Symbol.dispose](): void; + } + + export class OutputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + checkWrite(): bigint; + write(contents: Uint8Array): void; + blockingWriteAndFlush(contents: Uint8Array): void; + flush(): void; + blockingFlush(): void; + subscribe(): Pollable; + writeZeroes(len: bigint): void; + blockingWriteZeroesAndFlush(len: bigint): void; + splice(src: InputStream, len: bigint): bigint; + blockingSplice(src: InputStream, len: bigint): bigint; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts similarity index 71% rename from packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts rename to packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts index 04f9fea2e..79829ea62 100644 --- a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-tls-types.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts @@ -2,10 +2,14 @@ /// /// declare module 'wasi:tls/types@0.2.0-draft' { - export type InputStream = import('wasi:io/streams@0.2.6').InputStream; - export type OutputStream = import('wasi:io/streams@0.2.6').OutputStream; - export type Pollable = import('wasi:io/poll@0.2.6').Pollable; - export type IoError = import('wasi:io/error@0.2.6').Error; + /** + * Whether the host grants TLS connections. This query performs no IO. + */ + export function isAvailable(): boolean; + export type InputStream = import('wasi:io/streams@0.2.12').InputStream; + export type OutputStream = import('wasi:io/streams@0.2.12').OutputStream; + export type Pollable = import('wasi:io/poll@0.2.12').Pollable; + export type IoError = import('wasi:io/error@0.2.12').Error; export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; export class ClientConnection implements Disposable { diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts similarity index 57% rename from packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts rename to packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts index 3a8f9a6c1..336a082fb 100644 --- a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/tls-0.2.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts @@ -3,8 +3,8 @@ /// /// declare module 'wasi:tls/imports@0.2.0-draft' { - export type * as WasiIoError026 from 'wasi:io/error@0.2.6'; // import wasi:io/error@0.2.6 - export type * as WasiIoPoll026 from 'wasi:io/poll@0.2.6'; // import wasi:io/poll@0.2.6 - export type * as WasiIoStreams026 from 'wasi:io/streams@0.2.6'; // import wasi:io/streams@0.2.6 + export type * as WasiIoError0212 from 'wasi:io/error@0.2.12'; // import wasi:io/error@0.2.12 + export type * as WasiIoPoll0212 from 'wasi:io/poll@0.2.12'; // import wasi:io/poll@0.2.12 + export type * as WasiIoStreams0212 from 'wasi:io/streams@0.2.12'; // import wasi:io/streams@0.2.12 export type * as WasiTlsTypes020Draft from 'wasi:tls/types@0.2.0-draft'; // import wasi:tls/types@0.2.0-draft } diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts deleted file mode 100644 index 577436f7c..000000000 --- a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-error.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -declare module 'wasi:io/error@0.2.6' { - - export class Error implements Disposable { - /** - * This type does not have a public constructor. - */ - private constructor(); - /** - * Returns a string that is suitable to assist humans in debugging - * this error. - * - * WARNING: The returned string should not be consumed mechanically! - * It may change across platforms, hosts, or other implementation - * details. Parsing this string is a major platform-compatibility - * hazard. - */ - toDebugString(): string; - [Symbol.dispose](): void; - } -} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts deleted file mode 100644 index 10169ff96..000000000 --- a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-poll.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -declare module 'wasi:io/poll@0.2.6' { - /** - * Poll for completion on a set of pollables. - * - * This function takes a list of pollables, which identify I/O sources of - * interest, and waits until one or more of the events is ready for I/O. - * - * The result `list` contains one or more indices of handles in the - * argument list that is ready for I/O. - * - * This function traps if either: - * - the list is empty, or: - * - the list contains more elements than can be indexed with a `u32` value. - * - * A timeout can be implemented by adding a pollable from the - * wasi-clocks API to the list. - * - * This function does not return a `result`; polling in itself does not - * do any I/O so it doesn't fail. If any of the I/O sources identified by - * the pollables has an error, it is indicated by marking the source as - * being ready for I/O. - */ - export function poll(in_: Array): Uint32Array; - - export class Pollable implements Disposable { - /** - * This type does not have a public constructor. - */ - private constructor(); - /** - * Return the readiness of a pollable. This function never blocks. - * - * Returns `true` when the pollable is ready, and `false` otherwise. - */ - ready(): boolean; - /** - * `block` returns immediately if the pollable is ready, and otherwise - * blocks until ready. - * - * This function is equivalent to calling `poll.poll` on a list - * containing only this pollable. - */ - block(): void; - [Symbol.dispose](): void; - } -} diff --git a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts deleted file mode 100644 index dd3499530..000000000 --- a/packages/jco-std/src/wasi/0.2.6/generated/types/tls/interfaces/wasi-io-streams.d.ts +++ /dev/null @@ -1,247 +0,0 @@ -/// -/// -declare module 'wasi:io/streams@0.2.6' { - export type Error = import('wasi:io/error@0.2.6').Error; - export type Pollable = import('wasi:io/poll@0.2.6').Pollable; - /** - * An error for input-stream and output-stream operations. - */ - export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; - /** - * The last operation (a write or flush) failed before completion. - * - * More information is available in the `error` payload. - * - * After this, the stream will be closed. All future operations return - * `stream-error::closed`. - */ - export interface StreamErrorLastOperationFailed { - tag: 'last-operation-failed', - val: Error, - } - /** - * The stream is closed: no more input will be accepted by the - * stream. A closed output-stream will return this error on all - * future operations. - */ - export interface StreamErrorClosed { - tag: 'closed', - } - - export class InputStream implements Disposable { - /** - * This type does not have a public constructor. - */ - private constructor(); - /** - * Perform a non-blocking read from the stream. - * - * When the source of a `read` is binary data, the bytes from the source - * are returned verbatim. When the source of a `read` is known to the - * implementation to be text, bytes containing the UTF-8 encoding of the - * text are returned. - * - * This function returns a list of bytes containing the read data, - * when successful. The returned list will contain up to `len` bytes; - * it may return fewer than requested, but not more. The list is - * empty when no bytes are available for reading at this time. The - * pollable given by `subscribe` will be ready when more bytes are - * available. - * - * This function fails with a `stream-error` when the operation - * encounters an error, giving `last-operation-failed`, or when the - * stream is closed, giving `closed`. - * - * When the caller gives a `len` of 0, it represents a request to - * read 0 bytes. If the stream is still open, this call should - * succeed and return an empty list, or otherwise fail with `closed`. - * - * The `len` parameter is a `u64`, which could represent a list of u8 which - * is not possible to allocate in wasm32, or not desirable to allocate as - * as a return value by the callee. The callee may return a list of bytes - * less than `len` in size while more bytes are available for reading. - */ - read(len: bigint): Uint8Array; - /** - * Read bytes from a stream, after blocking until at least one byte can - * be read. Except for blocking, behavior is identical to `read`. - */ - blockingRead(len: bigint): Uint8Array; - /** - * Skip bytes from a stream. Returns number of bytes skipped. - * - * Behaves identical to `read`, except instead of returning a list - * of bytes, returns the number of bytes consumed from the stream. - */ - skip(len: bigint): bigint; - /** - * Skip bytes from a stream, after blocking until at least one byte - * can be skipped. Except for blocking behavior, identical to `skip`. - */ - blockingSkip(len: bigint): bigint; - /** - * Create a `pollable` which will resolve once either the specified stream - * has bytes available to read or the other end of the stream has been - * closed. - * The created `pollable` is a child resource of the `input-stream`. - * Implementations may trap if the `input-stream` is dropped before - * all derived `pollable`s created with this function are dropped. - */ - subscribe(): Pollable; - [Symbol.dispose](): void; - } - - export class OutputStream implements Disposable { - /** - * This type does not have a public constructor. - */ - private constructor(); - /** - * Check readiness for writing. This function never blocks. - * - * Returns the number of bytes permitted for the next call to `write`, - * or an error. Calling `write` with more bytes than this function has - * permitted will trap. - * - * When this function returns 0 bytes, the `subscribe` pollable will - * become ready when this function will report at least 1 byte, or an - * error. - */ - checkWrite(): bigint; - /** - * Perform a write. This function never blocks. - * - * When the destination of a `write` is binary data, the bytes from - * `contents` are written verbatim. When the destination of a `write` is - * known to the implementation to be text, the bytes of `contents` are - * transcoded from UTF-8 into the encoding of the destination and then - * written. - * - * Precondition: check-write gave permit of Ok(n) and contents has a - * length of less than or equal to n. Otherwise, this function will trap. - * - * returns Err(closed) without writing if the stream has closed since - * the last call to check-write provided a permit. - */ - write(contents: Uint8Array): void; - /** - * Perform a write of up to 4096 bytes, and then flush the stream. Block - * until all of these operations are complete, or an error occurs. - * - * This is a convenience wrapper around the use of `check-write`, - * `subscribe`, `write`, and `flush`, and is implemented with the - * following pseudo-code: - * - * ```text - * let pollable = this.subscribe(); - * while !contents.is_empty() { - * // Wait for the stream to become writable - * pollable.block(); - * let Ok(n) = this.check-write(); // eliding error handling - * let len = min(n, contents.len()); - * let (chunk, rest) = contents.split_at(len); - * this.write(chunk ); // eliding error handling - * contents = rest; - * } - * this.flush(); - * // Wait for completion of `flush` - * pollable.block(); - * // Check for any errors that arose during `flush` - * let _ = this.check-write(); // eliding error handling - * ``` - */ - blockingWriteAndFlush(contents: Uint8Array): void; - /** - * Request to flush buffered output. This function never blocks. - * - * This tells the output-stream that the caller intends any buffered - * output to be flushed. the output which is expected to be flushed - * is all that has been passed to `write` prior to this call. - * - * Upon calling this function, the `output-stream` will not accept any - * writes (`check-write` will return `ok(0)`) until the flush has - * completed. The `subscribe` pollable will become ready when the - * flush has completed and the stream can accept more writes. - */ - flush(): void; - /** - * Request to flush buffered output, and block until flush completes - * and stream is ready for writing again. - */ - blockingFlush(): void; - /** - * Create a `pollable` which will resolve once the output-stream - * is ready for more writing, or an error has occurred. When this - * pollable is ready, `check-write` will return `ok(n)` with n>0, or an - * error. - * - * If the stream is closed, this pollable is always ready immediately. - * - * The created `pollable` is a child resource of the `output-stream`. - * Implementations may trap if the `output-stream` is dropped before - * all derived `pollable`s created with this function are dropped. - */ - subscribe(): Pollable; - /** - * Write zeroes to a stream. - * - * This should be used precisely like `write` with the exact same - * preconditions (must use check-write first), but instead of - * passing a list of bytes, you simply pass the number of zero-bytes - * that should be written. - */ - writeZeroes(len: bigint): void; - /** - * Perform a write of up to 4096 zeroes, and then flush the stream. - * Block until all of these operations are complete, or an error - * occurs. - * - * This is a convenience wrapper around the use of `check-write`, - * `subscribe`, `write-zeroes`, and `flush`, and is implemented with - * the following pseudo-code: - * - * ```text - * let pollable = this.subscribe(); - * while num_zeroes != 0 { - * // Wait for the stream to become writable - * pollable.block(); - * let Ok(n) = this.check-write(); // eliding error handling - * let len = min(n, num_zeroes); - * this.write-zeroes(len); // eliding error handling - * num_zeroes -= len; - * } - * this.flush(); - * // Wait for completion of `flush` - * pollable.block(); - * // Check for any errors that arose during `flush` - * let _ = this.check-write(); // eliding error handling - * ``` - */ - blockingWriteZeroesAndFlush(len: bigint): void; - /** - * Read from one stream and write to another. - * - * The behavior of splice is equivalent to: - * 1. calling `check-write` on the `output-stream` - * 2. calling `read` on the `input-stream` with the smaller of the - * `check-write` permitted length and the `len` provided to `splice` - * 3. calling `write` on the `output-stream` with that read data. - * - * Any error reported by the call to `check-write`, `read`, or - * `write` ends the splice and reports that error. - * - * This function returns the number of bytes transferred; it may be less - * than `len`. - */ - splice(src: InputStream, len: bigint): bigint; - /** - * Read from one stream and write to another, with blocking. - * - * This is similar to `splice`, except that it blocks until the - * `output-stream` is ready for writing, and the `input-stream` - * is ready for reading, before performing the `splice`. - */ - blockingSplice(src: InputStream, len: bigint): bigint; - [Symbol.dispose](): void; - } - } From 8397e88384c629ce1ad1bf83ac8e5774b5e65f35 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:13:47 +0000 Subject: [PATCH 41/68] refactor(std): pass socket streams directly to TLS --- .../0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts | 5 +---- .../0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts | 11 ++--------- .../jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts | 7 ++----- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts index d7612be79..866e978ab 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts @@ -2,7 +2,6 @@ import { handshake, validateTlsOptions, type WasiTlsProvider, - type WasiTlsStreamBridge, type WasiTlsConnection, } from "./tls.js"; import { concatBytes } from "../../body.js"; @@ -84,7 +83,6 @@ export interface WasiNetwork { export interface WasiSocketsProvider { tls?: WasiTlsProvider; - tlsStreamBridge?: WasiTlsStreamBridge; instanceNetwork: { instanceNetwork(): WasiNetwork; }; @@ -582,7 +580,7 @@ export function createWasiSocketsHttpImplementation( request(request) { if (request.scheme === "https") { validateTlsOptions(request.tls); - if (!provider.tls || provider.tlsStreamBridge?.isAvailable() === false) { + if (!provider.tls?.isAvailable()) { throw fromImplementationError({ name: "Error", code: "ERR_JCO_TLS_ADAPTER_REQUIRED", @@ -615,7 +613,6 @@ export function createWasiSocketsHttpImplementation( output = undefined; [tlsConnection, input, output] = handshake( provider.tls!, - provider.tlsStreamBridge, request.tls?.servername ?? hostname, tcp.input, tcp.output, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts index b0b256edd..2a3895777 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts @@ -1,7 +1,7 @@ /** * Guest contract for WebAssembly/wasi-tls wit/types.wit, revision * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). - * The IO version adapter is a separate Jco interface, never an upstream extension. + * Local contract: wasi:io@0.2.12 and an availability query (see vendored README.md). */ import { fromImplementationError, unsupported } from "../../errors.js"; import type { HttpTlsMaterial } from "../../types.js"; @@ -36,15 +36,12 @@ export interface WasiTlsFuture { [Symbol.dispose]?(): void; } export interface WasiTlsProvider { + isAvailable(): boolean; ClientHandshake: { new (serverName: string, input: WasiInputStream, output: WasiOutputStream): WasiTlsHandshake; finish(handshake: WasiTlsHandshake): WasiTlsFuture; }; } -export interface WasiTlsStreamBridge { - isAvailable(): boolean; - adapt(input: WasiInputStream, output: WasiOutputStream): [WasiInputStream, WasiOutputStream]; -} export function validateTlsOptions(options: HttpTlsMaterial | undefined): void { for (const [name, value] of Object.entries(options ?? {})) { @@ -71,7 +68,6 @@ export function validateTlsOptions(options: HttpTlsMaterial | undefined): void { /** Takes ownership of input/output, including on handshake failure. */ export function handshake( provider: WasiTlsProvider, - bridge: WasiTlsStreamBridge | undefined, serverName: string, input: WasiInputStream, output: WasiOutputStream, @@ -81,9 +77,6 @@ export function handshake( let pending: WasiTlsHandshake | undefined; let future: WasiTlsFuture | undefined; try { - if (bridge) { - [ownedInput, ownedOutput] = bridge.adapt(ownedInput, ownedOutput); - } pending = new provider.ClientHandshake(serverName, ownedInput, ownedOutput); ownedInput = undefined; ownedOutput = undefined; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts index 0130bd497..da3d74e85 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts @@ -2,7 +2,7 @@ function denied(): never { throw Object.assign( new Error( - "HTTPS over wasi:sockets requires an explicitly configured wasi:tls/types@0.2.0-draft host provider and IO version bridge", + "HTTPS over wasi:sockets requires an explicitly configured wasi:tls/types@0.2.0-draft host provider", ), { code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }, ); @@ -28,10 +28,7 @@ export class FutureClientStreams { return denied(); } } -/** Jco bridge operation, not part of the upstream wasi:tls interface. */ +/** Availability query in Jco's local TLS contract. */ export function isAvailable(): boolean { return false; } -export function adapt(_input: unknown, _output: unknown): never { - return denied(); -} From 2e4c34574a801e3e78ff6952848ae6a0fca15ef4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:15:15 +0000 Subject: [PATCH 42/68] test(std): exercise TLS without stream version adaptation --- .../test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts index 9930c50d7..71bf3ea14 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts @@ -73,6 +73,7 @@ test.concurrent("polls a pending handshake and drops the poll before its future" const output = { blockingWriteAndFlush: (): void => {} }; const connection = { closeOutput: (): void => {} }; const provider: WasiTlsProvider = { + isAvailable: () => true, ClientHandshake: class { constructor(name: string, incoming: WasiInputStream, outgoing: WasiOutputStream) { expect(name).toBe("localhost"); @@ -98,11 +99,7 @@ test.concurrent("polls a pending handshake and drops the poll before its future" } }, }; - expect(handshake(provider, undefined, "localhost", input, output)).toEqual([ - connection, - input, - output, - ]); + expect(handshake(provider, "localhost", input, output)).toEqual([connection, input, output]); expect(events).toEqual(["poll", "future"]); }); @@ -111,6 +108,7 @@ test.concurrent("drops a failed handshake's IO error and future", () => { const input = { blockingRead: (): Uint8Array => new Uint8Array() }; const output = { blockingWriteAndFlush: (): void => {} }; const provider: WasiTlsProvider = { + isAvailable: () => true, ClientHandshake: class { static finish(): ReturnType { return { @@ -136,16 +134,13 @@ test.concurrent("drops a failed handshake's IO error and future", () => { } }, }; - expect(() => handshake(provider, undefined, "localhost", input, output)).toThrow( - /untrusted certificate/, - ); + expect(() => handshake(provider, "localhost", input, output)).toThrow(/untrusted certificate/); expect(events).toEqual(["error", "future"]); }); test.concurrent("default denial is lazy and refuses before acquiring TCP resources", () => { const implementation = createWasiSocketsHttpImplementation({ tls: denied, - tlsStreamBridge: denied, instanceNetwork: { instanceNetwork: (): never => { throw new Error("network touched"); From 35cd8c5cc64e9fd084c34b1924e86d81f2c17d14 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:15:44 +0000 Subject: [PATCH 43/68] refactor(jco): vendor the local TLS contract without IO bridges --- .../builtin/tls-streams-0-2-10/package.wit | 9 - .../builtin/tls-streams-0-2-12/package.wit | 9 - .../wasi-tls-0.2.0-draft/PROVENANCE.md | 13 +- .../builtin/wasi-tls-0.2.0-draft/README.md | 25 ++ .../wasi-tls-0.2.0-draft/deps/io/error.wit | 34 -- .../wasi-tls-0.2.0-draft/deps/io/package.wit | 66 ++++ .../wasi-tls-0.2.0-draft/deps/io/poll.wit | 47 --- .../wasi-tls-0.2.0-draft/deps/io/streams.wit | 290 ------------------ .../wasi-tls-0.2.0-draft/deps/io/world.wit | 10 - .../builtin/wasi-tls-0.2.0-draft/types.wit | 24 +- .../builtin/wasi-tls-0.2.0-draft/world.wit | 5 +- 11 files changed, 108 insertions(+), 424 deletions(-) delete mode 100644 packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit delete mode 100644 packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit create mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit delete mode 100644 packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit diff --git a/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit b/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit deleted file mode 100644 index 64ad51c72..000000000 --- a/packages/jco/lib/wit/builtin/tls-streams-0-2-10/package.wit +++ /dev/null @@ -1,9 +0,0 @@ -// Jco-owned resource version bridge; this is not part of wasi:tls. -package jco:tls-streams-0-2-10@0.1.0; -interface bridge { - use wasi:io/streams@0.2.10.{input-stream, output-stream}; - use wasi:io/streams@0.2.6.{input-stream as tls-input, output-stream as tls-output}; - // Query before connecting so a denied TLS capability never sends plaintext. - is-available: func() -> bool; - adapt: func(input: input-stream, output: output-stream) -> tuple; -} diff --git a/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit b/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit deleted file mode 100644 index 492c26671..000000000 --- a/packages/jco/lib/wit/builtin/tls-streams-0-2-12/package.wit +++ /dev/null @@ -1,9 +0,0 @@ -// Jco-owned resource version bridge; this is not part of wasi:tls. -package jco:tls-streams-0-2-12@0.1.0; -interface bridge { - use wasi:io/streams@0.2.12.{input-stream, output-stream}; - use wasi:io/streams@0.2.6.{input-stream as tls-input, output-stream as tls-output}; - // Query before connecting so a denied TLS capability never sends plaintext. - is-available: func() -> bool; - adapt: func(input: input-stream, output: output-stream) -> tuple; -} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md index dbd0553d9..7f1ec7bd7 100644 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md @@ -1,8 +1,5 @@ -WIT files vendored unchanged from WebAssembly/wasi-tls, revision -`6781ae26084100c0628ef72cc44e4517c6c48ae5`, directory `wit/`. -Package: `wasi:tls@0.2.0-draft`; dependency: `wasi:io@0.2.6`. -The IO WIT files are vendored in `deps/io/`; no dependency fetch is needed. -Upstream wit-deps manifests and lockfiles are omitted; Jco does not use wit-deps. -License: W3C Community Contributor License Agreement; see LICENSE.md. -The `tls` unstable WIT feature must be enabled. Client-only: no server, -trust configuration, verification bypass, cipher or ALPN configuration. +Adapted from WebAssembly/wasi-tls `wit/`, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5` (W3C Community CLA; see LICENSE.md). +Local changes: `wasi:io@0.2.12`, `is-available`, and no unstable-feature gate; see README.md. +IO WIT is copied from Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +Upstream wit-deps metadata is omitted; dependencies are vendored. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md new file mode 100644 index 000000000..bce5a7393 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md @@ -0,0 +1,25 @@ +# Local WASI TLS contract + +This is a slightly modified copy of [WebAssembly/wasi-tls](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit) +at revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, under the W3C Community +Contributor License Agreement (see LICENSE.md). + +It provides a provisional, shared interface for TLS implementations on Node.js, +the web, and other host platforms. It is not an unmodified upstream standard or +a claim that every host platform already has an implementation. + +Local changes: + +- Use `wasi:io@0.2.12` instead of `0.2.6`, sharing the sockets implementation's + stream resources directly without version bridging. IO WIT is copied from + Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +- Add `is-available`, a side-effect-free capability query so denied TLS requests + fail before acquiring TCP resources. +- Omit upstream unstable-feature annotations so normal WIT tooling can consume + this explicitly imported local contract without TLS-specific feature handling. + +The package retains `wasi:tls@0.2.0-draft`. +Hosts must implement this local contract. The upstream client handshake, +future polling, stream ownership, and output shutdown operations are retained. +Server TLS and guest trust/ALPN configuration remain outside the contract; +certificate verification and trust are host policy. No wit-deps tooling is used. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit deleted file mode 100644 index 784f74a53..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/error.wit +++ /dev/null @@ -1,34 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -interface error { - /// A resource which represents some error information. - /// - /// The only method provided by this resource is `to-debug-string`, - /// which provides some human-readable information about the error. - /// - /// In the `wasi:io` package, this resource is returned through the - /// `wasi:io/streams/stream-error` type. - /// - /// To provide more specific error information, other interfaces may - /// offer functions to "downcast" this error into more specific types. For example, - /// errors returned from streams derived from filesystem types can be described using - /// the filesystem's own error-code type. This is done using the function - /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` - /// parameter and returns an `option`. - /// - /// The set of functions which can "downcast" an `error` into a more - /// concrete type is open. - @since(version = 0.2.0) - resource error { - /// Returns a string that is suitable to assist humans in debugging - /// this error. - /// - /// WARNING: The returned string should not be consumed mechanically! - /// It may change across platforms, hosts, or other implementation - /// details. Parsing this string is a major platform-compatibility - /// hazard. - @since(version = 0.2.0) - to-debug-string: func() -> string; - } -} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit new file mode 100644 index 000000000..8006d6d2e --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit @@ -0,0 +1,66 @@ +package wasi:io@0.2.12; +interface error { + resource error { + to-debug-string: func() -> string; + } +} +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + poll: func(in: list>) -> list; +} +interface streams { + use error.{error}; + use poll.{pollable}; + variant stream-error { + last-operation-failed(error), + closed + } + resource input-stream { + read: func( + len: u64 + ) -> result, stream-error>; + blocking-read: func( + len: u64 + ) -> result, stream-error>; + skip: func( + len: u64, + ) -> result; + blocking-skip: func( + len: u64, + ) -> result; + subscribe: func() -> pollable; + } + resource output-stream { + check-write: func() -> result; + write: func( + contents: list + ) -> result<_, stream-error>; + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func( + len: u64 + ) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func( + len: u64 + ) -> result<_, stream-error>; + splice: func( + src: borrow, + len: u64, + ) -> result; + blocking-splice: func( + src: borrow, + len: u64, + ) -> result; + } +} +world imports { + import streams; + import poll; +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit deleted file mode 100644 index 7f711836c..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/poll.wit +++ /dev/null @@ -1,47 +0,0 @@ -package wasi:io@0.2.6; - -/// A poll API intended to let users wait for I/O events on multiple handles -/// at once. -@since(version = 0.2.0) -interface poll { - /// `pollable` represents a single I/O event which may be ready, or not. - @since(version = 0.2.0) - resource pollable { - - /// Return the readiness of a pollable. This function never blocks. - /// - /// Returns `true` when the pollable is ready, and `false` otherwise. - @since(version = 0.2.0) - ready: func() -> bool; - - /// `block` returns immediately if the pollable is ready, and otherwise - /// blocks until ready. - /// - /// This function is equivalent to calling `poll.poll` on a list - /// containing only this pollable. - @since(version = 0.2.0) - block: func(); - } - - /// Poll for completion on a set of pollables. - /// - /// This function takes a list of pollables, which identify I/O sources of - /// interest, and waits until one or more of the events is ready for I/O. - /// - /// The result `list` contains one or more indices of handles in the - /// argument list that is ready for I/O. - /// - /// This function traps if either: - /// - the list is empty, or: - /// - the list contains more elements than can be indexed with a `u32` value. - /// - /// A timeout can be implemented by adding a pollable from the - /// wasi-clocks API to the list. - /// - /// This function does not return a `result`; polling in itself does not - /// do any I/O so it doesn't fail. If any of the I/O sources identified by - /// the pollables has an error, it is indicated by marking the source as - /// being ready for I/O. - @since(version = 0.2.0) - poll: func(in: list>) -> list; -} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit deleted file mode 100644 index c5da38c86..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/streams.wit +++ /dev/null @@ -1,290 +0,0 @@ -package wasi:io@0.2.6; - -/// WASI I/O is an I/O abstraction API which is currently focused on providing -/// stream types. -/// -/// In the future, the component model is expected to add built-in stream types; -/// when it does, they are expected to subsume this API. -@since(version = 0.2.0) -interface streams { - @since(version = 0.2.0) - use error.{error}; - @since(version = 0.2.0) - use poll.{pollable}; - - /// An error for input-stream and output-stream operations. - @since(version = 0.2.0) - variant stream-error { - /// The last operation (a write or flush) failed before completion. - /// - /// More information is available in the `error` payload. - /// - /// After this, the stream will be closed. All future operations return - /// `stream-error::closed`. - last-operation-failed(error), - /// The stream is closed: no more input will be accepted by the - /// stream. A closed output-stream will return this error on all - /// future operations. - closed - } - - /// An input bytestream. - /// - /// `input-stream`s are *non-blocking* to the extent practical on underlying - /// platforms. I/O operations always return promptly; if fewer bytes are - /// promptly available than requested, they return the number of bytes promptly - /// available, which could even be zero. To wait for data to be available, - /// use the `subscribe` function to obtain a `pollable` which can be polled - /// for using `wasi:io/poll`. - @since(version = 0.2.0) - resource input-stream { - /// Perform a non-blocking read from the stream. - /// - /// When the source of a `read` is binary data, the bytes from the source - /// are returned verbatim. When the source of a `read` is known to the - /// implementation to be text, bytes containing the UTF-8 encoding of the - /// text are returned. - /// - /// This function returns a list of bytes containing the read data, - /// when successful. The returned list will contain up to `len` bytes; - /// it may return fewer than requested, but not more. The list is - /// empty when no bytes are available for reading at this time. The - /// pollable given by `subscribe` will be ready when more bytes are - /// available. - /// - /// This function fails with a `stream-error` when the operation - /// encounters an error, giving `last-operation-failed`, or when the - /// stream is closed, giving `closed`. - /// - /// When the caller gives a `len` of 0, it represents a request to - /// read 0 bytes. If the stream is still open, this call should - /// succeed and return an empty list, or otherwise fail with `closed`. - /// - /// The `len` parameter is a `u64`, which could represent a list of u8 which - /// is not possible to allocate in wasm32, or not desirable to allocate as - /// as a return value by the callee. The callee may return a list of bytes - /// less than `len` in size while more bytes are available for reading. - @since(version = 0.2.0) - read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Read bytes from a stream, after blocking until at least one byte can - /// be read. Except for blocking, behavior is identical to `read`. - @since(version = 0.2.0) - blocking-read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Skip bytes from a stream. Returns number of bytes skipped. - /// - /// Behaves identical to `read`, except instead of returning a list - /// of bytes, returns the number of bytes consumed from the stream. - @since(version = 0.2.0) - skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Skip bytes from a stream, after blocking until at least one byte - /// can be skipped. Except for blocking behavior, identical to `skip`. - @since(version = 0.2.0) - blocking-skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Create a `pollable` which will resolve once either the specified stream - /// has bytes available to read or the other end of the stream has been - /// closed. - /// The created `pollable` is a child resource of the `input-stream`. - /// Implementations may trap if the `input-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - - /// An output bytestream. - /// - /// `output-stream`s are *non-blocking* to the extent practical on - /// underlying platforms. Except where specified otherwise, I/O operations also - /// always return promptly, after the number of bytes that can be written - /// promptly, which could even be zero. To wait for the stream to be ready to - /// accept data, the `subscribe` function to obtain a `pollable` which can be - /// polled for using `wasi:io/poll`. - /// - /// Dropping an `output-stream` while there's still an active write in - /// progress may result in the data being lost. Before dropping the stream, - /// be sure to fully flush your writes. - @since(version = 0.2.0) - resource output-stream { - /// Check readiness for writing. This function never blocks. - /// - /// Returns the number of bytes permitted for the next call to `write`, - /// or an error. Calling `write` with more bytes than this function has - /// permitted will trap. - /// - /// When this function returns 0 bytes, the `subscribe` pollable will - /// become ready when this function will report at least 1 byte, or an - /// error. - @since(version = 0.2.0) - check-write: func() -> result; - - /// Perform a write. This function never blocks. - /// - /// When the destination of a `write` is binary data, the bytes from - /// `contents` are written verbatim. When the destination of a `write` is - /// known to the implementation to be text, the bytes of `contents` are - /// transcoded from UTF-8 into the encoding of the destination and then - /// written. - /// - /// Precondition: check-write gave permit of Ok(n) and contents has a - /// length of less than or equal to n. Otherwise, this function will trap. - /// - /// returns Err(closed) without writing if the stream has closed since - /// the last call to check-write provided a permit. - @since(version = 0.2.0) - write: func( - contents: list - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 bytes, and then flush the stream. Block - /// until all of these operations are complete, or an error occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write`, and `flush`, and is implemented with the - /// following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while !contents.is_empty() { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, contents.len()); - /// let (chunk, rest) = contents.split_at(len); - /// this.write(chunk ); // eliding error handling - /// contents = rest; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-and-flush: func( - contents: list - ) -> result<_, stream-error>; - - /// Request to flush buffered output. This function never blocks. - /// - /// This tells the output-stream that the caller intends any buffered - /// output to be flushed. the output which is expected to be flushed - /// is all that has been passed to `write` prior to this call. - /// - /// Upon calling this function, the `output-stream` will not accept any - /// writes (`check-write` will return `ok(0)`) until the flush has - /// completed. The `subscribe` pollable will become ready when the - /// flush has completed and the stream can accept more writes. - @since(version = 0.2.0) - flush: func() -> result<_, stream-error>; - - /// Request to flush buffered output, and block until flush completes - /// and stream is ready for writing again. - @since(version = 0.2.0) - blocking-flush: func() -> result<_, stream-error>; - - /// Create a `pollable` which will resolve once the output-stream - /// is ready for more writing, or an error has occurred. When this - /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an - /// error. - /// - /// If the stream is closed, this pollable is always ready immediately. - /// - /// The created `pollable` is a child resource of the `output-stream`. - /// Implementations may trap if the `output-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - - /// Write zeroes to a stream. - /// - /// This should be used precisely like `write` with the exact same - /// preconditions (must use check-write first), but instead of - /// passing a list of bytes, you simply pass the number of zero-bytes - /// that should be written. - @since(version = 0.2.0) - write-zeroes: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-zeroes-and-flush: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Read from one stream and write to another. - /// - /// The behavior of splice is equivalent to: - /// 1. calling `check-write` on the `output-stream` - /// 2. calling `read` on the `input-stream` with the smaller of the - /// `check-write` permitted length and the `len` provided to `splice` - /// 3. calling `write` on the `output-stream` with that read data. - /// - /// Any error reported by the call to `check-write`, `read`, or - /// `write` ends the splice and reports that error. - /// - /// This function returns the number of bytes transferred; it may be less - /// than `len`. - @since(version = 0.2.0) - splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - - /// Read from one stream and write to another, with blocking. - /// - /// This is similar to `splice`, except that it blocks until the - /// `output-stream` is ready for writing, and the `input-stream` - /// is ready for reading, before performing the `splice`. - @since(version = 0.2.0) - blocking-splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - } -} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit deleted file mode 100644 index 84c85c08e..000000000 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/world.wit +++ /dev/null @@ -1,10 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import streams; - - @since(version = 0.2.0) - import poll; -} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit index f6b69b3f0..a2a664948 100644 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit @@ -1,33 +1,27 @@ -@unstable(feature = tls) +// Adapted from WebAssembly/wasi-tls wit/types.wit, +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local changes: wasi:io@0.2.12 and an availability query. See README.md. interface types { - @unstable(feature = tls) - use wasi:io/streams@0.2.6.{input-stream, output-stream}; - @unstable(feature = tls) - use wasi:io/poll@0.2.6.{pollable}; - @unstable(feature = tls) - use wasi:io/error@0.2.6.{error as io-error}; + /// Whether the host grants TLS connections. This query performs no IO. + is-available: func() -> bool; + + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + use wasi:io/error@0.2.12.{error as io-error}; - @unstable(feature = tls) resource client-handshake { - @unstable(feature = tls) constructor(server-name: string, input: input-stream, output: output-stream); - @unstable(feature = tls) finish: static func(this: client-handshake) -> future-client-streams; } - @unstable(feature = tls) resource client-connection { - @unstable(feature = tls) close-output: func(); } - @unstable(feature = tls) resource future-client-streams { - @unstable(feature = tls) subscribe: func() -> pollable; - @unstable(feature = tls) get: func() -> option, io-error>>>; } } diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit index 8efd9593d..2c7093d93 100644 --- a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit @@ -1,7 +1,8 @@ +// Adapted from WebAssembly/wasi-tls wit/world.wit at +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local change: omit unstable-feature gates; see README.md. package wasi:tls@0.2.0-draft; -@unstable(feature = tls) world imports { - @unstable(feature = tls) import types; } From 749fbf327914bf0b0f1c823c9ac2c69b761dbd0c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:16:26 +0000 Subject: [PATCH 44/68] refactor(jco): remove TLS-specific componentization handling --- packages/jco/src/cmd/componentize.ts | 63 +++++----------------------- packages/jco/src/cmd/transpile.ts | 2 - packages/jco/src/node-builtins.ts | 8 +--- packages/jco/src/node-wit.ts | 40 +++++++----------- packages/jco/src/wit-features.ts | 45 -------------------- 5 files changed, 27 insertions(+), 131 deletions(-) delete mode 100644 packages/jco/src/wit-features.ts diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index e599377d4..f4ef25cd8 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -1,4 +1,3 @@ -import { resolveWitFeatures } from "../wit-features.js"; import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve, basename, dirname, extname, join } from "node:path"; @@ -107,24 +106,7 @@ const STARLINGMONKEY_OPTIONS: Array = [ */ export async function worldMetadataFor(witPath: string, worldName?: string): Promise { const path = (isWindows ? "//?/" : "") + resolve(witPath); - try { - return (await componentWitMetadataForWorld({ tag: "path", val: path }, worldName)) as WorldMetadata; - } catch (error) { - // wasi:tls is an explicitly imported unstable proposal. Resolve its feature - // without stripping annotations from the user's or vendored WIT. - if (!String(error).includes("interface not found in package")) { - throw error; - } - const resolved = await resolveWitFeatures(path, worldName, ["tls"]); - try { - return (await componentWitMetadataForWorld( - { tag: "path", val: resolved.witPath }, - resolved.worldName, - )) as WorldMetadata; - } finally { - await resolved.cleanup(); - } - } + return (await componentWitMetadataForWorld({ tag: "path", val: path }, worldName)) as WorldMetadata; } /** Re-bundle an entry wrapper that explicitly implements guest callback interface exports. */ @@ -221,8 +203,7 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): nodeBuiltinPlugin(await worldMetadataFor(witPath, opts.worldName), { nodejsHttpVia: opts.nodejsHttpVia ?? opts.withNodejsHttpVia, nodejsHttp2Via: opts.nodejsHttp2Via ?? opts.withNodejsHttp2Via, - // StarlingMonkey's built-in socket modules are currently WASI 0.2.10. - // Using that exact version preserves its cross-interface resource identities. + // Match the socket bindings supplied by the selected component engine. wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", onWitRequirement(requirement: NodeWitRequirement) { witRequirements.set(requirement.witImport, requirement); @@ -256,15 +237,7 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): // Build the component let component; - const requiresTls = source.includes("wasi:tls/types@0.2.0-draft"); - const resolvedWit = requiresTls ? await resolveWitFeatures(witPath, opts.worldName, ["tls"]) : undefined; - const backendArgs = { - source, - sourceName, - jsSource, - witPath: resolvedWit?.witPath ?? witPath, - opts: resolvedWit ? { ...opts, worldName: resolvedWit.worldName } : opts, - }; + const backendArgs = { source, sourceName, jsSource, witPath, opts }; // componentize-js reads the process working directory to decide the path prefix baked into // the component and the directory it preopens. Pin it so the build does not depend on where // the command ran, restoring the caller's directory afterwards. @@ -296,7 +269,6 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): throw err; } finally { process.chdir(callerCwd); - await resolvedWit?.cleanup(); } // Write out the component @@ -385,27 +357,14 @@ function calculateFeatureSet(opts: ComponentizeOptions) { async function componentizeQJS(args: BackendComponentizeArgs) { const { source, jsSource, opts, witPath } = args; const componentizeQJSModule = await eval('import("componentize-qjs")'); - try { - const result = await componentizeQJSModule.componentize({ - witPath, - jsSource: source, - jsPath: resolve(jsSource), - world: opts.worldName, - sync: opts.backendQjsDisableAysnc, - }); - return result.component; - } catch (error) { - if ( - String(error).includes("wasi:tls/types@0.2.0-draft") && - String(error).includes("mismatched resource types") - ) { - throw new Error( - "QuickJS's snapshot linker cannot reconcile the shared IO resource types imported by wasi:tls@0.2.0-draft (wasi:io@0.2.6). This is a componentize-qjs build-time limitation; --backend starlingmonkey is a workaround.", - { cause: error }, - ); - } - throw error; - } + const result = await componentizeQJSModule.componentize({ + witPath, + jsSource: source, + jsPath: resolve(jsSource), + world: opts.worldName, + sync: opts.backendQjsDisableAysnc, + }); + return result.component; } /** Componentize with componentize-js (StarlingMonkey) */ diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index b74e71f4e..1ffedc1ba 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -46,8 +46,6 @@ const HTTP2_ASYNC_IMPORTS = [ ]; const DEFAULT_NODE_CAPABILITY_MAP = { "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/console@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host", diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 03c22ffe3..134b86dfc 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -747,11 +747,7 @@ function protocolWasiSocketsAdapter( ): string { const factory = PROTOCOL_FACTORY[protocol]; const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; - const tlsImports = - protocol === "https" - ? `import * as tls from "wasi:tls/types@0.2.0-draft"; -import * as tlsStreamBridge from "jco:tls-streams-${version.replaceAll(".", "-")}/bridge@0.1.0";` - : ""; + const tlsImports = protocol === "https" ? 'import * as tls from "wasi:tls/types@0.2.0-draft";' : ""; return ` import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; @@ -759,7 +755,7 @@ import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; ${tlsImports} import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiSocketsHttpImplementation } from ${JSON.stringify(implementationModule)}; -${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule}${protocol === "https" ? ", tls, tlsStreamBridge" : ""} }))`)} +${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule}${protocol === "https" ? ", tls" : ""} }))`)} `; } diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index f95c19d0e..37d0bd8f8 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -211,38 +211,26 @@ function forHttps(requirements: readonly NodeWitRequirement[]): NodeWitRequireme return requirements.map((requirement) => ({ ...requirement, nodeSpecifier: "node:https" })); } -function tlsRequirements(version: "0.2.10" | "0.2.12"): NodeWitRequirement[] { +function tlsRequirements(): NodeWitRequirement[] { const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); - const bridge = `tls-streams-${version.replaceAll(".", "-")}`; - const dependencies: WitDependencyPackage[] = [ - { - dependencyDirectory: "wasi-tls-0.2.0-draft", - dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), - }, - { - dependencyDirectory: "wasi-io-0.2.6", - dependencySources: ["world.wit", "streams.wit", "poll.wit", "error.wit"].map((name) => - fileURLToPath(new URL(`deps/io/${name}`, tlsRoot)), - ), - }, - wasiDependency("wasi-io", version), - { - dependencyDirectory: bridge, - dependencySources: [fileURLToPath(new URL(`../lib/wit/builtin/${bridge}/package.wit`, import.meta.url))], - }, - ]; return forHttps([ - wasiRequirement("wasi:tls/types@0.2.0-draft", dependencies), - wasiRequirement(`jco:${bridge}/bridge@0.1.0`, dependencies), + wasiRequirement("wasi:tls/types@0.2.0-draft", [ + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), + }, + WASI_IO_DEPENDENCY, + ]), ]); } -export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ - ...forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS), - ...tlsRequirements("0.2.12"), -]; export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ ...forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS), - ...tlsRequirements("0.2.10"), + ...tlsRequirements(), +] as const; + +export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ + ...forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS), + ...tlsRequirements(), ]; export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forHttps(HTTP_WASI_HTTP_WIT_REQUIREMENTS); diff --git a/packages/jco/src/wit-features.ts b/packages/jco/src/wit-features.ts deleted file mode 100644 index bb2ae6a1f..000000000 --- a/packages/jco/src/wit-features.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { componentEmbed, componentNew, componentWit } from "@bytecodealliance/jco-transpile/wasm-tools"; - -export interface ResolvedWit { - witPath: string; - worldName: string; - cleanup(): Promise; -} - -/** - * Resolve opt-in WIT features before handing the graph to component backends that - * cannot enable them. Binary WIT dependencies preserve resource identities and - * feature metadata. The source WIT, including upstream annotations, is untouched. - */ -export async function resolveWitFeatures( - witPath: string, - worldName: string | undefined, - features: string[], -): Promise { - const core = await componentEmbed({ - witPath: resolve(witPath), - world: worldName, - dummy: true, - features: { tag: "list", val: features }, - }); - const sections = WebAssembly.Module.customSections(new WebAssembly.Module(new Uint8Array(core)), "component-type"); - if (sections.length !== 1) { - throw new Error("Expected one resolved WIT component-type section"); - } - // The dummy component gives us a root world referring to the original graph. - // Keep that graph once, in binary form, so multiple IO versions are not re-added. - const world = await componentWit(await componentNew(core, [])); - const root = await mkdtemp(join(tmpdir(), "jco-wit-features-")); - try { - await mkdir(join(root, "deps")); - await writeFile(join(root, "deps", "resolved.wasm"), new Uint8Array(sections[0])); - await writeFile(join(root, "world.wit"), world.replace("package root:component;", "package jco:resolved-wit;")); - } catch (error) { - await rm(root, { recursive: true, force: true }); - throw error; - } - return { witPath: root, worldName: "root", cleanup: () => rm(root, { recursive: true, force: true }) }; -} From 40634f58fe1dde67719546059ddfe686f36cb7a5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:17:26 +0000 Subject: [PATCH 45/68] test(jco): verify TLS WIT without feature preprocessing --- packages/jco/test/node/builtins.js | 4 --- packages/jco/test/node/tls-wit.ts | 46 +++++++++++++++++------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index afd5052e0..9aa7266e4 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -35,8 +35,6 @@ describe("Node builtin adapters", () => { test.concurrent("maps host-backed Node APIs to deny providers unless the application opts in", () => { expect(withDefaultNodeCapabilityMap()).toEqual({ "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", @@ -56,8 +54,6 @@ describe("Node builtin adapters", () => { }), ).toEqual({ "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-10/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", - "jco:tls-streams-0-2-12/bridge@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", diff --git a/packages/jco/test/node/tls-wit.ts b/packages/jco/test/node/tls-wit.ts index 069033160..ce0d6aaf8 100644 --- a/packages/jco/test/node/tls-wit.ts +++ b/packages/jco/test/node/tls-wit.ts @@ -4,36 +4,42 @@ import { tmpdir } from "node:os"; import { expect, test } from "vitest"; import { injectNodeWitImports, HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS } from "../../src/node-wit.js"; import { worldMetadataFor } from "../../src/cmd/componentize.js"; -import { resolveWitFeatures } from "../../src/wit-features.js"; -test.concurrent("TLS WIT injection is idempotent and feature resolution preserves upstream sources", async (): Promise => { +test.concurrent("TLS WIT injection is idempotent and shares IO 0.2.12 without feature handling", async (): Promise => { const root = await mkdtemp(join(tmpdir(), "jco-tls-wit-")); try { const source = "package tests:tls; world untouched {} world component { export run: func(); }\n"; await writeFile(join(root, "world.wit"), source); await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS); const tlsPath = join(root, "deps/wasi-tls-0.2.0-draft/types.wit"); - const upstream = await readFile(tlsPath, "utf8"); - expect(upstream).toContain("@unstable(feature = tls)"); - expect(upstream).toContain("wasi:io/streams@0.2.6"); + const contract = await readFile(tlsPath, "utf8"); + expect(contract).not.toContain("@unstable"); + expect(contract).toContain("wasi:io/streams@0.2.12"); + expect(contract).toContain("is-available: func() -> bool"); expect(await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS)).toBeUndefined(); const world = await readFile(join(root, "world.wit"), "utf8"); expect(world).toContain("world untouched {}"); - const resolved = await resolveWitFeatures(root, "component", ["tls"]); - try { - const metadata = await worldMetadataFor(resolved.witPath, resolved.worldName); - expect(metadata.imports).toContainEqual( - expect.objectContaining({ namespace: "wasi", package: "tls", interface: "types" }), - ); - expect(metadata.imports).toContainEqual( - expect.objectContaining({ namespace: "wasi", package: "sockets", interface: "tcp" }), - ); - expect(metadata.exports).toEqual([]); // Free-standing functions are not interface metadata. - expect(await readFile(tlsPath, "utf8")).toBe(upstream); - expect(await readFile(join(root, "world.wit"), "utf8")).toBe(world); - } finally { - await resolved.cleanup(); - } + const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "tls", interface: "types" }), + ); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "sockets", interface: "tcp" }), + ); + expect(metadata.exports).toEqual([]); // Free-standing functions are not interface metadata. + expect(metadata.imports).toContainEqual( + expect.objectContaining({ + package: "io", + interface: "streams", + version: expect.objectContaining({ patch: 12n }), + }), + ); + expect( + metadata.imports.filter((iface) => iface.package === "io").every((iface) => iface.version?.patch === 12n), + ).toBe(true); + expect(metadata.imports.some((iface) => iface.namespace === "jco")).toBe(false); + expect(await readFile(tlsPath, "utf8")).toBe(contract); + expect(await readFile(join(root, "world.wit"), "utf8")).toBe(world); expect((await worldMetadataFor(root, "component")).imports).toContainEqual( expect.objectContaining({ package: "tls" }), ); From 5c012bcff6e9199686c27d4c05799936d699448e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:18:34 +0000 Subject: [PATCH 46/68] refactor(p2-shim): expose generic IO integration for host providers --- packages/preview2-shim/package.json | 6 +- packages/preview2-shim/src/io/calls.ts | 8 +- packages/preview2-shim/src/io/extension.ts | 10 + .../preview2-shim/src/io/worker-thread.ts | 58 +++-- packages/preview2-shim/src/io/worker-tls.ts | 112 --------- .../preview2-shim/src/nodejs/io-worker.ts | 48 ++++ packages/preview2-shim/src/nodejs/tls.ts | 216 ------------------ 7 files changed, 91 insertions(+), 367 deletions(-) create mode 100644 packages/preview2-shim/src/io/extension.ts delete mode 100644 packages/preview2-shim/src/io/worker-tls.ts create mode 100644 packages/preview2-shim/src/nodejs/io-worker.ts delete mode 100644 packages/preview2-shim/src/nodejs/tls.ts diff --git a/packages/preview2-shim/package.json b/packages/preview2-shim/package.json index 1ad888685..6ac0c243c 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -55,9 +55,9 @@ "./interfaces/*": { "types": "./types/interfaces/*.d.ts" }, - "./tls": { - "types": "./dist/nodejs/tls.d.ts", - "node": "./dist/nodejs/tls.js" + "./io-worker": { + "types": "./dist/nodejs/io-worker.d.ts", + "node": "./dist/nodejs/io-worker.js" } }, "scripts": { diff --git a/packages/preview2-shim/src/io/calls.ts b/packages/preview2-shim/src/io/calls.ts index dd3d6bbab..016e4b28d 100644 --- a/packages/preview2-shim/src/io/calls.ts +++ b/packages/preview2-shim/src/io/calls.ts @@ -130,12 +130,8 @@ export const SOCKET_RESOLVE_ADDRESS_TAKE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_SUBSCRIBE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_DISPOSE_REQUEST = ++call_id << CALL_SHIFT; -// Opt-in wasi:tls provider operations, executed on the existing IO worker. -export const TLS_START = ++call_id << CALL_SHIFT; -export const TLS_CLOSE_OUTPUT = ++call_id << CALL_SHIFT; -export const TLS_DISPOSE = ++call_id << CALL_SHIFT; -export const TLS_STREAMS = ++call_id << CALL_SHIFT; -export const TLS_RESOURCE_COUNTS = ++call_id << CALL_SHIFT; +// Host extensions execute alongside the streams they operate on. +export const WORKER_EXTENSION_CALL = ++call_id << CALL_SHIFT; export const reverseMap = {}; diff --git a/packages/preview2-shim/src/io/extension.ts b/packages/preview2-shim/src/io/extension.ts new file mode 100644 index 000000000..1566c60e3 --- /dev/null +++ b/packages/preview2-shim/src/io/extension.ts @@ -0,0 +1,10 @@ +/** IO-worker integration for opt-in host providers. No capability is installed by importing this module. */ +import type { Readable, Writable } from "node:stream"; +export interface WorkerExtensionContext { + createFuture(promise: Promise): number; + createReadableStream(stream: Readable): number; + createWritableStream(stream: Writable): number; + getStream(id: number): unknown; + resourceCounts(): { streams: number; futures: number; polls: number; sockets: number }; +} +export type WorkerExtension = (operation: string, args: unknown[]) => unknown | Promise; diff --git a/packages/preview2-shim/src/io/worker-thread.ts b/packages/preview2-shim/src/io/worker-thread.ts index 2dd666762..932809212 100644 --- a/packages/preview2-shim/src/io/worker-thread.ts +++ b/packages/preview2-shim/src/io/worker-thread.ts @@ -1,17 +1,5 @@ -import { - TLS_START, - TLS_CLOSE_OUTPUT, - TLS_DISPOSE, - TLS_STREAMS, - TLS_RESOURCE_COUNTS, -} from "./calls.js"; -import { - tlsStart, - tlsCloseOutput, - tlsDispose, - tlsStreams, - tlsConnectionCount, -} from "./worker-tls.js"; +import { WORKER_EXTENSION_CALL } from "./calls.js"; +import type { WorkerExtension, WorkerExtensionContext } from "./extension.js"; import { createReadStream, createWriteStream, PathLike } from "node:fs"; import { hrtime, stderr, stdout } from "node:process"; import { PassThrough } from "node:stream"; @@ -335,6 +323,30 @@ export function getStreamOrThrow(streamId) { return stream; } +const extensions = new Map>(); +async function callExtension(module: string, operation: string, args: unknown[]): Promise { + let extension = extensions.get(module); + if (!extension) { + extension = import(module).then(({ default: create }) => { + const context: WorkerExtensionContext = { + createFuture: (promise) => createFuture(promise, undefined), + createReadableStream, + createWritableStream, + getStream: (id) => getStreamOrThrow(id).stream, + resourceCounts: () => ({ + streams: streams.size, + futures: futures.size, + polls: polls.size, + sockets: tcpSockets.size, + }), + }; + return create(context); + }); + extensions.set(module, extension); + } + return (await extension)(operation, args); +} + /** * @param {number} call * @param {number | null} id @@ -346,22 +358,8 @@ function handle(call, id, payload) { throw uncaughtException; } switch (call) { - case TLS_START: - return tlsStart(payload); - case TLS_STREAMS: - return tlsStreams(id); - case TLS_RESOURCE_COUNTS: - return { - tls: tlsConnectionCount(), - streams: streams.size, - futures: futures.size, - polls: polls.size, - sockets: tcpSockets.size, - }; - case TLS_CLOSE_OUTPUT: - return tlsCloseOutput(id); - case TLS_DISPOSE: - return tlsDispose(id); + case WORKER_EXTENSION_CALL: + return callExtension(payload.module, payload.operation, payload.args); // Http case HTTP_CREATE_REQUEST: { const { diff --git a/packages/preview2-shim/src/io/worker-tls.ts b/packages/preview2-shim/src/io/worker-tls.ts deleted file mode 100644 index 878900e23..000000000 --- a/packages/preview2-shim/src/io/worker-tls.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Native TLS over the streams already owned by the Preview 2 IO worker. */ -import { Duplex, Readable, Writable } from "node:stream"; -import { connect, checkServerIdentity, type TLSSocket } from "node:tls"; -import { isIP } from "node:net"; -import { - createFuture, - createReadableStream, - createWritableStream, - getStreamOrThrow, -} from "./worker-thread.js"; - -export interface TlsStartOptions { - serverName: string; - input: number; - output: number; - ca?: string[]; - handshakeTimeoutMs: number; -} -interface Connection { - socket: TLSSocket; - timer: ReturnType; -} -const connections = new Map(); -let nextId = 0; - -export function tlsStart(options: TlsStartOptions): { connection: number; future: number } { - const readable: unknown = getStreamOrThrow(options.input).stream; - const writable: unknown = getStreamOrThrow(options.output).stream; - if (!(readable instanceof Readable) || !(writable instanceof Writable)) { - throw new Error("wasi:tls requires Node-backed readable and writable streams"); - } - // This Duplex consumes precisely the supplied streams. No DNS lookup or replacement - // TCP connection is possible: tls.connect receives an already-connected transport. - const transport = Duplex.from({ readable, writable }); - const socket = connect({ - socket: transport, - servername: isIP(options.serverName) ? undefined : options.serverName, - rejectUnauthorized: true, - checkServerIdentity: (_host, certificate) => - checkServerIdentity(options.serverName, certificate), - ALPNProtocols: ["http/1.1"], - ca: options.ca, - }); - // Duplex.from may emit AbortError when TLS destroys an incomplete transport. - // Keep an error listener for the whole transport lifetime, including shutdown. - transport.on("error", (error: Error): void => { - socket.destroy(error); - }); - const connection = ++nextId; - const timer = setTimeout( - () => socket.destroy(new Error("TLS handshake timed out")), - options.handshakeTimeoutMs, - ); - connections.set(connection, { socket, timer }); - const future = createFuture( - new Promise((resolve, reject) => { - const fail = (error: Error): void => { - clearTimeout(timer); - reject({ - message: error.message, - code: "code" in error ? String(error.code) : "ERR_TLS_HANDSHAKE", - }); - }; - socket.on("error", fail); - socket.once("close", () => fail(new Error("TLS connection closed during handshake"))); - socket.once("secureConnect", () => { - clearTimeout(timer); - if (socket.alpnProtocol && socket.alpnProtocol !== "http/1.1") { - socket.destroy(new Error("TLS peer negotiated a protocol other than HTTP/1.1")); - return; - } - resolve(); - }); - }), - undefined, - ); - return { connection, future }; -} - -export function tlsCloseOutput(id: number): Promise { - const connection = connections.get(id); - if (!connection) { - throw new Error("wasi:tls connection was disposed"); - } - return new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error); - connection.socket.once("error", onError); - connection.socket.end(() => { - connection.socket.off("error", onError); - resolve(); - }); - }); -} - -export function tlsDispose(id: number): void { - const connection = connections.get(id); - if (!connection) { - return; - } - clearTimeout(connection.timer); - connection.socket.destroy(); - connections.delete(id); -} - -export function tlsStreams(id: number): [number, number] { - const socket = connections.get(id)!.socket; - return [createReadableStream(socket), createWritableStream(socket)]; -} - -export function tlsConnectionCount(): number { - return connections.size; -} diff --git a/packages/preview2-shim/src/nodejs/io-worker.ts b/packages/preview2-shim/src/nodejs/io-worker.ts new file mode 100644 index 000000000..175e83b52 --- /dev/null +++ b/packages/preview2-shim/src/nodejs/io-worker.ts @@ -0,0 +1,48 @@ +/** Low-level integration with the shared Node IO worker for opt-in host providers. */ +import * as io from "../io/worker-io.js"; +import { + WORKER_EXTENSION_CALL, + SOCKET_TCP, + FUTURE_TAKE_VALUE, + FUTURE_SUBSCRIBE, + FUTURE_DISPOSE, +} from "../io/calls.js"; +import type { InputStream, OutputStream } from "../../types/interfaces/wasi-io-streams.js"; +import type { Pollable } from "../../types/interfaces/wasi-io-poll.js"; +import type { Error as IoError } from "../../types/interfaces/wasi-io-error.js"; +export type { WorkerExtension, WorkerExtensionContext } from "../io/extension.js"; + +export type FutureResult = + | { tag: "err"; val?: undefined } + | { tag: "ok"; val: { tag: "ok"; val: T } | { tag: "err"; val: E } } + | undefined; + +/** Loads a host-selected module once in the existing worker; never accepts a guest module path. */ +export function callExtension(module: URL, operation: string, args: unknown[]): unknown { + return io.ioCall(WORKER_EXTENSION_CALL, null, { module: module.href, operation, args }); +} +export function inputStreamId(stream: InputStream): number { + return io.inputStreamId(stream); +} +export function outputStreamId(stream: OutputStream): number { + return io.outputStreamId(stream); +} +export function inputStreamCreate(id: number): InputStream { + return io.inputStreamCreate(SOCKET_TCP, id); +} +export function outputStreamCreate(id: number): OutputStream { + return io.outputStreamCreate(SOCKET_TCP, id); +} +export function futureSubscribe(id: number, parent: object): Pollable { + return io.pollableCreate(io.ioCall(FUTURE_SUBSCRIBE, id, undefined), parent); +} +/** T and E are the promise's value and rejection types chosen by the host extension. */ +export function futureTakeValue(id: number): FutureResult { + return io.ioCall(FUTURE_TAKE_VALUE, id, undefined); +} +export function futureDispose(id: number): void { + io.ioCall(FUTURE_DISPOSE, id, undefined); +} +export function createIoError(message: string): IoError { + return new io.error.Error(message); +} diff --git a/packages/preview2-shim/src/nodejs/tls.ts b/packages/preview2-shim/src/nodejs/tls.ts deleted file mode 100644 index d4cd65306..000000000 --- a/packages/preview2-shim/src/nodejs/tls.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Opt-in host implementation of WebAssembly/wasi-tls wit/types.wit at - * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). - * The upstream interface is unchanged; host trust and deadlines are local policy. - */ -import { - ioCall, - inputStreamId, - outputStreamId, - inputStreamCreate, - outputStreamCreate, - pollableCreate, - error, -} from "../io/worker-io.js"; -import { - TLS_START, - TLS_STREAMS, - TLS_CLOSE_OUTPUT, - TLS_DISPOSE, - TLS_RESOURCE_COUNTS, - SOCKET_TCP, - FUTURE_TAKE_VALUE, - FUTURE_SUBSCRIBE, - FUTURE_DISPOSE, -} from "../io/calls.js"; -import type { InputStream, OutputStream } from "../../types/interfaces/wasi-io-streams.js"; -import type { Pollable } from "../../types/interfaces/wasi-io-poll.js"; - -export interface TlsHostOptions { - ca?: string[]; - handshakeTimeoutMs?: number; -} -export interface IoError { - toDebugString(): string; - [Symbol.dispose]?(): void; -} -export type ClientStreamsResult = - | { tag: "err"; val?: undefined } - | { - tag: "ok"; - val: - | { tag: "err"; val: IoError } - | { tag: "ok"; val: [ClientConnection, InputStream, OutputStream] }; - }; -interface OwnedTransport { - input: InputStream; - output: OutputStream; -} -function disposeStream(stream: InputStream | OutputStream): void { - const drop: unknown = Symbol.dispose in stream ? stream[Symbol.dispose] : undefined; - if (typeof drop !== "function") { - throw new TypeError("wasi:tls requires disposable IO resources"); - } - drop.call(stream); -} - -export class ClientConnection { - readonly #id: number; - readonly #transport: OwnedTransport; - #disposed = false; - constructor(id: number, transport: OwnedTransport) { - this.#id = id; - this.#transport = transport; - } - closeOutput(): void { - ioCall(TLS_CLOSE_OUTPUT, this.#id, undefined); - } - [Symbol.dispose](): void { - if (this.#disposed) { - return; - } - this.#disposed = true; - ioCall(TLS_DISPOSE, this.#id, undefined); - disposeStream(this.#transport.output); - disposeStream(this.#transport.input); - } -} - -export class FutureClientStreams { - readonly #id: number; - readonly #connectionId: number; - readonly #connection: ClientConnection; - #taken = false; - #disposed = false; - constructor(id: number, connectionId: number, transport: OwnedTransport) { - this.#id = id; - this.#connectionId = connectionId; - this.#connection = new ClientConnection(connectionId, transport); - } - subscribe(): Pollable { - return pollableCreate(ioCall(FUTURE_SUBSCRIBE, this.#id, undefined), this); - } - get(): ClientStreamsResult | undefined { - const value: - | { tag: "err"; val?: undefined } - | { - tag: "ok"; - val: - | { tag: "ok"; val: undefined } - | { tag: "err"; val: { message: string; code: string } }; - } - | undefined = ioCall(FUTURE_TAKE_VALUE, this.#id, undefined); - if (!value) { - return undefined; - } - if (value.tag === "err") { - return { tag: "err", val: undefined }; - } - if (value.val.tag === "err") { - return { tag: "ok", val: { tag: "err", val: new error.Error(value.val.val.message) } }; - } - const [input, output]: [number, number] = ioCall( - TLS_STREAMS, - this.#connectionId, - undefined, - ); - this.#taken = true; - return { - tag: "ok", - val: { - tag: "ok", - val: [ - this.#connection, - inputStreamCreate(SOCKET_TCP, input), - outputStreamCreate(SOCKET_TCP, output), - ], - }, - }; - } - [Symbol.dispose](): void { - if (this.#disposed) { - return; - } - ioCall(FUTURE_DISPOSE, this.#id, undefined); - this.#disposed = true; - if (!this.#taken) { - this.#connection[Symbol.dispose](); - } - } -} - -export interface ClientHandshakeResource { - [Symbol.dispose](): void; -} -export interface TlsProvider { - ClientHandshake: { - new (serverName: string, input: InputStream, output: OutputStream): ClientHandshakeResource; - finish(handshake: ClientHandshakeResource): FutureClientStreams; - }; - ClientConnection: typeof ClientConnection; - FutureClientStreams: typeof FutureClientStreams; -} - -export function createTlsProvider(options: TlsHostOptions = {}): TlsProvider { - const ca = options.ca?.slice(); - const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; - if (!Number.isSafeInteger(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) { - throw new RangeError("handshakeTimeoutMs must be a positive safe integer"); - } - class ClientHandshake implements ClientHandshakeResource { - #transport: OwnedTransport | undefined; - readonly #serverName: string; - constructor(serverName: string, input: InputStream, output: OutputStream) { - this.#serverName = serverName; - this.#transport = { input, output }; - } - static finish(value: ClientHandshakeResource): FutureClientStreams { - if (!(value instanceof ClientHandshake) || !value.#transport) { - throw new Error( - "wasi:tls handshake already consumed or belongs to another provider", - ); - } - const transport = value.#transport; - const result: { connection: number; future: number } = ioCall(TLS_START, null, { - serverName: value.#serverName, - input: inputStreamId(transport.input), - output: outputStreamId(transport.output), - ca, - handshakeTimeoutMs, - }); - value.#transport = undefined; - return new FutureClientStreams(result.future, result.connection, transport); - } - [Symbol.dispose](): void { - if (this.#transport) { - disposeStream(this.#transport.output); - disposeStream(this.#transport.input); - } - this.#transport = undefined; - } - } - return { ClientHandshake, ClientConnection, FutureClientStreams }; -} - -export const { ClientHandshake } = createTlsProvider(); - -/** Own-to-own version conversion: preserve the actual stream resources and connection. */ -export function adapt(input: InputStream, output: OutputStream): [InputStream, OutputStream] { - return [input, output]; -} - -/** Host diagnostics for detecting owned IO resource leaks; not a WIT operation. */ -export function _resourceCounts(): { - tls: number; - streams: number; - futures: number; - polls: number; - sockets: number; -} { - return ioCall(TLS_RESOURCE_COUNTS, null, undefined); -} - -/** Jco IO version bridge capability check; not a wasi:tls operation. */ -export function isAvailable(): boolean { - return true; -} From 4f7a2daeeb55cc566ac62a5b68561c03dc50ba3c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:19:10 +0000 Subject: [PATCH 47/68] refactor(std): own the opt-in Node TLS provider --- packages/jco-std/package.json | 12 + .../0.2.x/node/24.x.x/tls-host-node-worker.ts | 172 ++++++++++++++ .../wasi/0.2.x/node/24.x.x/tls-host-node.ts | 210 ++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 94554b252..ea5e02c00 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -361,6 +361,10 @@ "./wasi/0.2.x/node/24.x.x/tls/host": { "types": "./dist/wasi/0.2.x/node/24.x.x/tls-host.d.ts", "default": "./dist/wasi/0.2.x/node/24.x.x/tls-host.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls-host-node.d.ts", + "node": "./dist/wasi/0.2.x/node/24.x.x/tls-host-node.js" } }, "scripts": { @@ -394,5 +398,13 @@ "typescript": "catalog:", "vitest": "^4.0.8", "which": "^5.0.0" + }, + "peerDependencies": { + "@bytecodealliance/preview2-shim": "^0.24.0" + }, + "peerDependenciesMeta": { + "@bytecodealliance/preview2-shim": { + "optional": true + } } } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts new file mode 100644 index 000000000..134bb029f --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts @@ -0,0 +1,172 @@ +/** Native TLS over the streams already owned by the Preview 2 IO worker. */ +import { Duplex, Readable, Writable } from "node:stream"; +import { connect, checkServerIdentity, type TLSSocket } from "node:tls"; +import { isIP } from "node:net"; +import type { + WorkerExtension, + WorkerExtensionContext, +} from "@bytecodealliance/preview2-shim/io-worker"; + +export interface TlsStartOptions { + serverName: string; + input: number; + output: number; + ca?: string[]; + handshakeTimeoutMs: number; +} +export interface TlsWorkerOperations { + start: { args: [TlsStartOptions]; result: { connection: number; future: number } }; + streams: { args: [number]; result: [number, number] }; + "close-output": { args: [number]; result: void }; + dispose: { args: [number]; result: void }; + counts: { + args: []; + result: { tls: number; streams: number; futures: number; polls: number; sockets: number }; + }; +} +interface Connection { + socket: TLSSocket; + timer: ReturnType; +} +export default function createTlsWorker(context: WorkerExtensionContext): WorkerExtension { + const { createFuture, createReadableStream, createWritableStream } = context; + const connections = new Map(); + let nextId = 0; + + function tlsStart(options: TlsStartOptions): { connection: number; future: number } { + const readable: unknown = context.getStream(options.input); + const writable: unknown = context.getStream(options.output); + if (!(readable instanceof Readable) || !(writable instanceof Writable)) { + throw new Error("wasi:tls requires Node-backed readable and writable streams"); + } + // This Duplex consumes precisely the supplied streams. No DNS lookup or replacement + // TCP connection is possible: tls.connect receives an already-connected transport. + const transport = Duplex.from({ readable, writable }); + const socket = connect({ + socket: transport, + servername: isIP(options.serverName) ? undefined : options.serverName, + rejectUnauthorized: true, + checkServerIdentity: (_host, certificate) => + checkServerIdentity(options.serverName, certificate), + ALPNProtocols: ["http/1.1"], + ca: options.ca, + }); + // Duplex.from may emit AbortError when TLS destroys an incomplete transport. + // Keep an error listener for the whole transport lifetime, including shutdown. + transport.on("error", (error: Error): void => { + socket.destroy(error); + }); + const connection = ++nextId; + const timer = setTimeout( + () => socket.destroy(new Error("TLS handshake timed out")), + options.handshakeTimeoutMs, + ); + connections.set(connection, { socket, timer }); + const future = createFuture( + new Promise((resolve, reject) => { + const fail = (error: Error): void => { + clearTimeout(timer); + reject({ + message: error.message, + code: "code" in error ? String(error.code) : "ERR_TLS_HANDSHAKE", + }); + }; + socket.on("error", fail); + socket.once("close", () => fail(new Error("TLS connection closed during handshake"))); + socket.once("secureConnect", () => { + clearTimeout(timer); + if (socket.alpnProtocol && socket.alpnProtocol !== "http/1.1") { + socket.destroy(new Error("TLS peer negotiated a protocol other than HTTP/1.1")); + return; + } + resolve(); + }); + }), + ); + return { connection, future }; + } + + function tlsCloseOutput(id: number): Promise { + const connection = connections.get(id); + if (!connection) { + throw new Error("wasi:tls connection was disposed"); + } + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error); + connection.socket.once("error", onError); + connection.socket.end(() => { + connection.socket.off("error", onError); + resolve(); + }); + }); + } + + function tlsDispose(id: number): void { + const connection = connections.get(id); + if (!connection) { + return; + } + clearTimeout(connection.timer); + connection.socket.destroy(); + connections.delete(id); + } + + function tlsStreams(id: number): [number, number] { + const socket = connections.get(id)!.socket; + return [createReadableStream(socket), createWritableStream(socket)]; + } + + function tlsConnectionCount(): number { + return connections.size; + } + + return (operation: string, args: unknown[]): unknown => { + const value = args[0]; + if (operation === "start") { + if ( + typeof value !== "object" || + value === null || + !("serverName" in value) || + typeof value.serverName !== "string" || + !("input" in value) || + typeof value.input !== "number" || + !("output" in value) || + typeof value.output !== "number" || + !("handshakeTimeoutMs" in value) || + typeof value.handshakeTimeoutMs !== "number" + ) { + throw new TypeError("Invalid TLS worker request"); + } + const ca = "ca" in value ? value.ca : undefined; + if ( + ca !== undefined && + (!Array.isArray(ca) || !ca.every((item: unknown) => typeof item === "string")) + ) { + throw new TypeError("Invalid TLS trust roots"); + } + return tlsStart({ + serverName: value.serverName, + input: value.input, + output: value.output, + handshakeTimeoutMs: value.handshakeTimeoutMs, + ca, + }); + } + if (operation === "counts") { + return { tls: tlsConnectionCount(), ...context.resourceCounts() }; + } + if (typeof value !== "number") { + throw new TypeError("Invalid TLS resource identifier"); + } + switch (operation) { + case "streams": + return tlsStreams(value); + case "close-output": + return tlsCloseOutput(value); + case "dispose": + return tlsDispose(value); + default: + throw new Error(`Unknown TLS operation: ${operation}`); + } + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts new file mode 100644 index 000000000..b7a056e33 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts @@ -0,0 +1,210 @@ +/** + * Opt-in host implementation of WebAssembly/wasi-tls wit/types.wit at + * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). + * Local contract: wasi:io@0.2.12 and an availability query. + * Host trust and deadlines remain host policy. + */ +import { + callExtension, + inputStreamId, + outputStreamId, + inputStreamCreate, + outputStreamCreate, + futureSubscribe, + futureTakeValue, + futureDispose, + createIoError, +} from "@bytecodealliance/preview2-shim/io-worker"; +import type { TlsWorkerOperations } from "./tls-host-node-worker.js"; +import type { + InputStream, + OutputStream, +} from "@bytecodealliance/preview2-shim/interfaces/wasi-io-streams"; +import type { Pollable } from "@bytecodealliance/preview2-shim/interfaces/wasi-io-poll"; + +function tlsCall( + operation: Operation, + ...args: TlsWorkerOperations[Operation]["args"] +): TlsWorkerOperations[Operation]["result"] { + // The companion worker implements this operation/result contract; only this host module selects it. + return callExtension( + new URL("./tls-host-node-worker.js", import.meta.url), + operation, + args, + ) as TlsWorkerOperations[Operation]["result"]; +} + +export interface TlsHostOptions { + ca?: string[]; + handshakeTimeoutMs?: number; +} +export interface IoError { + toDebugString(): string; + [Symbol.dispose]?(): void; +} +export type ClientStreamsResult = + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "err"; val: IoError } + | { tag: "ok"; val: [ClientConnection, InputStream, OutputStream] }; + }; +interface OwnedTransport { + input: InputStream; + output: OutputStream; +} +function disposeStream(stream: InputStream | OutputStream): void { + const drop: unknown = Symbol.dispose in stream ? stream[Symbol.dispose] : undefined; + if (typeof drop !== "function") { + throw new TypeError("wasi:tls requires disposable IO resources"); + } + drop.call(stream); +} + +export class ClientConnection { + readonly #id: number; + readonly #transport: OwnedTransport; + #disposed = false; + constructor(id: number, transport: OwnedTransport) { + this.#id = id; + this.#transport = transport; + } + closeOutput(): void { + tlsCall("close-output", this.#id); + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + tlsCall("dispose", this.#id); + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } +} + +export class FutureClientStreams { + readonly #id: number; + readonly #connectionId: number; + readonly #connection: ClientConnection; + #taken = false; + #disposed = false; + constructor(id: number, connectionId: number, transport: OwnedTransport) { + this.#id = id; + this.#connectionId = connectionId; + this.#connection = new ClientConnection(connectionId, transport); + } + subscribe(): Pollable { + return futureSubscribe(this.#id, this); + } + get(): ClientStreamsResult | undefined { + const value: + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "ok"; val: undefined } + | { tag: "err"; val: { message: string; code: string } }; + } + | undefined = futureTakeValue(this.#id); + if (!value) { + return undefined; + } + if (value.tag === "err") { + return { tag: "err", val: undefined }; + } + if (value.val.tag === "err") { + return { tag: "ok", val: { tag: "err", val: createIoError(value.val.val.message) } }; + } + const [input, output]: [number, number] = tlsCall("streams", this.#connectionId); + this.#taken = true; + return { + tag: "ok", + val: { + tag: "ok", + val: [this.#connection, inputStreamCreate(input), outputStreamCreate(output)], + }, + }; + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + futureDispose(this.#id); + this.#disposed = true; + if (!this.#taken) { + this.#connection[Symbol.dispose](); + } + } +} + +export interface ClientHandshakeResource { + [Symbol.dispose](): void; +} +export interface TlsProvider { + isAvailable(): boolean; + ClientHandshake: { + new (serverName: string, input: InputStream, output: OutputStream): ClientHandshakeResource; + finish(handshake: ClientHandshakeResource): FutureClientStreams; + }; + ClientConnection: typeof ClientConnection; + FutureClientStreams: typeof FutureClientStreams; +} + +export function createTlsProvider(options: TlsHostOptions = {}): TlsProvider { + const ca = options.ca?.slice(); + const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; + if (!Number.isSafeInteger(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) { + throw new RangeError("handshakeTimeoutMs must be a positive safe integer"); + } + class ClientHandshake implements ClientHandshakeResource { + #transport: OwnedTransport | undefined; + readonly #serverName: string; + constructor(serverName: string, input: InputStream, output: OutputStream) { + this.#serverName = serverName; + this.#transport = { input, output }; + } + static finish(value: ClientHandshakeResource): FutureClientStreams { + if (!(value instanceof ClientHandshake) || !value.#transport) { + throw new Error("wasi:tls handshake already consumed or belongs to another provider"); + } + const transport = value.#transport; + const result: { connection: number; future: number } = tlsCall("start", { + serverName: value.#serverName, + input: inputStreamId(transport.input), + output: outputStreamId(transport.output), + ca, + handshakeTimeoutMs, + }); + value.#transport = undefined; + return new FutureClientStreams(result.future, result.connection, transport); + } + [Symbol.dispose](): void { + if (this.#transport) { + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } + this.#transport = undefined; + } + } + return { ClientHandshake, ClientConnection, FutureClientStreams, isAvailable }; +} + +export const { ClientHandshake } = createTlsProvider(); + +/** Host diagnostics for detecting owned IO resource leaks; not a WIT operation. */ +export function _resourceCounts(): { + tls: number; + streams: number; + futures: number; + polls: number; + sockets: number; +} { + return tlsCall("counts"); +} + +/** Availability query in Jco's local TLS contract. */ +export function isAvailable(): boolean { + return true; +} From 880ce1638015882fdff7c374aaaf1d2be96b8098 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:20:06 +0000 Subject: [PATCH 48/68] test(p2-shim): cover host IO extension failure and ownership --- .../preview2-shim/test/fixtures/io-worker.ts | 19 ++++++++++ packages/preview2-shim/test/io-worker.ts | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 packages/preview2-shim/test/fixtures/io-worker.ts create mode 100644 packages/preview2-shim/test/io-worker.ts diff --git a/packages/preview2-shim/test/fixtures/io-worker.ts b/packages/preview2-shim/test/fixtures/io-worker.ts new file mode 100644 index 000000000..2fa0696d5 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/io-worker.ts @@ -0,0 +1,19 @@ +import type { WorkerExtension, WorkerExtensionContext } from "../../src/io/extension.ts"; + +export default function create(context: WorkerExtensionContext): WorkerExtension { + let calls = 0; + return (operation: string): unknown => { + switch (operation) { + case "count": + return ++calls; + case "fail": + throw new Error("host extension failed"); + case "rejected-future": + return context.createFuture(Promise.reject({ message: "handshake failed" })); + case "resources": + return context.resourceCounts(); + default: + throw new Error("unknown test operation"); + } + }; +} diff --git a/packages/preview2-shim/test/io-worker.ts b/packages/preview2-shim/test/io-worker.ts new file mode 100644 index 000000000..2d8594b1c --- /dev/null +++ b/packages/preview2-shim/test/io-worker.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { expect, test } from "vitest"; +import { + callExtension, + futureSubscribe, + futureTakeValue, + futureDispose, +} from "../dist/nodejs/io-worker.js"; + +const module = new URL("./fixtures/io-worker.ts", import.meta.url); + +test("host extensions survive operation errors and retain their worker state", (): void => { + expect(callExtension(module, "count", [])).toBe(1); + expect(() => callExtension(module, "fail", [])).toThrow("host extension failed"); + expect(callExtension(module, "count", [])).toBe(2); +}); + +test("extension futures preserve rejection, polling and single-consumption ownership", (): void => { + const before = callExtension(module, "resources", []); + const id = callExtension(module, "rejected-future", []); + assert(typeof id === "number"); + const pollable = futureSubscribe(id, {}); + expect(() => futureDispose(id)).toThrow(/child poll/); + pollable.block(); + assert(Symbol.dispose in pollable); + const dispose = pollable[Symbol.dispose]; + assert(typeof dispose === "function"); + dispose.call(pollable); + expect(futureTakeValue(id)).toEqual({ + tag: "ok", + val: { tag: "err", val: { message: "handshake failed" } }, + }); + expect(futureTakeValue(id)).toEqual({ tag: "err", val: undefined }); + futureDispose(id); + expect(callExtension(module, "resources", [])).toEqual(before); +}); From 66fd1e646f24f974e19e1d4fa9b163872b9f4190 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:20:31 +0000 Subject: [PATCH 49/68] test(std): port TLS lifecycle coverage and fixtures --- .../test/wasi/0.2.x/node/24.x.x/http2/host.ts | 28 ++------- .../24.x.x/https/helpers/tls/lifecycle.ts | 60 +++++++++++++++++++ .../24.x.x/https/helpers}/tls/localhost.crt | 0 .../24.x.x/https/helpers}/tls/localhost.key | 0 .../wasi/0.2.x/node/24.x.x/https/host-node.ts | 46 ++++++++++++++ .../test/wasi/0.2.x/node/24.x.x/https/host.ts | 2 +- .../wasi/0.2.x/node/24.x.x/https/server.ts | 2 +- .../test/fixtures/tls/lifecycle.ts | 56 ----------------- packages/preview2-shim/test/tls.ts | 46 -------------- 9 files changed, 112 insertions(+), 128 deletions(-) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts rename packages/{preview2-shim/test/fixtures => jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers}/tls/localhost.crt (100%) rename packages/{preview2-shim/test/fixtures => jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers}/tls/localhost.key (100%) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts delete mode 100644 packages/preview2-shim/test/fixtures/tls/lifecycle.ts delete mode 100644 packages/preview2-shim/test/tls.ts 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 2e0515c72..754b67250 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 @@ -105,18 +105,8 @@ describe("Node HTTP/2 host provider", () => { test("uses a real TLS client session with h2 ALPN", async () => { 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, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]); const server = nodeHttp2.createSecureServer({ key, cert }); closeables.push(() => new Promise((resolve) => server.close(() => resolve()))); @@ -152,18 +142,8 @@ describe("Node HTTP/2 host provider", () => { test.each([false, true])("uses a real %s server callback round trip", async (secure) => { const [key, cert] = secure ? 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, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]) : [undefined, undefined]; const listener: DirectHttp2StreamListener = { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts new file mode 100644 index 000000000..71644b787 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { tcpCreateSocket, instanceNetwork } from "@bytecodealliance/preview2-shim/sockets"; +import { + createTlsProvider, + _resourceCounts, +} from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node"; + +function dispose(resource: object): void { + assert(Symbol.dispose in resource); + const drop: unknown = resource[Symbol.dispose]; + assert(typeof drop === "function"); + drop.call(resource); +} +const mode = process.argv[3]; +const ca = await readFile(new URL("./localhost.crt", import.meta.url), "utf8"); +const provider = createTlsProvider({ ca: [ca], handshakeTimeoutMs: 1000 }); +const before = _resourceCounts(); +const socket = tcpCreateSocket.createTcpSocket("ipv4"); +socket.startConnect(instanceNetwork.instanceNetwork(), { + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: Number(process.argv[2]) }, +}); +const poll = socket.subscribe(); +poll.block(); +dispose(poll); +const [input, output] = socket.finishConnect(); +const handshake = new provider.ClientHandshake("localhost", input, output); +assert.throws(() => createTlsProvider().ClientHandshake.finish(handshake), /another provider/); +if (mode === "unstarted") { + handshake[Symbol.dispose](); +} else { + const future = provider.ClientHandshake.finish(handshake); + assert.throws(() => provider.ClientHandshake.finish(handshake), /consumed/); + handshake[Symbol.dispose](); // consuming finish transfers ownership out of it + const ready = future.subscribe(); + assert.throws(() => future[Symbol.dispose](), /child poll/); + if (mode === "pending") { + assert.equal(future.get(), undefined); + dispose(ready); + future[Symbol.dispose](); + } else { + ready.block(); + dispose(ready); + const result = future.get(); + assert.equal(result?.tag, "ok"); + assert(result?.tag === "ok" && result.val.tag === "ok"); + assert.deepEqual(future.get(), { tag: "err", val: undefined }); + const [connection, plaintextInput, plaintextOutput] = result.val.val; + future[Symbol.dispose](); + connection.closeOutput(); + dispose(plaintextOutput); + dispose(plaintextInput); + connection[Symbol.dispose](); + connection[Symbol.dispose](); + } +} +dispose(socket); +assert.deepEqual(_resourceCounts(), before); +console.log("clean"); diff --git a/packages/preview2-shim/test/fixtures/tls/localhost.crt b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt similarity index 100% rename from packages/preview2-shim/test/fixtures/tls/localhost.crt rename to packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt diff --git a/packages/preview2-shim/test/fixtures/tls/localhost.key b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key similarity index 100% rename from packages/preview2-shim/test/fixtures/tls/localhost.key rename to packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts new file mode 100644 index 000000000..df1678741 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createServer as tcpServer, type Socket } from "node:net"; +import { createServer as tlsServer } from "node:tls"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); +const fixture = new URL("./helpers/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", fixture)); +const key = await readFile(new URL("localhost.key", fixture)); +test.concurrent.each(["unstarted", "pending", "completed"])( + "TLS resource ownership: %s", + async (mode: string): Promise => { + const peers = new Set(); + const server = mode === "completed" ? tlsServer({ cert, key }) : tcpServer(); + server.on("connection", (socket: Socket): void => { + peers.add(socket); + socket.once("close", (): void => { + peers.delete(socket); + }); + }); + server.on("tlsClientError", (): void => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP address"); + } + const result = await exec( + process.execPath, + [fileURLToPath(new URL("lifecycle.ts", fixture)), String(address.port), mode], + { timeout: 5000 }, + ); + expect(result.stdout.trim()).toBe("clean"); + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts index 3eaa3ed28..cb3515be5 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts @@ -11,7 +11,7 @@ import type { const encoder = new TextEncoder(); const decoder = new TextDecoder(); -const FIXTURES = new URL("../../../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const FIXTURES = new URL("./helpers/tls/", import.meta.url); const cert = new Uint8Array(readFileSync(new URL("localhost.crt", FIXTURES))); const key = new Uint8Array(readFileSync(new URL("localhost.key", FIXTURES))); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts index 765b9dc1c..fca4271ea 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts @@ -7,7 +7,7 @@ import { servingImplementation } from "./helpers/index.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -const FIXTURES = new URL("../../../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const FIXTURES = new URL("./helpers/tls/", import.meta.url); const cert = readFileSync(new URL("localhost.crt", FIXTURES)); const key = readFileSync(new URL("localhost.key", FIXTURES)); diff --git a/packages/preview2-shim/test/fixtures/tls/lifecycle.ts b/packages/preview2-shim/test/fixtures/tls/lifecycle.ts deleted file mode 100644 index 23626060a..000000000 --- a/packages/preview2-shim/test/fixtures/tls/lifecycle.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { tcpCreateSocket, instanceNetwork } from "../../../dist/nodejs/sockets.js"; -import { createTlsProvider, _resourceCounts } from "../../../dist/nodejs/tls.js"; - -function dispose(resource: object): void { - assert(Symbol.dispose in resource); - const drop: unknown = resource[Symbol.dispose]; - assert(typeof drop === "function"); - drop.call(resource); -} -const mode = process.argv[3]; -const ca = await readFile(new URL("./localhost.crt", import.meta.url), "utf8"); -const provider = createTlsProvider({ ca: [ca], handshakeTimeoutMs: 1000 }); -const before = _resourceCounts(); -const socket = tcpCreateSocket.createTcpSocket("ipv4"); -socket.startConnect(instanceNetwork.instanceNetwork(), { - tag: "ipv4", - val: { address: [127, 0, 0, 1], port: Number(process.argv[2]) }, -}); -const poll = socket.subscribe(); -poll.block(); -dispose(poll); -const [input, output] = socket.finishConnect(); -const handshake = new provider.ClientHandshake("localhost", input, output); -if (mode === "unstarted") { - handshake[Symbol.dispose](); -} else { - const future = provider.ClientHandshake.finish(handshake); - assert.throws(() => provider.ClientHandshake.finish(handshake), /consumed/); - handshake[Symbol.dispose](); // consuming finish transfers ownership out of it - const ready = future.subscribe(); - assert.throws(() => future[Symbol.dispose](), /child poll/); - if (mode === "pending") { - assert.equal(future.get(), undefined); - dispose(ready); - future[Symbol.dispose](); - } else { - ready.block(); - dispose(ready); - const result = future.get(); - assert.equal(result?.tag, "ok"); - assert(result?.tag === "ok" && result.val.tag === "ok"); - assert.deepEqual(future.get(), { tag: "err", val: undefined }); - const [connection, plaintextInput, plaintextOutput] = result.val.val; - future[Symbol.dispose](); - connection.closeOutput(); - dispose(plaintextOutput); - dispose(plaintextInput); - connection[Symbol.dispose](); - connection[Symbol.dispose](); - } -} -dispose(socket); -assert.deepEqual(_resourceCounts(), before); -console.log("clean"); diff --git a/packages/preview2-shim/test/tls.ts b/packages/preview2-shim/test/tls.ts deleted file mode 100644 index c46186525..000000000 --- a/packages/preview2-shim/test/tls.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { once } from "node:events"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { createServer as tcpServer, type Socket } from "node:net"; -import { createServer as tlsServer } from "node:tls"; -import { expect, test } from "vitest"; - -const exec = promisify(execFile); -const fixture = new URL("./fixtures/tls/", import.meta.url); -const cert = await readFile(new URL("localhost.crt", fixture)); -const key = await readFile(new URL("localhost.key", fixture)); -test.concurrent.each(["unstarted", "pending", "completed"])( - "TLS resource ownership: %s", - async (mode: string): Promise => { - const peers = new Set(); - const server = mode === "completed" ? tlsServer({ cert, key }) : tcpServer(); - server.on("connection", (socket: Socket): void => { - peers.add(socket); - socket.once("close", (): void => { - peers.delete(socket); - }); - }); - server.on("tlsClientError", (): void => {}); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - try { - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Expected TCP address"); - } - const result = await exec( - process.execPath, - [fileURLToPath(new URL("lifecycle.ts", fixture)), String(address.port), mode], - { timeout: 5000 }, - ); - expect(result.stdout.trim()).toBe("clean"); - } finally { - for (const peer of peers) { - peer.destroy(); - } - await new Promise((resolve) => server.close(() => resolve())); - } - }, -); From 81ad896b981547bba5c50e15f0b88360b4f4d9ab Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:21:12 +0000 Subject: [PATCH 50/68] test(jco): run HTTPS components with the jco-std TLS host --- .../test/fixtures/componentize/node-https-server/run.js | 2 +- .../fixtures/componentize/node-https-wasi-tls/build.ts | 2 -- .../test/fixtures/componentize/node-https-wasi-tls/run.ts | 7 ++----- packages/jco/test/fixtures/componentize/node-https/run.js | 2 +- packages/jco/test/node/https-wasi-tls.ts | 5 ++++- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/jco/test/fixtures/componentize/node-https-server/run.js b/packages/jco/test/fixtures/componentize/node-https-server/run.js index 0b2acc4a9..ec746bf01 100644 --- a/packages/jco/test/fixtures/componentize/node-https-server/run.js +++ b/packages/jco/test/fixtures/componentize/node-https-server/run.js @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; -const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const tls = new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/", import.meta.url); const cert = await readFile(new URL("localhost.crt", tls), "utf8"); const key = await readFile(new URL("localhost.key", tls), "utf8"); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts index 887308541..cea57bb22 100644 --- a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts @@ -60,8 +60,6 @@ const { files } = await transpileBytes(bytes, { ]), ), "wasi:tls/types@0.2.0-draft": "tls", - "jco:tls-streams-0-2-10/bridge@0.1.0": "tls", - "jco:tls-streams-0-2-12/bridge@0.1.0": "tls", }, }); await writeFiles(Object.fromEntries(Object.entries(files).map(([name, bytes]) => [join(root, name), bytes]))); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts index 914102566..7a6343876 100644 --- a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import * as sockets from "../../../../../preview2-shim/dist/nodejs/sockets.js"; -import * as tls from "../../../../../preview2-shim/dist/nodejs/tls.js"; +import * as tls from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host-node.js"; import * as denied from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host.js"; const root = resolve(process.argv[2]); @@ -33,10 +33,7 @@ imports.sockets = { }, }, }; -imports.tls = - policy === "denied" - ? denied - : { ...provider, ClientHandshake: CountedHandshake, adapt: tls.adapt, isAvailable: tls.isAvailable }; +imports.tls = policy === "denied" ? denied : { ...provider, ClientHandshake: CountedHandshake }; interface Report { status: number; body: string; diff --git a/packages/jco/test/fixtures/componentize/node-https/run.js b/packages/jco/test/fixtures/componentize/node-https/run.js index f51a0c560..f815b9692 100644 --- a/packages/jco/test/fixtures/componentize/node-https/run.js +++ b/packages/jco/test/fixtures/componentize/node-https/run.js @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; -const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const tls = new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/", import.meta.url); const cert = await readFile(new URL("localhost.crt", tls), "utf8"); const key = await readFile(new URL("localhost.key", tls), "utf8"); diff --git a/packages/jco/test/node/https-wasi-tls.ts b/packages/jco/test/node/https-wasi-tls.ts index 826baaa6b..00a87cc2d 100644 --- a/packages/jco/test/node/https-wasi-tls.ts +++ b/packages/jco/test/node/https-wasi-tls.ts @@ -96,6 +96,9 @@ for (const backend of ["starlingmonkey"]) { const imports = await readFile(join(root, "imports.wit"), "utf8"); expect(imports).toContain("import wasi:tls/types@0.2.0-draft"); expect(imports).toContain("import wasi:sockets/tcp@"); + expect(imports).toContain("import wasi:io/streams@0.2.12"); + expect(imports).not.toContain("wasi:io/streams@0.2.6"); + expect(imports).not.toContain("jco:tls-streams"); expect(imports).not.toContain("import jco:node/http@"); expect(imports).not.toContain("import wasi:http/outgoing-handler@"); }, 190_000); @@ -272,7 +275,7 @@ test.concurrent("QuickJS reports its snapshot linker TLS resource incompatibilit try { await expect( exec(process.execPath, [build, root, "quickjs"], { timeout: 180_000, maxBuffer: 2_000_000 }), - ).rejects.toThrow(/QuickJS.*snapshot linker.*shared IO resource types.*starlingmonkey/); + ).rejects.toThrow(/wasi:tls.*mismatched resource types/); } finally { await rm(root, { recursive: true, force: true }); } From 91a7b897b33e2c28bc3107cf9694fd6b70e2e144 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:22:21 +0000 Subject: [PATCH 51/68] docs(std): document the local TLS host provider --- packages/jco-std/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 53239d968..a43f9fca7 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -624,11 +624,10 @@ jco componentize component.js --wit wit --bundle \ validation. It rejects `Server` construction immediately because an outgoing-handler cannot listen for arbitrary inbound connections. -For HTTPS over sockets, explicitly map `wasi:tls/types@0.2.0-draft` and the -component's `jco:tls-streams-0-2-10/bridge@0.1.0` (or `0-2-12`) import to -`@bytecodealliance/preview2-shim/tls`. TLS support is backend-independent. -The default mapping denies TLS before -connecting. The Node provider wraps the existing TCP streams, validates the +For HTTPS over sockets, explicitly map `wasi:tls/types@0.2.0-draft` to +`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node`. TLS support is backend-independent. +The [local TLS contract](wit/tls-0.2.0-draft/README.md) shares `wasi:io@0.2.12` +resources with sockets directly. The default mapping denies TLS before connecting. The Node provider wraps the existing TCP streams, validates the certificate chain and hostname, and offers HTTP/1.1 ALPN. The draft accepts only `servername` (and `rejectUnauthorized: true`); other per-request TLS settings, including `ca`, are rejected. Hosts can configure trust with `createTlsProvider`. From a226c2f407faedd78fe0f78b4fbd3dabe4706a7d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:23:07 +0000 Subject: [PATCH 52/68] docs(p2-shim): describe host IO integration --- packages/preview2-shim/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index e3fe35ed7..61d483f27 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -257,13 +257,13 @@ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. -### Opt-in TLS - -`@bytecodealliance/preview2-shim/tls` is a Node-only provider for the pinned -`wasi:tls/types@0.2.0-draft` interface (upstream revision -`6781ae26084100c0628ef72cc44e4517c6c48ae5`). It wraps existing WASI TCP streams with -native TLS, verifies certificate chains and names, and offers HTTP/1.1 ALPN. -It is never enabled by the default WASI mappings. `createTlsProvider({ ca, -handshakeTimeoutMs })` configures host trust and handshake deadlines. The module -also exports the separate Jco IO version bridge (`adapt`, `isAvailable`). -The draft supports clients only; it cannot express guest CA or TLS settings. +### Host IO extensions + +Opt-in providers can use the Node-only `@bytecodealliance/preview2-shim/io-worker` +entry point to operate on existing streams in the shim's IO worker. Host-selected +modules load lazily and share the worker's stream, future, and poll ownership rules. +Guest code cannot select extension modules. + +The Node `wasi:tls` provider and its TLS policy live in +[`jco-std`](../jco-std/README.md#http-1), which wraps the supplied TCP streams +without opening a replacement connection. From 16d80598932c2a86a683afaec93bbfacf69c63d2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:23:41 +0000 Subject: [PATCH 53/68] docs(jco): map HTTPS to the standard-library TLS provider --- docs/src/interop/nodejs-builtins.md | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index bda15d8cb..b820c15de 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -875,35 +875,33 @@ being dropped. | `wasi-http` | Clients only, with the `HTTPS` scheme. `wasi:http/outgoing-handler` owns certificate validation, so any per-request TLS option is refused; servers are rejected as for `node:http`. | TLS support is part of the `wasi-sockets` implementation, which uses the -`wasi:tls` host capability for TLS connections. Explicitly grant that capability -and the bridge matching the component's imported IO version when transpiling: +`wasi:tls` host capability for TLS connections. Explicitly grant it when transpiling: ```sh jco transpile component.wasm -o out \ - --map 'wasi:tls/types@0.2.0-draft=@bytecodealliance/preview2-shim/tls' \ - --map 'jco:tls-streams-0-2-10/bridge@0.1.0=@bytecodealliance/preview2-shim/tls' + --map 'wasi:tls/types@0.2.0-draft=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node' ``` -This example maps the IO 0.2.10 bridge; for IO 0.2.12, map -`jco:tls-streams-0-2-12/bridge@0.1.0` instead. -The Jco bridge transfers IO resource versions and checks capability availability; -it is separate from the upstream TLS interface. Without this opt-in, HTTPS fails -before connecting, with no plaintext fallback. Plain HTTP needs no TLS capability. +Sockets and TLS share `wasi:io@0.2.12` stream resources directly. Without this +opt-in, HTTPS fails before connecting, with no plaintext fallback. Plain HTTP +needs no TLS capability. The Node provider uses `node:tls` over the supplied TCP streams, system trust, -hostname verification, and HTTP/1.1 ALPN. Hosts needing private trust can map both -interfaces to a module exporting: +hostname verification, and HTTP/1.1 ALPN. Hosts needing private trust can map the +TLS interface to a module exporting: ```js -import { createTlsProvider } from '@bytecodealliance/preview2-shim/tls'; -export { adapt, isAvailable } from '@bytecodealliance/preview2-shim/tls'; -export const { ClientHandshake, ClientConnection, FutureClientStreams } = createTlsProvider({ +import { createTlsProvider } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node'; +export const { ClientHandshake, ClientConnection, FutureClientStreams, isAvailable } = createTlsProvider({ ca: [trustedCaPem], handshakeTimeoutMs: 10_000, }); ``` -Pinned upstream: [`WebAssembly/wasi-tls` at `6781ae26084100c0628ef72cc44e4517c6c48ae5`](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit), -`wasi:tls@0.2.0-draft`, depending on `wasi:io@0.2.6`. It exposes client handshake, +Based on upstream [`WebAssembly/wasi-tls` at `6781ae26084100c0628ef72cc44e4517c6c48ae5`](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit), +Jco's [local contract](https://github.com/bytecodealliance/jco/tree/main/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft) +retains `wasi:tls@0.2.0-draft` but uses `wasi:io@0.2.12`, adds `is-available`, and +omits unstable-feature annotations. It is a provisional interface for Node.js, +web, and other host implementations. It exposes client handshake, future polling, streams, and output shutdown. It has no server handshake, certificate configuration, or ALPN controls. Only guest `servername` and `rejectUnauthorized: true` are supported; other TLS options, including `ca`, are From 4b0680166fbcbc1dca09ee4f6120e4dff72e63ea Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 8 Sep 2026 23:55:38 +0000 Subject: [PATCH 54/68] style(std): format rebased sockets error imports --- .../wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts index 866e978ab..8fada983c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts @@ -5,7 +5,12 @@ import { type WasiTlsConnection, } from "./tls.js"; import { concatBytes } from "../../body.js"; -import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../../errors.js"; +import { + fromImplementationError, + invalidArgValue, + unsupported, + wasiErrorCode, +} from "../../errors.js"; import { parseHttp1Request, parseHttp1Response, From 43cccec333a1093989157b6e970b125dfb5806d5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 8 Sep 2026 23:55:38 +0000 Subject: [PATCH 55/68] test(std): bind HTTPS hosts to callback factories --- .../test/wasi/0.2.x/node/24.x.x/https/host.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts index cb3515be5..cd49a2831 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts @@ -2,10 +2,13 @@ import { readFileSync } from "node:fs"; import { afterEach, describe, expect, test } 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 { + DirectHttpCallbacks, DirectHttpRequestListener, - DirectHttpServer, DirectHttpServerOptions, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; @@ -15,7 +18,8 @@ const FIXTURES = new URL("./helpers/tls/", import.meta.url); const cert = new Uint8Array(readFileSync(new URL("localhost.crt", FIXTURES))); const key = new Uint8Array(readFileSync(new URL("localhost.key", FIXTURES))); -const servers = new Set(); +type HttpHostServer = InstanceType["Server"]>; +const servers = new Set(); afterEach(async () => { await Promise.all([...servers].map((server) => server.close())); @@ -24,24 +28,28 @@ afterEach(async () => { const echo: DirectHttpRequestListener = { handle: async (incoming) => ({ - tag: "ok", - val: { - statusCode: 200, - statusMessage: "OK", - headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], - body: encoder.encode(`${incoming.method} ${incoming.url} ${decoder.decode(incoming.body)}`), - }, + statusCode: 200, + statusMessage: "OK", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode(`${incoming.method} ${incoming.url} ${decoder.decode(incoming.body)}`), }), [Symbol.dispose]: () => undefined, }; async function listen( options: DirectHttpServerOptions, -): Promise<{ server: DirectHttpServer; port: number }> { - const server = new Server(options, echo); +): Promise<{ server: HttpHostServer; port: number }> { + const callbacks: DirectHttpCallbacks = { + takeRequestListener(id: number): DirectHttpRequestListener { + expect(id).toBe(1); + return echo; + }, + }; + const { Server } = createHttpHost(() => callbacks); + const server = new Server(options, 1); servers.add(server); const started = await server.listen({ port: 0, host: "127.0.0.1" }); - if (started.tag === "err" || started.val.tag !== "tcp") { + if (started.tag !== "ok" || started.val.tag !== "tcp") { throw new Error(`expected a TCP listener, got ${JSON.stringify(started)}`); } return { server, port: started.val.val.port }; From b15b130afd8493153121d5cd968c71b11181eaa8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 8 Sep 2026 23:55:39 +0000 Subject: [PATCH 56/68] test(jco): reconcile HTTP fixtures after HTTPS rebase --- .../test/fixtures/componentize/node-http2/run-direct.js | 8 ++++++-- packages/jco/test/node/http.js | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js index 291bedfa5..aecbbc59c 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js +++ b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js @@ -80,8 +80,12 @@ async function request(port, path, secure = false) { } 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)), + readFile( + new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key", import.meta.url), + ), + readFile( + new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt", import.meta.url), + ), ]); const peer = http2.createServer(); peer.on("stream", (stream) => { diff --git a/packages/jco/test/node/http.js b/packages/jco/test/node/http.js index 62d055936..d2a41ae76 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -239,7 +239,7 @@ describe.skipIf(!hasJspi)("node:http in a component", () => { // injects *something*, so the assertion names the interface the mode must add. const injected = { direct: "jco:node/http@0.1.0", - "wasi-sockets": "wasi:sockets/instance-network@0.2.12", + "wasi-sockets": "wasi:sockets/instance-network@0.2.10", "wasi-http": "wasi:http/outgoing-handler@0.2.12", }[implementation]; expect(stderr).toContain("Jco added generated WIT import"); From 034df27ef8356cd5d879d944bc4e1ddef568eb42 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:37:46 +0000 Subject: [PATCH 57/68] feat(std): add node:net address utilities and option types --- .../wasi/0.2.x/node/24.x.x/net/block-list.ts | 201 ++++++++++++++++++ .../wasi/0.2.x/node/24.x.x/net/defaults.ts | 32 +++ .../src/wasi/0.2.x/node/24.x.x/net/errors.ts | 61 ++++++ .../src/wasi/0.2.x/node/24.x.x/net/ip.ts | 159 ++++++++++++++ .../wasi/0.2.x/node/24.x.x/net/normalize.ts | 35 +++ .../0.2.x/node/24.x.x/net/socket-address.ts | 142 +++++++++++++ .../src/wasi/0.2.x/node/24.x.x/net/types.ts | 122 +++++++++++ 7 files changed, 752 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts new file mode 100644 index 000000000..a7a8e7aa1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts @@ -0,0 +1,201 @@ +/** + * Portable `net.BlockList`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/blocklist.js (MIT license). + * The native `block_list` handle is replaced by JavaScript byte/range comparisons while the + * public validation, newest-first rule ordering, JSON format, and IPv4-mapped behavior remain. + */ + +import { invalidArgType, invalidArgValue, outOfRange } from "./errors.js"; +import { mappedIpv4Bytes } from "./ip.js"; +import { SocketAddress, socketAddressDetails, type IPVersion } from "./socket-address.js"; + +type Rule = + | { kind: "address"; address: SocketAddress; text: string } + | { kind: "range"; start: SocketAddress; end: SocketAddress; text: string } + | { kind: "subnet"; network: SocketAddress; prefix: number; text: string }; + +function address(value: string | SocketAddress, family: IPVersion, name: string): SocketAddress { + if (SocketAddress.isSocketAddress(value)) { + return value; + } + if (typeof value !== "string") { + throw invalidArgType(name, "string", value); + } + const normalizedFamily = typeof family === "string" ? family.toLowerCase() : family; + if (normalizedFamily !== "ipv4" && normalizedFamily !== "ipv6") { + throw invalidArgValue("family", family); + } + return new SocketAddress({ address: value, family: normalizedFamily }); +} + +function numeric(value: SocketAddress): bigint { + let result = 0n; + for (const byte of socketAddressDetails(value).bytes) { + result = (result << 8n) | BigInt(byte); + } + return result; +} + +function sameFamily(first: SocketAddress, second: SocketAddress): void { + if (first.family !== second.family) { + throw invalidArgValue("family", second.family, "must match the first address family"); + } +} + +function matches(rule: Rule, candidate: SocketAddress): boolean { + if (rule.kind === "address") { + return rule.address.family === candidate.family && numeric(rule.address) === numeric(candidate); + } + if (rule.kind === "range") { + if (rule.start.family !== candidate.family) { + return false; + } + const value = numeric(candidate); + return value >= numeric(rule.start) && value <= numeric(rule.end); + } + if (rule.network.family !== candidate.family) { + return false; + } + const bits = candidate.family === "ipv4" ? 32 : 128; + const shift = BigInt(bits - rule.prefix); + return numeric(candidate) >> shift === numeric(rule.network) >> shift; +} + +export class BlockList { + readonly #rules: Rule[] = []; + + static isBlockList(value: unknown): value is BlockList { + return value instanceof BlockList; + } + + addAddress(value: string | SocketAddress, family: IPVersion = "ipv4"): void { + const parsed = address(value, family, "address"); + this.#rules.unshift({ + kind: "address", + address: parsed, + text: `Address: ${parsed.family === "ipv4" ? "IPv4" : "IPv6"} ${parsed.address}`, + }); + } + + addRange( + startValue: string | SocketAddress, + endValue: string | SocketAddress, + family: IPVersion = "ipv4", + ): void { + const start = address(startValue, family, "start"); + const end = address(endValue, family, "end"); + sameFamily(start, end); + if (numeric(start) > numeric(end)) { + throw invalidArgValue("start", start, "must come before end"); + } + this.#rules.unshift({ + kind: "range", + start, + end, + text: `Range: ${start.family === "ipv4" ? "IPv4" : "IPv6"} ${start.address}-${end.address}`, + }); + } + + addSubnet( + networkValue: string | SocketAddress, + prefix: number, + family: IPVersion = "ipv4", + ): void { + const network = address(networkValue, family, "network"); + const maximum = network.family === "ipv4" ? 32 : 128; + if (typeof prefix !== "number") { + throw invalidArgType("prefix", "number", prefix); + } + if (!Number.isInteger(prefix) || prefix < 0 || prefix > maximum) { + throw outOfRange("prefix", `>= 0 and <= ${maximum}`, prefix); + } + this.#rules.unshift({ + kind: "subnet", + network, + prefix: Object.is(prefix, -0) ? 0 : prefix, + text: `Subnet: ${network.family === "ipv4" ? "IPv4" : "IPv6"} ${network.address}/${prefix}`, + }); + } + + check(value: string | SocketAddress, family: IPVersion = "ipv4"): boolean { + if (!SocketAddress.isSocketAddress(value) && typeof value !== "string") { + throw invalidArgType("address", "string", value); + } + let candidate: SocketAddress; + try { + candidate = address(value, family, "address"); + } catch { + return false; + } + if (this.#rules.some((rule) => matches(rule, candidate))) { + return true; + } + if (candidate.family === "ipv4") { + const mapped = new SocketAddress({ address: `::ffff:${candidate.address}`, family: "ipv6" }); + return this.#rules.some((rule) => matches(rule, mapped)); + } + if (candidate.family === "ipv6") { + const mapped = mappedIpv4Bytes(socketAddressDetails(candidate).bytes); + if (mapped) { + const ipv4 = new SocketAddress({ address: Array.from(mapped).join("."), family: "ipv4" }); + return this.#rules.some((rule) => matches(rule, ipv4)); + } + } + return false; + } + + get rules(): readonly string[] { + return this.#rules.map((rule) => rule.text); + } + + toJSON(): readonly string[] { + return this.rules; + } + + fromJSON(data: string | readonly string[]): void { + let rules: unknown = data; + if (typeof rules === "string") { + rules = JSON.parse(rules) as unknown; + } + if (!Array.isArray(rules) || !rules.every((rule) => typeof rule === "string")) { + throw invalidArgType("data", ["string", "string[]"], data); + } + for (const rule of rules) { + if (rule.includes("IPv4")) { + let match = /Subnet: IPv4 (\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})/.exec(rule); + if (match) { + this.addSubnet(match[1], Number.parseInt(match[2])); + continue; + } + match = /Address: IPv4 (\d{1,3}(?:\.\d{1,3}){3})/.exec(rule); + if (match) { + this.addAddress(match[1]); + continue; + } + match = /Range: IPv4 (\d{1,3}(?:\.\d{1,3}){3})-(\d{1,3}(?:\.\d{1,3}){3})/.exec(rule); + if (match) { + this.addRange(match[1], match[2]); + continue; + } + } + if (rule.includes("IPv6")) { + let match = /Subnet: IPv6 ([0-9a-fA-F:]{1,39})\/([0-9]{1,3})/i.exec(rule); + if (match) { + this.addSubnet(match[1], Number.parseInt(match[2]), "ipv6"); + continue; + } + match = /Address: IPv6 ([0-9a-fA-F:]{1,39})/i.exec(rule); + if (match) { + this.addAddress(match[1], "ipv6"); + continue; + } + match = /Range: IPv6 ([0-9a-fA-F:]{1,39})-([0-9a-fA-F:]{1,39})/i.exec(rule); + if (match) { + this.addRange(match[1], match[2], "ipv6"); + } + } + } + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts new file mode 100644 index 000000000..dc36b37fa --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts @@ -0,0 +1,32 @@ +/** Module-local defaults from nodejs/node v24.19.0 `lib/net.js`, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae (MIT license). Native flags become local state. */ + +import { invalidArgType, outOfRange } from "./errors.js"; + +let autoSelectFamily = true; +let autoSelectFamilyAttemptTimeout = 250; + +export function getDefaultAutoSelectFamily(): boolean { + return autoSelectFamily; +} + +export function setDefaultAutoSelectFamily(value: boolean): void { + if (typeof value !== "boolean") { + throw invalidArgType("value", "boolean", value); + } + autoSelectFamily = value; +} + +export function getDefaultAutoSelectFamilyAttemptTimeout(): number { + return autoSelectFamilyAttemptTimeout; +} + +export function setDefaultAutoSelectFamilyAttemptTimeout(value: number): void { + if (typeof value !== "number") { + throw invalidArgType("value", "number", value); + } + if (!Number.isInteger(value) || value < 1 || value > 0x7fff_ffff) { + throw outOfRange("value", "an integer >= 1 and <= 2147483647", value); + } + autoSelectFamilyAttemptTimeout = Math.max(value, 10); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts new file mode 100644 index 000000000..4370e3cf7 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts @@ -0,0 +1,61 @@ +/** Node-style errors used by the portable `node:net` shim. */ + +import { + AbortError, + codedError, + deprecatedNodeApi, + invalidArgType, + invalidArgValue, + missingArgs, + outOfRange, + unsupportedNodeApi, +} from "../errors/core.js"; + +export { AbortError, invalidArgType, invalidArgValue, missingArgs, outOfRange }; + +export function invalidAddress(): Error & { code: "ERR_INVALID_ADDRESS" } { + return codedError(new Error("Invalid socket address"), "ERR_INVALID_ADDRESS"); +} + +export function serverAlreadyListening(): Error & { code: "ERR_SERVER_ALREADY_LISTEN" } { + return codedError( + new Error("Listen method has been called more than once without closing."), + "ERR_SERVER_ALREADY_LISTEN", + ); +} + +export function serverNotRunning(): Error & { code: "ERR_SERVER_NOT_RUNNING" } { + return codedError(new Error("Server is not running."), "ERR_SERVER_NOT_RUNNING"); +} + +export function socketClosed(): Error & { code: "ERR_SOCKET_CLOSED" } { + return codedError(new Error("Socket is closed"), "ERR_SOCKET_CLOSED"); +} + +export function socketClosedBeforeConnection(): Error & { + code: "ERR_SOCKET_CLOSED_BEFORE_CONNECTION"; +} { + return codedError( + new Error("Socket closed before the connection was established"), + "ERR_SOCKET_CLOSED_BEFORE_CONNECTION", + ); +} + +export function socketHandleAdopted(): Error & { code: "ERR_SOCKET_HANDLE_ADOPTED" } { + return codedError( + new Error("This socket handle has already been bound to another socket"), + "ERR_SOCKET_HANDLE_ADOPTED", + ); +} + +export function ipBlocked(address: string): Error & { code: "ERR_IP_BLOCKED" } { + return codedError(new Error(`IP ${address} is blocked`), "ERR_IP_BLOCKED"); +} + +export function unsupported(api: string, reason: string): never { + throw unsupportedNodeApi(api, reason); +} + +export function deprecated(api: string, replacement: string): never { + throw deprecatedNodeApi(api, replacement); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts new file mode 100644 index 000000000..f17ae0fcf --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts @@ -0,0 +1,159 @@ +/** + * IP address predicates and portable parsing helpers. + * + * The regular expressions are mechanically adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/net.js (MIT license). Node calls the + * native predicates from `lib/net.js`; the local helpers additionally turn accepted addresses + * into bytes because a component has no `block_list` native binding. + */ + +const V4_SEGMENT = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"; +const V4_SOURCE = `(?:${V4_SEGMENT}\\.){3}${V4_SEGMENT}`; +const IPV4 = new RegExp(`^${V4_SOURCE}$`); +const V6_SEGMENT = "(?:[0-9a-fA-F]{1,4})"; +const IPV6 = new RegExp( + "^(?:" + + `(?:${V6_SEGMENT}:){7}(?:${V6_SEGMENT}|:)|` + + `(?:${V6_SEGMENT}:){6}(?:${V4_SOURCE}|:${V6_SEGMENT}|:)|` + + `(?:${V6_SEGMENT}:){5}(?::${V4_SOURCE}|(?::${V6_SEGMENT}){1,2}|:)|` + + `(?:${V6_SEGMENT}:){4}(?:(?::${V6_SEGMENT}){0,1}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,3}|:)|` + + `(?:${V6_SEGMENT}:){3}(?:(?::${V6_SEGMENT}){0,2}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,4}|:)|` + + `(?:${V6_SEGMENT}:){2}(?:(?::${V6_SEGMENT}){0,3}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,5}|:)|` + + `(?:${V6_SEGMENT}:){1}(?:(?::${V6_SEGMENT}){0,4}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,6}|:)|` + + `(?::(?:(?::${V6_SEGMENT}){0,5}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,7}|:))` + + ")(?:%[0-9a-zA-Z-.:]{1,})?$", +); + +export type IpFamily = 4 | 6; + +export interface ParsedIpAddress { + family: IpFamily; + bytes: Uint8Array; + canonical: string; +} + +export function isIPv4(input: unknown): input is string { + return typeof input === "string" && IPV4.test(input); +} + +export function isIPv6(input: unknown): input is string { + return typeof input === "string" && IPV6.test(input); +} + +export function isIP(input: unknown): 0 | IpFamily { + return isIPv4(input) ? 4 : isIPv6(input) ? 6 : 0; +} + +export function parseIpv4(input: string): Uint8Array | undefined { + return isIPv4(input) ? Uint8Array.from(input.split(".").map(Number)) : undefined; +} + +function hextets(input: string): number[] | undefined { + let value = input; + const zone = value.indexOf("%"); + if (zone !== -1) { + value = value.slice(0, zone); + } + const ipv4Text = value.match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + if (ipv4Text) { + const ipv4 = parseIpv4(ipv4Text); + if (!ipv4) { + return undefined; + } + value = `${value.slice(0, -ipv4Text.length)}${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`; + } + const halves = value.split("::"); + if (halves.length > 2) { + return undefined; + } + const left = halves[0] ? halves[0].split(":") : []; + const right = halves[1] ? halves[1].split(":") : []; + if (halves.length === 1 ? left.length !== 8 : left.length + right.length > 7) { + return undefined; + } + const fill = new Array(8 - left.length - right.length).fill(0); + return [ + ...left.map((part) => Number.parseInt(part, 16)), + ...fill, + ...right.map((part) => Number.parseInt(part, 16)), + ]; +} + +export function parseIpv6(input: string): Uint8Array | undefined { + if (!isIPv6(input)) { + return undefined; + } + const parts = hextets(input); + if (!parts || parts.length !== 8) { + return undefined; + } + const bytes = new Uint8Array(16); + parts.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return bytes; +} + +function mappedIpv4(bytes: Uint8Array): string | undefined { + if ( + bytes.length === 16 && + bytes.slice(0, 10).every((byte) => byte === 0) && + bytes[10] === 0xff && + bytes[11] === 0xff + ) { + return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`; + } + return undefined; +} + +export function canonicalIpv6(bytes: Uint8Array): string { + const mapped = mappedIpv4(bytes); + if (mapped) { + return `::ffff:${mapped}`; + } + const parts = Array.from({ length: 8 }, (_, index) => + ((bytes[index * 2] << 8) | bytes[index * 2 + 1]).toString(16), + ); + let bestStart = -1; + let bestLength = 0; + for (let start = 0; start < parts.length; ) { + if (parts[start] !== "0") { + start += 1; + continue; + } + let end = start + 1; + while (end < parts.length && parts[end] === "0") { + end += 1; + } + if (end - start > bestLength && end - start > 1) { + bestStart = start; + bestLength = end - start; + } + start = end; + } + if (bestStart === -1) { + return parts.join(":"); + } + return `${parts.slice(0, bestStart).join(":")}::${parts.slice(bestStart + bestLength).join(":")}`; +} + +export function parseIp(input: string, family?: "ipv4" | "ipv6"): ParsedIpAddress | undefined { + if (family !== "ipv6") { + const bytes = parseIpv4(input); + if (bytes) { + return { family: 4, bytes, canonical: Array.from(bytes).join(".") }; + } + } + if (family !== "ipv4") { + const bytes = parseIpv6(input); + if (bytes) { + return { family: 6, bytes, canonical: canonicalIpv6(bytes) }; + } + } + return undefined; +} + +export function mappedIpv4Bytes(bytes: Uint8Array): Uint8Array | undefined { + return mappedIpv4(bytes) === undefined ? undefined : bytes.slice(12); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts new file mode 100644 index 000000000..9844fefc2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts @@ -0,0 +1,35 @@ +/** Overload normalization adapted from nodejs/node v24.19.0 `lib/net.js`, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae (MIT license). Uses a local normalization symbol. */ + +export const normalizedArgsSymbol = Symbol("normalizedArgs"); + +export type NormalizedArgs = [ + options: Record, + callback: ((...args: unknown[]) => void) | null, +] & { + [normalizedArgsSymbol]: true; +}; + +function isPipeName(value: unknown): value is string { + return typeof value === "string" && !(Number(value) >= 0); +} + +export function normalizeArgs(args: readonly unknown[]): NormalizedArgs { + let options: Record; + if (args.length === 0) { + options = {}; + } else if (typeof args[0] === "object" && args[0] !== null) { + options = args[0] as Record; + } else if (isPipeName(args[0])) { + options = { path: args[0] }; + } else { + options = { port: args[0] }; + if (typeof args[1] === "string") { + options.host = args[1]; + } + } + const last = args.at(-1); + const result = [options, typeof last === "function" ? last : null] as NormalizedArgs; + result[normalizedArgsSymbol] = true; + return result; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts new file mode 100644 index 000000000..5c0b4723d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts @@ -0,0 +1,142 @@ +/** + * Portable `net.SocketAddress`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/socketaddress.js (MIT license). + * The native `block_list` address handle is replaced by immutable validated JavaScript state. + */ + +import { codedError } from "../errors/core.js"; +import { invalidAddress, invalidArgType, invalidArgValue, outOfRange } from "./errors.js"; +import { parseIp, type IpFamily } from "./ip.js"; + +export type IPVersion = "ipv4" | "ipv6"; + +export interface SocketAddressInitOptions { + address?: string; + family?: IPVersion; + port?: number; + flowlabel?: number; +} + +export interface SocketAddressJson { + address: string; + port: number; + family: IPVersion; + flowlabel: number; +} + +export interface SocketAddressDetails { + familyNumber: IpFamily; + bytes: Uint8Array; +} + +const details = new WeakMap(); + +function validatePort(value: unknown, name = "options.port"): number { + if (typeof value !== "number" && typeof value !== "string") { + throw invalidArgType(name, ["number", "string"], value); + } + const port = typeof value === "string" && value.trim() !== "" ? Number(value) : value; + if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65_535) { + throw codedError( + new RangeError(`${name} should be >= 0 and < 65536. Received ${String(value)}.`), + "ERR_SOCKET_BAD_PORT", + ); + } + return port; +} + +export class SocketAddress { + readonly #address: string; + readonly #family: IPVersion; + readonly #port: number; + readonly #flowlabel: number; + + static isSocketAddress(value: unknown): value is SocketAddress { + return value instanceof SocketAddress; + } + + static parse(input: string): SocketAddress | undefined { + if (typeof input !== "string") { + throw invalidArgType("input", "string", input); + } + try { + const url = new URL(`http://${input}`); + const bracketed = url.hostname.startsWith("[") && url.hostname.endsWith("]"); + return new SocketAddress({ + address: bracketed ? url.hostname.slice(1, -1) : url.hostname, + family: bracketed ? "ipv6" : "ipv4", + port: url.port === "" ? 0 : Number(url.port), + }); + } catch { + return undefined; + } + } + + constructor(options: SocketAddressInitOptions = {}) { + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + const rawFamily: unknown = options.family ?? "ipv4"; + if (typeof rawFamily !== "string") { + throw invalidArgValue("options.family", rawFamily); + } + const family = rawFamily.toLowerCase(); + if (family !== "ipv4" && family !== "ipv6") { + throw invalidArgValue("options.family", options.family); + } + const address = options.address ?? (family === "ipv4" ? "127.0.0.1" : "::"); + if (typeof address !== "string") { + throw invalidArgType("options.address", "string", address); + } + const parsed = parseIp(address, family); + if (!parsed) { + throw invalidAddress(); + } + const port = validatePort(options.port ?? 0); + const flowlabel = family === "ipv4" ? 0 : (options.flowlabel ?? 0); + if (typeof flowlabel !== "number") { + throw invalidArgType("options.flowlabel", "number", flowlabel); + } + if (!Number.isInteger(flowlabel) || flowlabel < 0 || flowlabel > 0x000f_ffff) { + throw outOfRange("options.flowlabel", ">= 0 and <= 1048575", flowlabel); + } + this.#address = parsed.canonical; + this.#family = family; + this.#port = port; + this.#flowlabel = flowlabel; + details.set(this, { familyNumber: parsed.family, bytes: parsed.bytes }); + } + + get address(): string { + return this.#address; + } + + get family(): IPVersion { + return this.#family; + } + + get port(): number { + return this.#port; + } + + get flowlabel(): number { + return this.#flowlabel; + } + + toJSON(): SocketAddressJson { + return { + address: this.address, + port: this.port, + family: this.family, + flowlabel: this.flowlabel, + }; + } +} + +export function socketAddressDetails(value: SocketAddress): SocketAddressDetails { + return details.get(value)!; +} + +export { validatePort }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts new file mode 100644 index 000000000..ffb44cdf3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts @@ -0,0 +1,122 @@ +import type { BlockList } from "./block-list.js"; + +export interface AddressInfo { + address: string; + family: string; + port: number; +} + +export type SocketReadyState = "opening" | "open" | "readOnly" | "writeOnly" | "closed"; +export type NetChunk = string | ArrayBuffer | ArrayBufferView; +export type NetEncoding = + | "ascii" + | "utf8" + | "utf-8" + | "utf16le" + | "utf-16le" + | "ucs2" + | "ucs-2" + | "base64" + | "base64url" + | "latin1" + | "binary" + | "hex"; +export type NetCallback = () => void; +export type NetErrorCallback = (error?: Error | null) => void; + +export interface SocketEventMap { + close: [hadError: boolean]; + connect: []; + ready: []; + data: [chunk: Uint8Array | string]; + drain: []; + end: []; + finish: []; + readable: []; + error: [error: Error]; + timeout: []; + lookup: [error: Error | null, address: string, family: number, host: string]; + connectionAttempt: [address: string, port: number, family: number]; + connectionAttemptFailed: [address: string, port: number, family: number, error: Error]; + connectionAttemptTimeout: [address: string, port: number, family: number]; +} + +export interface OnReadOptions { + buffer: Uint8Array | (() => Uint8Array); + callback(bytesWritten: number, buffer: Uint8Array): boolean; +} + +export interface SocketConstructorOptions { + allowHalfOpen?: boolean; + onread?: OnReadOptions; + readable?: boolean; + writable?: boolean; + signal?: AbortSignal; + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + blockList?: BlockList; + /** Internal adoption hook used by `BoundSocket`; arbitrary handles are unsupported. */ + handle?: unknown; + /** Native file descriptors cannot cross the component boundary. */ + fd?: unknown; + objectMode?: boolean; + readableObjectMode?: boolean; + writableObjectMode?: boolean; +} + +export interface TcpSocketConnectOptions extends SocketConstructorOptions { + port: number; + host?: string; + localAddress?: string; + localPort?: number; + family?: 0 | 4 | 6; + lookup?: unknown; + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + timeout?: number; +} + +export interface IpcSocketConnectOptions extends SocketConstructorOptions { + path: string; + timeout?: number; +} + +export type SocketConnectOptions = TcpSocketConnectOptions | IpcSocketConnectOptions; + +export interface ServerOptions { + allowHalfOpen?: boolean; + pauseOnConnect?: boolean; + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + highWaterMark?: number; + blockList?: BlockList; +} + +export interface ListenOptions { + port?: number; + host?: string; + backlog?: number; + path?: string; + exclusive?: boolean; + ipv6Only?: boolean; + reusePort?: boolean; + signal?: AbortSignal; + /** Internal adoption hook used by `BoundSocket`; arbitrary handles are unsupported. */ + handle?: unknown; +} + +export interface DropArgument { + localAddress?: string; + localPort?: number; + localFamily?: string; + remoteAddress?: string; + remotePort?: number; + remoteFamily?: string; +} + +export interface WritableDestination { + write(chunk: Uint8Array | string): unknown; + end?(): unknown; +} From a9865ccc32e49470d2c201b4159602652c558470 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:38:11 +0000 Subject: [PATCH 58/68] refactor(std): share WASI TCP transport across HTTP implementations --- .../src/wasi/0.2.x/node/24.x.x/http/body.ts | 36 +- .../24.x.x/http/impl/wasi-sockets/index.ts | 356 ++---------- .../node/24.x.x/http/impl/wasi-sockets/tls.ts | 2 +- .../0.2.x/node/24.x.x/http2/impl/frames.ts | 2 +- .../24.x.x/http2/impl/wasi-sockets/client.ts | 2 +- .../24.x.x/http2/impl/wasi-sockets/index.ts | 2 +- .../24.x.x/http2/impl/wasi-sockets/server.ts | 73 +-- .../24.x.x/http2/impl/wasi-sockets/shared.ts | 2 +- .../wasi/0.2.x/node/24.x.x/internal/bytes.ts | 37 ++ .../node/24.x.x/internal/wasi-sockets.ts | 516 ++++++++++++++++++ 10 files changed, 631 insertions(+), 397 deletions(-) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts index 2325cb25c..15c293fcc 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts @@ -1,38 +1,4 @@ -import { invalidArgType, invalidArgValue } from "./errors.js"; -import type { HttpBodyChunk } from "./types.js"; - -export function bodyBytes(value: HttpBodyChunk, encoding = "utf8"): Uint8Array { - if (typeof value === "string") { - const normalized = encoding.toLowerCase().replace("-", ""); - if (normalized === "utf8" || normalized === "utf") { - return new TextEncoder().encode(value); - } - if (normalized === "latin1" || normalized === "binary" || normalized === "ascii") { - return Uint8Array.from(value, (character) => character.charCodeAt(0) & 0xff); - } - throw invalidArgValue("encoding", encoding); - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value.slice(0)); - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array( - value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), - ); - } - throw invalidArgType("chunk", "string, Buffer, TypedArray, or DataView", value); -} - -export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const result = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; - } - return result; -} +export { bodyBytes, concatBytes } from "../internal/bytes.js"; export function base64(value: string): string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts index 8fada983c..543fe7fa0 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts @@ -5,12 +5,23 @@ import { type WasiTlsConnection, } from "./tls.js"; import { concatBytes } from "../../body.js"; +import { fromImplementationError, invalidArgValue, unsupported } from "../../errors.js"; import { - fromImplementationError, - invalidArgValue, - unsupported, - wasiErrorCode, -} from "../../errors.js"; + accept as acceptTcp, + bind, + connect, + dispose, + errorCode, + listen as listenTcp, + nodeAddress, + socketError, + wasiU64, + type WasiInputStream, + type WasiNetwork, + type WasiOutputStream, + type WasiSocketsProvider as WasiTcpProvider, + type WasiTcpSocket, +} from "../../../internal/wasi-sockets.js"; import { parseHttp1Request, parseHttp1Response, @@ -29,165 +40,31 @@ import type { HttpServerOptions, } from "../../types.js"; -export type WasiIpAddress = - | { tag: "ipv4"; val: [number, number, number, number] } - | { tag: "ipv6"; val: [number, number, number, number, number, number, number, number] }; - -export type WasiIpSocketAddress = - | { tag: "ipv4"; val: { port: number; address: [number, number, number, number] } } - | { - tag: "ipv6"; - val: { - port: number; - flowInfo: number; - address: [number, number, number, number, number, number, number, number]; - scopeId: number; - }; - }; - -export interface WasiPollable { - block(): void; - [Symbol.dispose]?(): void; -} - -export interface WasiInputStream { - blockingRead(length: bigint): Uint8Array; - [Symbol.dispose]?(): void; -} - -export interface WasiOutputStream { - blockingWriteAndFlush(contents: Uint8Array): void; - [Symbol.dispose]?(): void; -} - -export interface WasiResolveAddressStream { - resolveNextAddress(): WasiIpAddress | undefined; - subscribe(): WasiPollable; - [Symbol.dispose]?(): void; -} - -export interface WasiTcpSocket { - startBind?(network: unknown, localAddress: WasiIpSocketAddress): void; - finishBind?(): void; - startConnect(network: unknown, remoteAddress: WasiIpSocketAddress): void; - finishConnect(): [WasiInputStream, WasiOutputStream]; - startListen?(): void; - finishListen?(): void; - accept?(): [WasiTcpSocket, WasiInputStream, WasiOutputStream]; - localAddress?(): WasiIpSocketAddress; - remoteAddress?(): WasiIpSocketAddress; - setListenBacklogSize?(value: bigint): void; - subscribe(): WasiPollable; - shutdown(direction: "receive" | "send" | "both"): void; - [Symbol.dispose]?(): void; -} - -export interface WasiNetwork { - [Symbol.dispose]?(): void; -} - -export interface WasiSocketsProvider { +// Preserve the implementation module's existing transport exports while their ownership moves +// to the shared layer used by node:net and node:http2. +export { + connect, + dispose, + errorCode, + finishPending, + localAddress, + nodeAddress, + socketError, + wasiU64, +} from "../../../internal/wasi-sockets.js"; +export type { + WasiInputStream, + WasiIpAddress, + WasiIpSocketAddress, + WasiNetwork, + WasiOutputStream, + WasiPollable, + WasiResolveAddressStream, + WasiTcpSocket, +} from "../../../internal/wasi-sockets.js"; + +export interface WasiSocketsProvider extends WasiTcpProvider { tls?: WasiTlsProvider; - instanceNetwork: { - instanceNetwork(): WasiNetwork; - }; - ipNameLookup: { - resolveAddresses(network: WasiNetwork, name: string): WasiResolveAddressStream; - }; - tcpCreateSocket: { - createTcpSocket(family: "ipv4" | "ipv6"): WasiTcpSocket; - }; - /** Convert a safe integer to the component engine's WIT u64 representation. */ - u64?: (value: number) => bigint; - schedule?: (task: () => void | Promise) => void; -} - -export function wasiU64(provider: WasiSocketsProvider, value: number): bigint { - return provider.u64?.(value) ?? BigInt(value); -} - -export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): void { - resource?.[Symbol.dispose]?.(); -} - -export const errorCode = wasiErrorCode; - -export function socketError(error: unknown, syscall: string, hostname?: string): Error { - const code = errorCode(error) ?? "unknown"; - const nodeCodes: Record = { - "access-denied": "EACCES", - "address-in-use": "EADDRINUSE", - "connection-aborted": "ECONNABORTED", - "connection-refused": "ECONNREFUSED", - "connection-reset": "ECONNRESET", - "name-unresolvable": "ENOTFOUND", - "remote-unreachable": "EHOSTUNREACH", - timeout: "ETIMEDOUT", - }; - return fromImplementationError({ - name: "Error", - message: `${syscall} ${nodeCodes[code] ?? "ERR_JCO_WASI_SOCKET"}${hostname ? ` ${hostname}` : ""}`, - code: nodeCodes[code] ?? "ERR_JCO_WASI_SOCKET", - syscall, - hostname, - }); -} - -function remoteAddress(address: WasiIpAddress, port: number): WasiIpSocketAddress { - return address.tag === "ipv4" - ? { tag: "ipv4", val: { address: address.val, port } } - : { - tag: "ipv6", - val: { address: address.val, port, flowInfo: 0, scopeId: 0 }, - }; -} - -function parseIpv4(value: string): [number, number, number, number] | undefined { - const parts = value.split("."); - if (parts.length !== 4) { - return undefined; - } - const numbers = parts.map(Number); - return numbers.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ? (numbers as [number, number, number, number]) - : undefined; -} - -export function localAddress(host: string, port: number): WasiIpSocketAddress { - const normalized = host === "localhost" ? "127.0.0.1" : host; - const ipv4 = parseIpv4(normalized); - if (ipv4) { - return { tag: "ipv4", val: { address: ipv4, port } }; - } - if (normalized === "::" || normalized === "::1") { - return { - tag: "ipv6", - val: { - address: normalized === "::" ? [0, 0, 0, 0, 0, 0, 0, 0] : [0, 0, 0, 0, 0, 0, 0, 1], - port, - flowInfo: 0, - scopeId: 0, - }, - }; - } - return unsupported( - "http.Server.listen host", - "the wasi-sockets implementation currently accepts localhost and IPv4, ::, or ::1 literals", - ); -} - -export function nodeAddress(address: WasiIpSocketAddress): Exclude { - if (address.tag === "ipv4") { - return { - address: address.val.address.join("."), - family: "IPv4", - port: address.val.port, - }; - } - const addressText = address.val.address.every((part) => part === 0) - ? "::" - : address.val.address.join(":"); - return { address: addressText, family: "IPv6", port: address.val.port }; } export function authority(value: string, scheme = "http"): { hostname: string; port: number } { @@ -202,80 +79,6 @@ export function authority(value: string, scheme = "http"): { hostname: string; p } } -function nextAddress(stream: WasiResolveAddressStream): WasiIpAddress | undefined { - for (;;) { - try { - return stream.resolveNextAddress(); - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = stream.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } -} - -export function connect( - provider: WasiSocketsProvider, - hostname: string, - port: number, -): { socket: WasiTcpSocket; input: WasiInputStream; output: WasiOutputStream } { - const network = provider.instanceNetwork.instanceNetwork(); - let addresses: WasiResolveAddressStream; - try { - addresses = provider.ipNameLookup.resolveAddresses(network, hostname); - } catch (error) { - dispose(network); - throw socketError(error, "getaddrinfo", hostname); - } - try { - let lastError: unknown; - for (;;) { - let address: WasiIpAddress | undefined; - try { - address = nextAddress(addresses); - } catch (error) { - throw socketError(error, "getaddrinfo", hostname); - } - if (!address) { - throw socketError(lastError ?? "name-unresolvable", "connect", hostname); - } - let socket: WasiTcpSocket | undefined; - try { - socket = provider.tcpCreateSocket.createTcpSocket(address.tag); - socket.startConnect(network, remoteAddress(address, port)); - for (;;) { - try { - const [input, output] = socket.finishConnect(); - return { socket, input, output }; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = socket.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } - } catch (error) { - lastError = error; - dispose(socket); - } - } - } finally { - dispose(addresses); - dispose(network); - } -} - function readResponse( provider: WasiSocketsProvider, input: WasiInputStream, @@ -303,25 +106,6 @@ function readResponse( } } -export function finishPending(operation: () => void, socket: WasiTcpSocket): void { - for (;;) { - try { - operation(); - return; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = socket.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } -} - function readRequest( provider: WasiSocketsProvider, input: WasiInputStream, @@ -403,41 +187,24 @@ class WasiSocketsHttpServer implements HttpServerImplementation { "exclusive, ipv6Only, and reusePort cannot be configured with wasi:sockets Preview 2", ); } - const address = localAddress(options.host ?? "::", options.port ?? 0); - const network = this.#provider.instanceNetwork.instanceNetwork(); - const socket = this.#provider.tcpCreateSocket.createTcpSocket(address.tag); - if ( - !socket.startBind || - !socket.finishBind || - !socket.startListen || - !socket.finishListen || - !socket.accept || - !socket.localAddress - ) { - dispose(socket); - dispose(network); - return unsupported( - "http.Server", - "the supplied wasi-sockets implementation does not expose TCP server operations", - ); - } + const bound = bind( + this.#provider, + options.host ?? "::", + options.port ?? 0, + options.backlog, + "http.Server.listen", + ); try { - if (options.backlog !== undefined) { - socket.setListenBacklogSize?.(wasiU64(this.#provider, options.backlog)); - } - socket.startBind(network, address); - finishPending(() => socket.finishBind!(), socket); - socket.startListen(); - finishPending(() => socket.finishListen!(), socket); - this.#network = network; - this.#socket = socket; - this.#address = nodeAddress(socket.localAddress()); + listenTcp(bound.socket); + this.#network = bound.network; + this.#socket = bound.socket; + this.#address = bound.address; this.#listening = true; this.#scheduleAccept(); return this.#address; } catch (error) { - dispose(socket); - dispose(network); + dispose(bound.socket); + dispose(bound.network); throw socketError(error, "listen", options.host); } } @@ -492,22 +259,7 @@ class WasiSocketsHttpServer implements HttpServerImplementation { let input: WasiInputStream | undefined; let output: WasiOutputStream | undefined; try { - for (;;) { - try { - [connection, input, output] = listener.accept(); - break; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = listener.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } + [connection, input, output] = acceptTcp(listener); this.#connections.add(connection); const request = readRequest(this.#provider, input); if ( diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts index 2a3895777..e90a5fd96 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts @@ -10,7 +10,7 @@ import { type WasiInputStream, type WasiOutputStream, type WasiPollable, -} from "./index.js"; +} from "../../../internal/wasi-sockets.js"; export interface WasiTlsError { toDebugString(): string; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts index caeedc88d..4b448d758 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts @@ -1,4 +1,4 @@ -import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets/index.js"; +import type { WasiInputStream, WasiOutputStream } from "../../internal/wasi-sockets.js"; export const FRAME = { data: 0, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts index cf20bc481..9c6b693a7 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts @@ -5,7 +5,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets/index.js"; +} from "../../../internal/wasi-sockets.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts index 865cc729c..5f7536167 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts @@ -1,4 +1,4 @@ -import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets/index.js"; +import type { WasiSocketsProvider } from "../../../internal/wasi-sockets.js"; import type { Http2Implementation } from "../../types.js"; import { createWasiSocketsHttp2Client } from "./client.js"; import { createWasiSocketsHttp2Server } from "./server.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts index e24b74f9b..0cd6ecede 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts @@ -1,7 +1,8 @@ import { + accept, + bind, dispose, - finishPending, - localAddress, + listen as listenTcp, nodeAddress, socketError, wasiU64, @@ -10,7 +11,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets/index.js"; +} from "../../../internal/wasi-sockets.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { @@ -93,41 +94,24 @@ class Http2Server implements Http2ServerImplementation { if (options.path !== undefined) { unsupported("http2.Server.listen path", "wasi:sockets does not expose Unix domain sockets"); } - const address = localAddress(options.host ?? "::", options.port ?? 0); - const network = this.provider.instanceNetwork.instanceNetwork(); - const socket = this.provider.tcpCreateSocket.createTcpSocket(address.tag); - if ( - !socket.startBind || - !socket.finishBind || - !socket.startListen || - !socket.finishListen || - !socket.accept || - !socket.localAddress - ) { - dispose(socket); - dispose(network); - unsupported( - "http2.Server", - "the supplied wasi:sockets provider does not expose TCP server operations", - ); - } + const bound = bind( + this.provider, + options.host ?? "::", + options.port ?? 0, + options.backlog, + "http2.Server.listen", + ); try { - if (options.backlog !== undefined) { - socket.setListenBacklogSize?.(wasiU64(this.provider, options.backlog)); - } - socket.startBind(network, address); - finishPending(() => socket.finishBind!(), socket); - socket.startListen(); - finishPending(() => socket.finishListen!(), socket); - this.#network = network; - this.#socket = socket; - this.#address = nodeAddress(socket.localAddress()); + listenTcp(bound.socket); + this.#network = bound.network; + this.#socket = bound.socket; + this.#address = bound.address; this.#listening = true; this.#scheduleAccept(); return this.#address; } catch (error) { - dispose(socket); - dispose(network); + dispose(bound.socket); + dispose(bound.network); throw socketError(error, "listen", options.host); } } @@ -173,28 +157,7 @@ class Http2Server implements Http2ServerImplementation { return; } try { - let accepted: [WasiTcpSocket, WasiInputStream, WasiOutputStream]; - for (;;) { - try { - accepted = listener.accept(); - break; - } catch (error) { - const isWouldBlock = - typeof error === "object" && - error !== null && - "tag" in error && - error.tag === "would-block"; - if (!isWouldBlock) { - throw error; - } - const pollable = listener.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } + const accepted = accept(listener); const connection = { socket: accepted[0], input: accepted[1], output: accepted[2] }; this.#connections.add(connection); this.#schedule(async () => { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts index 1524bbf34..193a356af 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts @@ -3,7 +3,7 @@ import { type WasiInputStream, type WasiOutputStream, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets/index.js"; +} from "../../../internal/wasi-sockets.js"; import type { Http2Settings, HttpHeaderField } from "../../types.js"; import { encodeFrame, FLAG, FRAME, type Http2Frame } from "../frames.js"; import { encodeHeaders } from "../hpack.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts new file mode 100644 index 000000000..0d62eb2ad --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts @@ -0,0 +1,37 @@ +import { invalidArgType, invalidArgValue } from "../errors/core.js"; + +export type ByteChunk = string | ArrayBuffer | ArrayBufferView; + +/** Convert the byte-like chunk accepted by Node stream and protocol APIs. */ +export function bodyBytes(value: ByteChunk, encoding = "utf8"): Uint8Array { + if (typeof value === "string") { + const normalized = encoding.toLowerCase().replace("-", ""); + if (normalized === "utf8" || normalized === "utf") { + return new TextEncoder().encode(value); + } + if (normalized === "latin1" || normalized === "binary" || normalized === "ascii") { + return Uint8Array.from(value, (character) => character.charCodeAt(0) & 0xff); + } + throw invalidArgValue("encoding", encoding); + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), + ); + } + throw invalidArgType("chunk", "string, Buffer, TypedArray, or DataView", value); +} + +export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts new file mode 100644 index 000000000..15800789b --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts @@ -0,0 +1,516 @@ +/** + * Shared Preview 2 TCP transport for the Node-compatible net, HTTP/1, and HTTP/2 shims. + * + * The state transitions follow the WASI Preview 2 sockets specification. Node-facing address + * and error shapes follow nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). Local changes replace + * libuv handles with typed WASI resources and complete would-block operations through pollables. + */ + +import { codedError, invalidArgValue, unsupportedNodeApi } from "../errors/core.js"; +import { canonicalIpv6, isIP, parseIpv4, parseIpv6 } from "../net/ip.js"; + +export type WasiIpAddress = + | { tag: "ipv4"; val: [number, number, number, number] } + | { tag: "ipv6"; val: [number, number, number, number, number, number, number, number] }; + +export type WasiIpSocketAddress = + | { tag: "ipv4"; val: { port: number; address: [number, number, number, number] } } + | { + tag: "ipv6"; + val: { + port: number; + flowInfo: number; + address: [number, number, number, number, number, number, number, number]; + scopeId: number; + }; + }; + +export interface NodeTcpAddress { + address: string; + family: "IPv4" | "IPv6"; + port: number; +} + +export interface WasiPollable { + block(): void; + [Symbol.dispose]?(): void; +} + +export interface WasiInputStream { + blockingRead(length: bigint): Uint8Array; + [Symbol.dispose]?(): void; +} + +export interface WasiOutputStream { + blockingWriteAndFlush(contents: Uint8Array): void; + [Symbol.dispose]?(): void; +} + +export interface WasiResolveAddressStream { + resolveNextAddress(): WasiIpAddress | undefined; + subscribe(): WasiPollable; + [Symbol.dispose]?(): void; +} + +export interface WasiTcpSocket { + startBind?(network: WasiNetwork, localAddress: WasiIpSocketAddress): void; + finishBind?(): void; + startConnect(network: WasiNetwork, remoteAddress: WasiIpSocketAddress): void; + finishConnect(): [WasiInputStream, WasiOutputStream]; + startListen?(): void; + finishListen?(): void; + accept?(): [WasiTcpSocket, WasiInputStream, WasiOutputStream]; + localAddress?(): WasiIpSocketAddress; + remoteAddress?(): WasiIpSocketAddress; + setListenBacklogSize?(value: bigint): void; + setKeepAliveEnabled?(value: boolean): void; + setKeepAliveIdleTime?(value: bigint): void; + setKeepAliveInterval?(value: bigint): void; + setKeepAliveCount?(value: number): void; + subscribe(): WasiPollable; + shutdown(direction: "receive" | "send" | "both"): void; + [Symbol.dispose]?(): void; +} + +export interface WasiNetwork { + [Symbol.dispose]?(): void; +} + +export interface WasiSocketsProvider { + instanceNetwork: { + instanceNetwork(): WasiNetwork; + }; + ipNameLookup: { + resolveAddresses(network: WasiNetwork, name: string): WasiResolveAddressStream; + }; + tcpCreateSocket: { + createTcpSocket(family: "ipv4" | "ipv6"): WasiTcpSocket; + }; + /** Convert a safe integer to the component engine's WIT u64 representation. */ + u64?: (value: number) => bigint; + schedule?: (task: () => void | Promise) => void; +} + +export interface ConnectedTcpSocket { + socket: WasiTcpSocket; + input: WasiInputStream; + output: WasiOutputStream; + localAddress?: NodeTcpAddress; + remoteAddress?: NodeTcpAddress; + attemptedAddresses: string[]; +} + +export interface TcpConnectOptions { + family?: 0 | 4 | 6; + localAddress?: string; + localPort?: number; + allowAddress?: (address: string, family: 4 | 6) => boolean; + onLookup?: (address: string, family: 4 | 6, hostname: string) => void; + onAttempt?: (address: string, port: number, family: 4 | 6) => void; + socket?: WasiTcpSocket; + network?: WasiNetwork; +} + +export function wasiU64(provider: WasiSocketsProvider, value: number): bigint { + return provider.u64?.(value) ?? BigInt(value); +} + +export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): void { + resource?.[Symbol.dispose]?.(); +} + +export function schedule(provider: WasiSocketsProvider, task: () => void | Promise): void { + if (provider.schedule) { + provider.schedule(task); + } else { + queueMicrotask(() => void task()); + } +} + +export function errorCode(error: unknown): string | undefined { + // ComponentizeJS wraps WIT result errors in an Error with a payload; direct + // providers and QuickJS expose the result's error value itself. + if (typeof error === "object" && error !== null && "payload" in error) { + error = error.payload; + } + 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 function socketError( + error: unknown, + syscall: string, + hostname?: string, + address?: string, + port?: number, +): Error & { + code: string; + syscall: string; + hostname?: string; + address?: string; + port?: number; +} { + const wasiCode = errorCode(error) ?? "unknown"; + const nodeCodes: Readonly> = { + "access-denied": "EACCES", + "address-in-use": "EADDRINUSE", + "connection-aborted": "ECONNABORTED", + "connection-refused": "ECONNREFUSED", + "connection-reset": "ECONNRESET", + "name-unresolvable": "ENOTFOUND", + "remote-unreachable": "EHOSTUNREACH", + timeout: "ETIMEDOUT", + }; + const code = nodeCodes[wasiCode] ?? "ERR_JCO_WASI_SOCKET"; + const target = address ?? hostname; + const message = `${syscall} ${code}${target ? ` ${target}` : ""}${port === undefined ? "" : `:${port}`}`; + return Object.assign(codedError(new Error(message), code), { + syscall, + hostname, + address, + port, + }); +} + +export function ipSocketAddress(address: string, port: number): WasiIpSocketAddress { + const family = isIP(address); + if (family === 4) { + return { + tag: "ipv4", + val: { + address: Array.from(parseIpv4(address)!) as [number, number, number, number], + port, + }, + }; + } + if (family === 6) { + const bytes = parseIpv6(address)!; + const parts = Array.from( + { length: 8 }, + (_, index) => (bytes[index * 2] << 8) | bytes[index * 2 + 1], + ) as [number, number, number, number, number, number, number, number]; + return { + tag: "ipv6", + val: { address: parts, port, flowInfo: 0, scopeId: 0 }, + }; + } + throw invalidArgValue("address", address, "must be a valid IP address"); +} + +export function localAddress( + host: string, + port: number, + api = "Server.listen host", +): WasiIpSocketAddress { + const normalized = host === "localhost" ? "127.0.0.1" : host; + try { + return ipSocketAddress(normalized, port); + } catch { + throw unsupportedNodeApi( + api, + "wasi:sockets Preview 2 accepts localhost or numeric IPv4 and IPv6 listen addresses", + ); + } +} + +export function nodeAddress(address: WasiIpSocketAddress): NodeTcpAddress { + if (address.tag === "ipv4") { + return { + address: address.val.address.join("."), + family: "IPv4", + port: address.val.port, + }; + } + const bytes = new Uint8Array(16); + address.val.address.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return { + address: canonicalIpv6(bytes), + family: "IPv6", + port: address.val.port, + }; +} + +export function finishPending(operation: () => void, socket: WasiTcpSocket): void { + for (;;) { + try { + operation(); + return; + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +function nextAddress(stream: WasiResolveAddressStream): WasiIpAddress | undefined { + for (;;) { + try { + return stream.resolveNextAddress(); + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = stream.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +function addressText(address: WasiIpAddress): string { + if (address.tag === "ipv4") { + return address.val.join("."); + } + const bytes = new Uint8Array(16); + address.val.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return canonicalIpv6(bytes); +} + +function remoteAddress(address: WasiIpAddress, port: number): WasiIpSocketAddress { + return address.tag === "ipv4" + ? { tag: "ipv4", val: { address: address.val, port } } + : { + tag: "ipv6", + val: { address: address.val, port, flowInfo: 0, scopeId: 0 }, + }; +} + +function literalAddress(hostname: string): WasiIpAddress | undefined { + const value = isIP(hostname); + const socketAddress = value === 0 ? undefined : ipSocketAddress(hostname, 0); + return socketAddress?.tag === "ipv4" + ? { tag: "ipv4", val: socketAddress.val.address } + : socketAddress?.tag === "ipv6" + ? { tag: "ipv6", val: socketAddress.val.address } + : undefined; +} + +/** Resolve and connect a TCP socket, trying WASI-provided addresses in order. */ +export function connect( + provider: WasiSocketsProvider, + hostname: string, + port: number, + options: TcpConnectOptions = {}, +): ConnectedTcpSocket { + const network = options.network ?? provider.instanceNetwork.instanceNetwork(); + const ownsNetwork = options.network === undefined; + const literal = literalAddress(hostname); + let addresses: WasiResolveAddressStream | undefined; + if (!literal) { + try { + addresses = provider.ipNameLookup.resolveAddresses(network, hostname); + } catch (error) { + if (ownsNetwork) { + dispose(network); + } + if (options.socket) { + dispose(options.socket); + } + throw socketError(error, "getaddrinfo", hostname); + } + } + const attemptedAddresses: string[] = []; + let literalConsumed = false; + let connected = false; + try { + let lastError: unknown; + for (;;) { + let address: WasiIpAddress | undefined; + try { + address = literal && !literalConsumed ? literal : addresses && nextAddress(addresses); + literalConsumed = true; + } catch (error) { + throw socketError(error, "getaddrinfo", hostname); + } + if (!address) { + throw socketError(lastError ?? "name-unresolvable", "connect", hostname, undefined, port); + } + const family = address.tag === "ipv4" ? 4 : 6; + if ( + (options.family && options.family !== family) || + options.allowAddress?.(addressText(address), family) === false + ) { + continue; + } + const text = addressText(address); + if (!literal) { + options.onLookup?.(text, family, hostname); + } + options.onAttempt?.(text, port, family); + attemptedAddresses.push(family === 6 ? `[${text}]:${port}` : `${text}:${port}`); + let socket: WasiTcpSocket | undefined; + try { + socket = options.socket ?? provider.tcpCreateSocket.createTcpSocket(address.tag); + if (options.localAddress !== undefined || options.localPort !== undefined) { + if (!socket.startBind || !socket.finishBind) { + throw unsupportedNodeApi( + "net.Socket local bind", + "the supplied wasi:sockets provider does not expose TCP bind operations", + ); + } + const local = localAddress( + options.localAddress ?? (address.tag === "ipv4" ? "0.0.0.0" : "::"), + options.localPort ?? 0, + "net.Socket localAddress", + ); + socket.startBind(network, local); + finishPending(() => socket.finishBind!(), socket); + } + socket.startConnect(network, remoteAddress(address, port)); + for (;;) { + try { + const [input, output] = socket.finishConnect(); + const result: ConnectedTcpSocket = { + socket, + input, + output, + localAddress: socket.localAddress && nodeAddress(socket.localAddress()), + remoteAddress: socket.remoteAddress + ? nodeAddress(socket.remoteAddress()) + : { address: text, family: family === 4 ? "IPv4" : "IPv6", port }, + attemptedAddresses, + }; + connected = true; + return result; + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } + } catch (error) { + lastError = error; + if (options.socket) { + throw error; + } + dispose(socket); + } + } + } finally { + dispose(addresses); + if (ownsNetwork) { + dispose(network); + } + if (options.socket && !connected) { + dispose(options.socket); + } + } +} + +export interface BoundTcpSocket { + socket: WasiTcpSocket; + network: WasiNetwork; + address: NodeTcpAddress; +} + +/** Bind a TCP resource without choosing whether it will listen or connect. */ +export function bind( + provider: WasiSocketsProvider, + host: string, + port: number, + backlog?: number, + api = "net.Server.listen", +): BoundTcpSocket { + const local = localAddress(host, port, `${api} host`); + const network = provider.instanceNetwork.instanceNetwork(); + const socket = provider.tcpCreateSocket.createTcpSocket(local.tag); + if (!socket.startBind || !socket.finishBind || !socket.localAddress) { + dispose(socket); + dispose(network); + throw unsupportedNodeApi( + api, + "the supplied wasi:sockets provider does not expose TCP bind operations", + ); + } + try { + if (backlog !== undefined) { + socket.setListenBacklogSize?.(wasiU64(provider, backlog)); + } + socket.startBind(network, local); + finishPending(() => socket.finishBind!(), socket); + return { socket, network, address: nodeAddress(socket.localAddress()) }; + } catch (error) { + dispose(socket); + dispose(network); + throw socketError(error, "listen", undefined, host, port); + } +} + +export function listen(socket: WasiTcpSocket): void { + if (!socket.startListen || !socket.finishListen || !socket.accept) { + throw unsupportedNodeApi( + "net.Server.listen", + "the supplied wasi:sockets provider does not expose TCP server operations", + ); + } + socket.startListen(); + finishPending(() => socket.finishListen!(), socket); +} + +export function accept(socket: WasiTcpSocket): [WasiTcpSocket, WasiInputStream, WasiOutputStream] { + if (!socket.accept) { + throw unsupportedNodeApi( + "net.Server", + "the supplied wasi:sockets provider does not expose TCP accept operations", + ); + } + for (;;) { + try { + return socket.accept(); + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +export function closeTransport( + socket: WasiTcpSocket, + input?: WasiInputStream, + output?: WasiOutputStream, +): void { + try { + socket.shutdown("both"); + } catch { + // The peer may already have closed the connection. + } + dispose(output); + dispose(input); + dispose(socket); +} From b7287d7ef70eb3640150a595784f7f14a195e704 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:38:29 +0000 Subject: [PATCH 59/68] test(std): use the shared transport in HTTP socket tests --- .../test/wasi/0.2.x/node/24.x.x/http/conformance.ts | 10 +++++----- .../test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts | 2 +- .../test/wasi/0.2.x/node/24.x.x/http2/conformance.ts | 2 +- .../test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) 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 a42df63ea..e88198878 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 @@ -5,11 +5,11 @@ import { type WasiHttpFields, type WasiHttpProvider, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.js"; -import { - createWasiSocketsHttpImplementation, - type WasiSocketsProvider, - type WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; import { parseHttp1Response, serializeHttp1Request, 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 cc0efb9a8..2ea724368 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 @@ -7,7 +7,7 @@ import type { WasiOutputStream, WasiSocketsProvider, WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); 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 cbc24bcd2..f7f42e2ee 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 @@ -14,7 +14,7 @@ import { encodeHeaders, HpackDecoder, } 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/index.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; import type { DirectHttp2Settings, DirectHttp2StreamListener, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts index 10e9000c3..c543d1b28 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; import { createWasiHttpHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-http/index.js"; import { createWasiSocketsHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; describe("node:http2 via wasi-http", () => { const createImplementation = createWasiHttpHttp2Implementation; From b2da3ded93b12a14205e0afee677051ecbf23570 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:38:46 +0000 Subject: [PATCH 60/68] feat(std): add WASI-backed node:net sockets --- .../0.2.x/node/24.x.x/net/bound-socket.ts | 115 +++ .../src/wasi/0.2.x/node/24.x.x/net/socket.ts | 882 ++++++++++++++++++ 2 files changed, 997 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts new file mode 100644 index 000000000..e711e3299 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts @@ -0,0 +1,115 @@ +/** + * Preview 2-backed `net.BoundSocket`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js `BoundSocket` (MIT + * license). The libuv handle is a WASI TCP resource and `fd()` returns `-1` because components do + * not receive an operating-system descriptor. + */ + +import { + bind, + closeTransport, + dispose, + type BoundTcpSocket, + type NodeTcpAddress, + type WasiSocketsProvider, +} from "../internal/wasi-sockets.js"; +import { invalidArgType, invalidArgValue, socketHandleAdopted, unsupported } from "./errors.js"; +import { isIP } from "./ip.js"; +import { validatePort } from "./socket-address.js"; + +export interface BoundSocketOptions { + port?: number | string; + host?: string | null; + ipv6Only?: boolean; + reusePort?: boolean; +} + +export const consumeBoundSocket = Symbol("consumeBoundSocket"); + +export class BoundSocketBase { + #bound: BoundTcpSocket | undefined; + + constructor(provider: WasiSocketsProvider, options: BoundSocketOptions = {}) { + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + if (options.ipv6Only !== undefined && typeof options.ipv6Only !== "boolean") { + throw invalidArgType("options.ipv6Only", "boolean", options.ipv6Only); + } + if (options.reusePort !== undefined && typeof options.reusePort !== "boolean") { + throw invalidArgType("options.reusePort", "boolean", options.reusePort); + } + if (options.ipv6Only || options.reusePort) { + unsupported( + "net.BoundSocket ipv6Only/reusePort", + "wasi:sockets Preview 2 does not expose these bind flags", + ); + } + const host = options.host ?? (options.ipv6Only ? "::" : "0.0.0.0"); + if (typeof host !== "string") { + throw invalidArgType("options.host", "string", host); + } + if (isIP(host) === 0) { + throw invalidArgValue( + "options.host", + host, + "must be a numeric IP address; net.BoundSocket does not perform DNS resolution", + ); + } + this.#bound = bind(provider, host, validatePort(options.port ?? 0)); + } + + address(): NodeTcpAddress { + return { ...this.#get().address }; + } + + fd(): number { + this.#get(); + return -1; + } + + close(): void { + const bound = this.#get(); + dispose(bound.socket); + dispose(bound.network); + this.#bound = undefined; + } + + [Symbol.dispose](): void { + if (this.#bound) { + closeTransport(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + } + } + + [consumeBoundSocket](): BoundTcpSocket { + const bound = this.#get(); + this.#bound = undefined; + return bound; + } + + #get(): BoundTcpSocket { + if (!this.#bound) { + throw socketHandleAdopted(); + } + return this.#bound; + } +} + +export interface BoundSocketConstructor { + new (options?: BoundSocketOptions): BoundSocketBase; + readonly prototype: BoundSocketBase; +} + +export function createBoundSocketConstructor( + provider: WasiSocketsProvider, +): BoundSocketConstructor { + return class BoundSocket extends BoundSocketBase { + constructor(options?: BoundSocketOptions) { + super(provider, options); + } + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts new file mode 100644 index 000000000..ec9a081e3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts @@ -0,0 +1,882 @@ +/** + * Preview 2-backed `net.Socket`. + * + * The public state transitions and event ordering are adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). libuv stream handles are + * replaced by WASI TCP/input/output resources. Since jco-std has no classic Node Duplex core yet, + * this class supplies the commonly used Duplex-shaped methods directly over its EventEmitter. + */ + +import { Buffer } from "node:buffer"; + +import { bodyBytes } from "../internal/bytes.js"; +import { StringDecoder } from "../string-decoder.js"; +import { EventEmitter } from "../internal/event-emitter.js"; +import { + closeTransport, + connect as connectTcp, + dispose, + errorCode, + schedule, + socketError, + wasiU64, + type BoundTcpSocket, + type ConnectedTcpSocket, + type NodeTcpAddress, + type WasiInputStream, + type WasiOutputStream, + type WasiSocketsProvider, + type WasiTcpSocket, +} from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { BoundSocketBase, consumeBoundSocket } from "./bound-socket.js"; +import { + AbortError, + deprecated, + invalidArgType, + invalidArgValue, + ipBlocked, + outOfRange, + socketClosed, + socketClosedBeforeConnection, + unsupported, +} from "./errors.js"; +import { + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, +} from "./defaults.js"; +import { normalizeArgs, normalizedArgsSymbol, type NormalizedArgs } from "./normalize.js"; +import { validatePort } from "./socket-address.js"; +import type { + AddressInfo, + NetCallback, + NetChunk, + NetEncoding, + NetErrorCallback, + SocketConnectOptions, + SocketConstructorOptions, + SocketEventMap, + SocketReadyState, + TcpSocketConnectOptions, + WritableDestination, +} from "./types.js"; + +type Timer = ReturnType; +type Listener = (...args: never[]) => unknown; + +interface PendingRead { + resolve: (result: IteratorResult) => void; + reject: (error: Error) => void; +} + +interface AcceptedTransport { + socket: WasiTcpSocket; + input: WasiInputStream; + output: WasiOutputStream; + localAddress?: NodeTcpAddress; + remoteAddress?: NodeTcpAddress; +} + +export const attachAcceptedTransport = Symbol("attachAcceptedTransport"); +export const startSocketReading = Symbol("startSocketReading"); + +function callbackFrom(values: readonly unknown[]): NetCallback | undefined { + const last = values.at(-1); + return typeof last === "function" ? (last as NetCallback) : undefined; +} + +function normalizedConnectArgs(args: readonly unknown[]): NormalizedArgs { + const first = args[0]; + return Array.isArray(first) && + (first as unknown as { [normalizedArgsSymbol]?: unknown })[normalizedArgsSymbol] + ? (first as NormalizedArgs) + : normalizeArgs(args); +} + +function family(value: unknown): 0 | 4 | 6 | undefined { + if (value === undefined || value === null || value === 0 || value === 4 || value === 6) { + return value ?? undefined; + } + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (normalized === "ipv4") { + return 4; + } + if (normalized === "ipv6") { + return 6; + } + } + throw invalidArgValue("options.family", value); +} + +export class SocketBase extends EventEmitter implements AsyncIterable { + readonly #provider: WasiSocketsProvider; + readonly #allowHalfOpen: boolean; + readonly #onread: SocketConstructorOptions["onread"]; + #bound: BoundTcpSocket | undefined; + #socket: WasiTcpSocket | undefined; + #input: WasiInputStream | undefined; + #output: WasiOutputStream | undefined; + #local: NodeTcpAddress | undefined; + #remote: NodeTcpAddress | undefined; + #decoder: StringDecoder | undefined; + #paused = false; + #reading = false; + #readQueue: Array = []; + #pendingReads: PendingRead[] = []; + #timeoutTimer: Timer | undefined; + #timeout: number | undefined; + #closed = false; + #hadError = false; + #error: Error | undefined; + #signal: AbortSignal | undefined; + #abort: (() => void) | undefined; + #noDelay = false; + #keepAlive = false; + #keepAliveInitialDelay = 0; + #keepAliveInterval: number | undefined; + #keepAliveCount: number | undefined; + #bytesRead = 0; + #bytesWritten = 0; + #connecting = false; + #readable: boolean; + #writable: boolean; + + declare autoSelectFamilyAttemptedAddresses: string[] | undefined; + readonly allowHalfOpen: boolean; + server: unknown = null; + _server: unknown = null; + + constructor(provider: WasiSocketsProvider, options: SocketConstructorOptions = {}) { + super(); + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + for (const name of ["objectMode", "readableObjectMode", "writableObjectMode"] as const) { + if (options[name]) { + throw invalidArgValue(`options.${name}`, options[name], "is not supported"); + } + } + if (options.blockList !== undefined && !BlockList.isBlockList(options.blockList)) { + throw invalidArgType("options.blockList", "net.BlockList", options.blockList); + } + if ( + options.keepAliveInitialDelay !== undefined && + typeof options.keepAliveInitialDelay !== "number" + ) { + throw invalidArgType( + "options.keepAliveInitialDelay", + "number", + options.keepAliveInitialDelay, + ); + } + if (options.fd !== undefined) { + unsupported( + "net.Socket options.fd", + "operating-system file descriptors cannot cross the component boundary", + ); + } + this.#provider = provider; + this.#allowHalfOpen = Boolean(options.allowHalfOpen); + this.allowHalfOpen = this.#allowHalfOpen; + const onread = options.onread; + this.#onread = + onread !== null && + typeof onread === "object" && + (onread.buffer instanceof Uint8Array || typeof onread.buffer === "function") && + typeof onread.callback === "function" + ? onread + : undefined; + this.#readable = options.readable !== false; + this.#writable = options.writable !== false; + this.#noDelay = Boolean(options.noDelay); + this.#keepAlive = Boolean(options.keepAlive); + this.#keepAliveInitialDelay = + ~~(Math.max(0, options.keepAliveInitialDelay ?? 0) / 1_000) * 1_000; + if (options.handle !== undefined) { + if (!(options.handle instanceof BoundSocketBase)) { + unsupported( + "net.Socket options.handle", + "components can adopt only a net.BoundSocket, not a libuv or file-descriptor handle", + ); + } + this.#bound = options.handle[consumeBoundSocket](); + this.#local = this.#bound.address; + } + this.#setSignal(options.signal); + } + + get connecting(): boolean { + return this.#connecting; + } + + on( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + on(event: string, listener: Listener): this; + on(event: string, listener: Listener): this { + super.on(event, listener); + if (event === "data" && !this.#paused) { + queueMicrotask(() => { + if (!this.#paused) { + this.resume(); + } + }); + } + return this; + } + + addListener( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + addListener(event: string, listener: Listener): this; + addListener(event: string, listener: Listener): this { + return this.on(event, listener); + } + + once( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + once(event: string, listener: Listener): this; + once(event: string, listener: Listener): this { + super.once(event, listener); + if (event === "data" && !this.#paused) { + queueMicrotask(() => { + if (!this.#paused) { + this.resume(); + } + }); + } + return this; + } + + get pending(): boolean { + return this.#socket === undefined || this.#connecting; + } + + get destroyed(): boolean { + return this.#closed; + } + + get readable(): boolean { + return this.#readable; + } + + get writable(): boolean { + return this.#writable; + } + + get readableEnded(): boolean { + return !this.#readable; + } + + get writableEnded(): boolean { + return !this.#writable; + } + + get writableLength(): number { + return 0; + } + + get readyState(): SocketReadyState { + if (this.#connecting) { + return "opening"; + } + if (this.#readable && this.#writable) { + return "open"; + } + if (this.#readable) { + return "readOnly"; + } + if (this.#writable) { + return "writeOnly"; + } + return "closed"; + } + + get bufferSize(): never { + return deprecated("net.Socket.bufferSize", "socket.writableLength"); + } + + get bytesRead(): number { + return this.#bytesRead; + } + + get bytesWritten(): number { + return this.#bytesWritten; + } + + get remoteAddress(): string | undefined { + return this.#remote?.address; + } + + get remoteFamily(): string | undefined { + return this.#remote?.family; + } + + get remotePort(): number | undefined { + return this.#remote?.port; + } + + get localAddress(): string | undefined { + return this.#local?.address; + } + + get localFamily(): string | undefined { + return this.#local?.family; + } + + get localPort(): number | undefined { + return this.#local?.port; + } + + get timeout(): number | undefined { + return this.#timeout; + } + + connect(options: SocketConnectOptions, connectionListener?: NetCallback): this; + connect(port: number, host?: string | NetCallback, connectionListener?: NetCallback): this; + connect(path: string, connectionListener?: NetCallback): this; + connect(...args: unknown[]): this { + if (this.#closed) { + throw socketClosed(); + } + const [rawOptions, listener] = normalizedConnectArgs(args); + const options = rawOptions as Partial & { path?: unknown }; + this.#setSignal(options.signal); + const connectionListener = (listener as NetCallback | null) ?? callbackFrom(args); + if (connectionListener) { + this.once("connect", connectionListener as Listener); + } + if (options.path !== undefined) { + return unsupported( + "net.Socket.connect path", + "wasi:sockets Preview 2 exposes IP sockets but not Unix-domain sockets or named pipes", + ); + } + const port = validatePort(options.port, "options.port"); + const host = options.host ?? "localhost"; + if (typeof host !== "string") { + throw invalidArgType("options.host", "string", host); + } + if (options.lookup !== undefined) { + return unsupported( + "net.Socket.connect custom lookup", + "asynchronous JavaScript DNS callbacks cannot be retained across the Preview 2 boundary", + ); + } + const selectedFamily = family(options.family); + if (options.timeout !== undefined) { + this.setTimeout(options.timeout); + } + const autoSelect = options.autoSelectFamily ?? getDefaultAutoSelectFamily(); + if (typeof autoSelect !== "boolean") { + throw invalidArgType("options.autoSelectFamily", "boolean", autoSelect); + } + const attemptTimeout = + options.autoSelectFamilyAttemptTimeout ?? getDefaultAutoSelectFamilyAttemptTimeout(); + if ( + typeof attemptTimeout !== "number" || + !Number.isInteger(attemptTimeout) || + attemptTimeout < 1 || + attemptTimeout > 0x7fff_ffff + ) { + throw outOfRange("options.autoSelectFamilyAttemptTimeout", "an integer >= 1", attemptTimeout); + } + const lookupEvents: Array<[string, 4 | 6, string]> = []; + if (this.#signal?.aborted) { + return this; + } + this.#connecting = true; + try { + const bound = this.#bound; + this.#bound = undefined; + let transport: ConnectedTcpSocket; + try { + transport = connectTcp(this.#provider, host, port, { + family: selectedFamily, + localAddress: options.localAddress, + localPort: options.localPort, + socket: bound?.socket, + network: bound?.network, + allowAddress: (address, addressFamily) => { + if (options.blockList?.check(address, addressFamily === 4 ? "ipv4" : "ipv6")) { + throw ipBlocked(address); + } + return true; + }, + onLookup: (address, addressFamily, hostname) => + lookupEvents.push([address, addressFamily, hostname]), + onAttempt: (address, attemptPort, addressFamily) => + queueMicrotask(() => + this.emit("connectionAttempt", address, attemptPort, addressFamily), + ), + }); + } finally { + if (bound) { + dispose(bound.network); + } + } + this.#attach(transport); + if (autoSelect && transport.attemptedAddresses.length > 1) { + this.autoSelectFamilyAttemptedAddresses = transport.attemptedAddresses; + } + queueMicrotask(() => { + if (this.#closed) { + return; + } + for (const [address, addressFamily, hostname] of lookupEvents) { + this.emit("lookup", null, address, addressFamily, hostname); + } + this.#connecting = false; + this.emit("connect"); + this.emit("ready"); + this[startSocketReading](); + }); + } catch (error) { + this.#connecting = false; + const failure = + error instanceof Error ? error : socketError(error, "connect", host, undefined, port); + queueMicrotask(() => this.destroy(failure)); + } + return this; + } + + write(chunk: NetChunk, callback?: NetErrorCallback): boolean; + write(chunk: NetChunk, encoding?: string, callback?: NetErrorCallback): boolean; + write( + chunk: NetChunk, + encodingOrCallback?: string | NetErrorCallback, + callback?: NetErrorCallback, + ): boolean { + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (!this.#writable || this.#closed || !this.#output) { + const error = socketClosed(); + queueMicrotask(() => { + done?.(error); + this.emit("error", error); + }); + return false; + } + try { + if (encoding !== undefined && !Buffer.isEncoding(encoding)) { + throw invalidArgValue("encoding", encoding); + } + const bytes = typeof chunk === "string" ? Buffer.from(chunk, encoding) : bodyBytes(chunk); + this.#output.blockingWriteAndFlush(bytes); + this.#bytesWritten += bytes.byteLength; + this.#refreshTimeout(); + queueMicrotask(() => done?.(null)); + return true; + } catch (error) { + const failure = error instanceof Error ? error : socketError(error, "write"); + queueMicrotask(() => { + done?.(failure); + this.destroy(failure); + }); + return false; + } + } + + end(callback?: NetCallback): this; + end(chunk: NetChunk, callback?: NetCallback): this; + end(chunk: NetChunk, encoding?: string, callback?: NetCallback): this; + end( + chunkOrCallback?: NetChunk | NetCallback, + encodingOrCallback?: string | NetCallback, + callback?: NetCallback, + ): this { + const chunk = typeof chunkOrCallback === "function" ? undefined : chunkOrCallback; + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; + const done = + typeof chunkOrCallback === "function" + ? chunkOrCallback + : typeof encodingOrCallback === "function" + ? encodingOrCallback + : callback; + if (chunk !== undefined) { + this.write(chunk, encoding); + } + if (this.#writable) { + this.#writable = false; + try { + this.#socket?.shutdown("send"); + } catch (error) { + if (errorCode(error) !== "not-connected") { + this.#hadError = true; + } + } + dispose(this.#output); + this.#output = undefined; + queueMicrotask(() => { + this.emit("finish"); + done?.(); + if (!this.#readable) { + this.destroy(); + } + }); + } else { + queueMicrotask(() => done?.()); + } + return this; + } + + destroy(error?: Error): this { + if (this.#closed) { + return this; + } + if (this.#connecting && !error) { + error = socketClosedBeforeConnection(); + } + this.#closed = true; + this.#connecting = false; + this.#readable = false; + this.#writable = false; + this.#hadError ||= error !== undefined; + this.#error = error; + if (this.#signal && this.#abort) { + this.#signal.removeEventListener("abort", this.#abort); + this.#signal = undefined; + this.#abort = undefined; + } + if (this.#timeoutTimer !== undefined) { + clearTimeout(this.#timeoutTimer); + this.#timeoutTimer = undefined; + } + if (this.#socket) { + closeTransport(this.#socket, this.#input, this.#output); + } + if (this.#bound) { + closeTransport(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + } + this.#socket = undefined; + this.#input = undefined; + this.#output = undefined; + while (this.#pendingReads.length > 0) { + const pending = this.#pendingReads.shift()!; + if (error) { + pending.reject(error); + } else { + pending.resolve({ done: true, value: undefined }); + } + } + queueMicrotask(() => { + if (error) { + this.emit("error", error); + } + this.emit("close", this.#hadError); + }); + return this; + } + + destroySoon(): void { + if (this.#writable) { + this.end(() => this.destroy()); + } else { + this.destroy(); + } + } + + resetAndDestroy(): never { + return unsupported( + "net.Socket.resetAndDestroy", + "wasi:sockets Preview 2 does not expose TCP reset-on-close", + ); + } + + pause(): this { + this.#paused = true; + return this; + } + + resume(): this { + this.#paused = false; + while (!this.#paused && this.#readQueue.length > 0) { + this.emit("data", this.#readQueue.shift()); + } + this[startSocketReading](); + return this; + } + + read(): Uint8Array | string | null { + return this.#readQueue.shift() ?? null; + } + + pipe(destination: WritableDestination): WritableDestination { + this.on("data", ((chunk: Uint8Array | string) => destination.write(chunk)) as Listener); + this.once("end", (() => destination.end?.()) as Listener); + this.resume(); + return destination; + } + + setEncoding(encoding: NetEncoding = "utf8"): this { + this.#decoder = new StringDecoder(encoding); + return this; + } + + setTimeout(timeout: number, callback?: NetCallback): this { + if (typeof timeout !== "number") { + throw invalidArgType("msecs", "number", timeout); + } + if (!Number.isFinite(timeout) || timeout < 0 || timeout > 0xffff_ffff) { + throw outOfRange("msecs", ">= 0 and <= 4294967295", timeout); + } + if (timeout > 0 && typeof setTimeout !== "function") { + return unsupported("net.Socket.setTimeout", "the component engine does not provide timers"); + } + this.#timeout = Math.trunc(timeout); + if (callback) { + this.once("timeout", callback as Listener); + } + this.#refreshTimeout(); + return this; + } + + setNoDelay(noDelay = true): this { + this.#noDelay = Boolean(noDelay); + return this; + } + + setKeepAlive(enable = false, initialDelay = 0, interval?: number, count?: number): this { + this.#keepAlive = Boolean(enable); + this.#keepAliveInitialDelay = ~~(initialDelay / 1_000) * 1_000; + this.#keepAliveInterval = interval === undefined ? undefined : ~~(interval / 1_000) * 1_000; + this.#keepAliveCount = count; + this.#applySocketOptions(); + return this; + } + + setTypeOfService(tos: number): never { + if (typeof tos !== "number" || Number.isNaN(tos)) { + throw invalidArgType("tos", "number", tos); + } + if (!Number.isInteger(tos) || tos < 0 || tos > 255) { + throw outOfRange("tos", ">= 0 and <= 255", tos); + } + return unsupported( + "net.Socket.setTypeOfService", + "wasi:sockets Preview 2 does not expose the IP type-of-service socket option", + ); + } + + getTypeOfService(): never { + return unsupported( + "net.Socket.getTypeOfService", + "wasi:sockets Preview 2 does not expose the IP type-of-service socket option", + ); + } + + address(): AddressInfo | Record { + return this.#local ? { ...this.#local } : {}; + } + + ref(): this { + return this; + } + + unref(): this { + return this; + } + + [attachAcceptedTransport](transport: AcceptedTransport): void { + this.#attach({ ...transport, attemptedAddresses: [] }); + this.#connecting = false; + } + + [startSocketReading](): void { + if (this.#reading || this.#paused || !this.#readable || this.#closed || !this.#input) { + return; + } + this.#reading = true; + schedule(this.#provider, () => this.#readOnce()); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + // An iterator consumes stream errors through its rejected next() promise. + const onError = (): void => {}; + this.on("error", onError); + try { + for (;;) { + if (this.#error) { + throw this.#error; + } + const queued = this.#readQueue.shift(); + if (queued !== undefined) { + yield queued; + continue; + } + if (!this.#readable || this.#closed) { + return; + } + const result = await new Promise>((resolve, reject) => { + this.#pendingReads.push({ resolve, reject }); + this[startSocketReading](); + }); + if (result.done) { + return; + } + yield result.value; + } + } finally { + if (!this.#closed) { + this.destroy(); + } + queueMicrotask(() => this.off("error", onError)); + } + } + + #attach(transport: ConnectedTcpSocket): void { + this.#socket = transport.socket; + this.#input = transport.input; + this.#output = transport.output; + this.#local = transport.localAddress; + this.#remote = transport.remoteAddress; + this.#readable = true; + this.#writable = true; + this.#applySocketOptions(); + this.#refreshTimeout(); + } + + #applySocketOptions(): void { + const socket = this.#socket; + if (!socket) { + return; + } + socket.setKeepAliveEnabled?.(this.#keepAlive); + if (this.#keepAlive && this.#keepAliveInitialDelay > 0) { + socket.setKeepAliveIdleTime?.( + wasiU64(this.#provider, this.#keepAliveInitialDelay * 1_000_000), + ); + } + if (this.#keepAlive && this.#keepAliveInterval !== undefined && this.#keepAliveInterval > 0) { + socket.setKeepAliveInterval?.( + wasiU64(this.#provider, Math.trunc(this.#keepAliveInterval) * 1_000_000), + ); + } + if (this.#keepAlive && this.#keepAliveCount !== undefined && this.#keepAliveCount > 0) { + socket.setKeepAliveCount?.(Math.trunc(this.#keepAliveCount)); + } + void this.#noDelay; + } + + #refreshTimeout(): void { + if (this.#timeoutTimer !== undefined) { + clearTimeout(this.#timeoutTimer); + this.#timeoutTimer = undefined; + } + if (!this.#timeout) { + return; + } + this.#timeoutTimer = setTimeout(() => this.emit("timeout"), this.#timeout); + } + + #setSignal(signal: AbortSignal | undefined): void { + if (!signal || signal === this.#signal) { + return; + } + if (this.#signal && this.#abort) { + this.#signal.removeEventListener("abort", this.#abort); + } + this.#signal = signal; + this.#abort = () => { + this.destroy(new AbortError()); + }; + if (signal.aborted) { + queueMicrotask(this.#abort); + } else { + signal.addEventListener("abort", this.#abort, { once: true }); + } + } + + #readOnce(): void { + this.#reading = false; + if (this.#paused || !this.#readable || this.#closed || !this.#input) { + return; + } + try { + const target = this.#onread + ? typeof this.#onread.buffer === "function" + ? this.#onread.buffer() + : this.#onread.buffer + : undefined; + if (target && target.byteLength === 0) { + throw invalidArgValue("options.onread.buffer", target, "must not be empty"); + } + const bytes = this.#input.blockingRead(wasiU64(this.#provider, target?.byteLength ?? 65_536)); + if (bytes.byteLength > 0) { + this.#bytesRead += bytes.byteLength; + this.#refreshTimeout(); + if (this.#onread && target) { + target.set(bytes); + if (this.#onread.callback(bytes.byteLength, target) === false) { + this.#paused = true; + } + } else { + this.#deliver(this.#decoder ? this.#decoder.write(bytes) : Buffer.from(bytes)); + } + } + this[startSocketReading](); + } catch (error) { + if (errorCode(error) === "closed") { + if (this.#decoder) { + this.#deliver(this.#decoder.end()); + } + this.#readable = false; + dispose(this.#input); + this.#input = undefined; + while (this.#pendingReads.length > 0) { + this.#pendingReads.shift()!.resolve({ done: true, value: undefined }); + } + this.emit("end"); + if (!this.#allowHalfOpen && this.#writable) { + this.end(); + } + if (!this.#writable) { + this.destroy(); + } + } else { + this.destroy(error instanceof Error ? error : socketError(error, "read")); + } + } + } + + #deliver(value: Uint8Array | string): void { + if (value.length === 0) { + return; + } + const pending = this.#pendingReads.shift(); + if (pending) { + pending.resolve({ done: false, value }); + } else if (this.#paused || this.listenerCount("data") === 0) { + this.#readQueue.push(value); + this.emit("readable"); + } else { + this.emit("data", value); + } + } +} + +export interface SocketConstructor { + (options?: SocketConstructorOptions): SocketBase; + new (options?: SocketConstructorOptions): SocketBase; + readonly prototype: SocketBase; +} + +export function createSocketConstructor(provider: WasiSocketsProvider): SocketConstructor { + class Socket extends SocketBase { + constructor(options?: SocketConstructorOptions) { + super(provider, options); + } + } + return new Proxy(Socket, { + apply(_target, _thisArgument, argumentsList) { + return new Socket(argumentsList[0] as SocketConstructorOptions | undefined); + }, + }) as SocketConstructor; +} From 92eb5d48f756f02c39502aca5db9ca635353267f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:39:02 +0000 Subject: [PATCH 61/68] feat(std): expose node:net servers and module exports --- packages/jco-std/package.json | 10 + .../wasi/0.2.x/node/24.x.x/net-interface.d.ts | 11 + .../jco-std/src/wasi/0.2.x/node/24.x.x/net.ts | 36 ++ .../src/wasi/0.2.x/node/24.x.x/net/core.ts | 110 ++++ .../src/wasi/0.2.x/node/24.x.x/net/server.ts | 495 ++++++++++++++++++ packages/jco-std/tsconfig.json | 3 + 6 files changed, 665 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index ea5e02c00..16eaef86b 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -187,6 +187,16 @@ "browser": "./dist/wasi/0.2.x/node/24.x.x/https/core.js", "default": "./dist/wasi/0.2.x/node/24.x.x/https/core.js" }, + "./wasi/0.2.x/node/24.x.x/net": { + "types": "./dist/wasi/0.2.x/node/24.x.x/net.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/net.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/net.js" + }, + "./wasi/0.2.x/node/24.x.x/net/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/net/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/net/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/net/core.js" + }, "./wasi/0.2.x/node/24.x.x/path": { "types": "./dist/wasi/0.2.x/node/24.x.x/path.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/path.js", diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts new file mode 100644 index 000000000..58ff35a10 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts @@ -0,0 +1,11 @@ +declare module "wasi:sockets/instance-network@0.2.12" { + export const instanceNetwork: import("./internal/wasi-sockets.js").WasiSocketsProvider["instanceNetwork"]["instanceNetwork"]; +} + +declare module "wasi:sockets/ip-name-lookup@0.2.12" { + export const resolveAddresses: import("./internal/wasi-sockets.js").WasiSocketsProvider["ipNameLookup"]["resolveAddresses"]; +} + +declare module "wasi:sockets/tcp-create-socket@0.2.12" { + export const createTcpSocket: import("./internal/wasi-sockets.js").WasiSocketsProvider["tcpCreateSocket"]["createTcpSocket"]; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts new file mode 100644 index 000000000..b7a64f7c2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts @@ -0,0 +1,36 @@ +import * as instanceNetwork from "wasi:sockets/instance-network@0.2.12"; +import * as ipNameLookup from "wasi:sockets/ip-name-lookup@0.2.12"; +import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@0.2.12"; + +import { createNet } from "./net/core.js"; + +const net = createNet({ instanceNetwork, ipNameLookup, tcpCreateSocket }); + +export type * from "./net/types.js"; +export type Socket = import("./net/socket.js").SocketBase; +export type Server = import("./net/server.js").ServerBase; +export type BoundSocket = import("./net/bound-socket.js").BoundSocketBase; +export type SocketAddress = import("./net/socket-address.js").SocketAddress; +export type BlockList = import("./net/block-list.js").BlockList; +export const { + BlockList, + BoundSocket, + Server, + Socket, + SocketAddress, + Stream, + _createServerHandle, + _normalizeArgs, + connect, + createConnection, + createServer, + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + isIP, + isIPv4, + isIPv6, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, +} = net; + +export default net; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts new file mode 100644 index 000000000..bdd965aa3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts @@ -0,0 +1,110 @@ +/** + * Portable core for `node:net`. + * + * The export surface, aliases, factories, and overload normalization follow nodejs/node v24.19.0, + * commit cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). + */ + +import type { WasiSocketsProvider } from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { createBoundSocketConstructor, type BoundSocketConstructor } from "./bound-socket.js"; +import { + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, +} from "./defaults.js"; +import { unsupported } from "./errors.js"; +import { isIP, isIPv4, isIPv6 } from "./ip.js"; +import { normalizeArgs, type NormalizedArgs } from "./normalize.js"; +import { + createServerConstructor, + type ConnectionListener, + type ServerBase, + type ServerConstructor, +} from "./server.js"; +import { createSocketConstructor, type SocketBase, type SocketConstructor } from "./socket.js"; +import { SocketAddress } from "./socket-address.js"; +import type { NetCallback, ServerOptions, SocketConnectOptions } from "./types.js"; + +export interface CreateConnection { + (options: SocketConnectOptions, connectionListener?: NetCallback): SocketBase; + (port: number, host?: string | NetCallback, connectionListener?: NetCallback): SocketBase; + (path: string, connectionListener?: NetCallback): SocketBase; +} + +export interface CreateServer { + (connectionListener?: ConnectionListener): ServerBase; + (options?: ServerOptions | null, connectionListener?: ConnectionListener): ServerBase; +} + +export interface NodeNetModule { + BlockList: typeof BlockList; + BoundSocket: BoundSocketConstructor; + Server: ServerConstructor; + Socket: SocketConstructor; + SocketAddress: typeof SocketAddress; + Stream: SocketConstructor; + _createServerHandle: (...args: unknown[]) => never; + _normalizeArgs: (args: readonly unknown[]) => NormalizedArgs; + connect: CreateConnection; + createConnection: CreateConnection; + createServer: CreateServer; + getDefaultAutoSelectFamily: typeof getDefaultAutoSelectFamily; + getDefaultAutoSelectFamilyAttemptTimeout: typeof getDefaultAutoSelectFamilyAttemptTimeout; + isIP: typeof isIP; + isIPv4: typeof isIPv4; + isIPv6: typeof isIPv6; + setDefaultAutoSelectFamily: typeof setDefaultAutoSelectFamily; + setDefaultAutoSelectFamilyAttemptTimeout: typeof setDefaultAutoSelectFamilyAttemptTimeout; +} + +export function createNet(provider: WasiSocketsProvider): NodeNetModule { + const BoundSocket = createBoundSocketConstructor(provider); + const Socket = createSocketConstructor(provider); + const Server = createServerConstructor(provider, Socket); + + const createConnection = ((...args: unknown[]): SocketBase => { + const normalized = normalizeArgs(args); + const socket = new Socket(normalized[0] as unknown as SocketConnectOptions); + return (socket.connect as (...values: unknown[]) => SocketBase).call(socket, normalized); + }) as CreateConnection; + const connect = createConnection; + + const createServer = (( + optionsOrListener?: ServerOptions | ConnectionListener | null, + listener?: ConnectionListener, + ): ServerBase => new Server(optionsOrListener, listener)) as CreateServer; + + function _createServerHandle(..._args: unknown[]): never { + return unsupported( + "net._createServerHandle", + "components cannot create or expose Node/libuv server handles", + ); + } + + function _normalizeArgs(args: readonly unknown[]): NormalizedArgs { + return normalizeArgs(args); + } + + return { + BlockList, + BoundSocket, + Server, + Socket, + SocketAddress, + Stream: Socket, + _createServerHandle, + _normalizeArgs, + connect, + createConnection, + createServer, + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + isIP, + isIPv4, + isIPv6, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts new file mode 100644 index 000000000..ddf387939 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts @@ -0,0 +1,495 @@ +/** + * Preview 2-backed `net.Server`. + * + * Public lifecycle and connection-count behavior are adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). libuv listen handles are + * replaced by WASI TCP resources. + */ + +import { EventEmitter } from "../internal/event-emitter.js"; +import { + accept, + bind, + closeTransport, + dispose, + listen, + nodeAddress, + schedule, + socketError, + type BoundTcpSocket, + type NodeTcpAddress, + type WasiSocketsProvider, +} from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { BoundSocketBase, consumeBoundSocket } from "./bound-socket.js"; +import { + invalidArgType, + outOfRange, + serverAlreadyListening, + serverNotRunning, + unsupported, +} from "./errors.js"; +import { + attachAcceptedTransport, + startSocketReading, + type SocketBase, + type SocketConstructor, +} from "./socket.js"; +import { validatePort } from "./socket-address.js"; +import type { + AddressInfo, + DropArgument, + ListenOptions, + NetCallback, + ServerOptions, +} from "./types.js"; + +type Listener = (...args: never[]) => unknown; +type CloseCallback = (error?: Error) => void; +type ConnectionsCallback = (error: Error | null, count: number) => void; +export type ConnectionListener = (socket: SocketBase) => void; +export interface ServerEventMap { + close: []; + connection: [socket: SocketBase]; + listening: []; + error: [error: Error]; + drop: [data: DropArgument]; +} +const DEFAULT_HIGH_WATER_MARK = 65_536; + +function callbackFrom(values: readonly unknown[]): NetCallback | undefined { + const last = values.at(-1); + return typeof last === "function" ? (last as NetCallback) : undefined; +} + +function backlogFrom(values: readonly unknown[]): number | undefined { + for (const value of values.slice(1)) { + if (typeof value === "number") { + return value; + } + } + return undefined; +} + +function listenOptions(values: readonly unknown[]): ListenOptions | BoundSocketBase { + const first = values[0]; + if (first instanceof BoundSocketBase) { + return first; + } + if (first === undefined || typeof first === "function") { + return { port: 0 }; + } + if (typeof first === "object" && first !== null) { + return first as ListenOptions; + } + if (typeof first === "string" && !(Number(first) >= 0)) { + return { path: first }; + } + const options: ListenOptions = { port: validatePort(first, "options.port") }; + if (typeof values[1] === "string") { + options.host = values[1]; + } + options.backlog = backlogFrom(values); + return options; +} + +function validateBacklog(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw invalidArgType("options.backlog", "integer", value); + } + if (value < 0 || value > 0x7fff_ffff) { + throw outOfRange("options.backlog", ">= 0 and <= 2147483647", value); + } + return value; +} + +export class ServerBase extends EventEmitter { + readonly #provider: WasiSocketsProvider; + readonly #Socket: SocketConstructor; + #bound: BoundTcpSocket | undefined; + #listening = false; + #closing = false; + #connections = new Set(); + #address: NodeTcpAddress | undefined; + + readonly allowHalfOpen: boolean; + readonly pauseOnConnect: boolean; + readonly noDelay: boolean; + readonly keepAlive: boolean; + readonly keepAliveInitialDelay: number; + readonly highWaterMark: number; + declare readonly blockList: BlockList | undefined; + declare maxConnections: number | undefined; + declare dropMaxConnection: boolean | undefined; + + constructor( + provider: WasiSocketsProvider, + Socket: SocketConstructor, + options: ServerOptions | ConnectionListener | null = {}, + connectionListener?: ConnectionListener, + ) { + super(); + if (typeof options === "function") { + connectionListener = options; + options = {}; + } + if (options === null) { + options = {}; + } else if (typeof options !== "object") { + throw invalidArgType("options", "Object", options); + } + if (options.blockList !== undefined && !BlockList.isBlockList(options.blockList)) { + throw invalidArgType("options.blockList", "net.BlockList", options.blockList); + } + if ( + options.keepAliveInitialDelay !== undefined && + typeof options.keepAliveInitialDelay !== "number" + ) { + throw invalidArgType( + "options.keepAliveInitialDelay", + "number", + options.keepAliveInitialDelay, + ); + } + if (options.highWaterMark !== undefined && typeof options.highWaterMark !== "number") { + throw invalidArgType("options.highWaterMark", "number", options.highWaterMark); + } + this.#provider = provider; + this.#Socket = Socket; + this.allowHalfOpen = Boolean(options.allowHalfOpen); + this.pauseOnConnect = Boolean(options.pauseOnConnect); + this.noDelay = Boolean(options.noDelay); + this.keepAlive = Boolean(options.keepAlive); + this.keepAliveInitialDelay = ~~(Math.max(0, options.keepAliveInitialDelay ?? 0) / 1_000); + this.highWaterMark = + options.highWaterMark === undefined || options.highWaterMark < 0 + ? DEFAULT_HIGH_WATER_MARK + : options.highWaterMark; + if (options.blockList) { + this.blockList = options.blockList; + } + if (connectionListener) { + this.on("connection", connectionListener as Listener); + } + } + + get listening(): boolean { + return this.#listening; + } + + on( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + on(event: string, listener: Listener): this; + on(event: string, listener: Listener): this { + return super.on(event, listener); + } + + addListener( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + addListener(event: string, listener: Listener): this; + addListener(event: string, listener: Listener): this { + return this.on(event, listener); + } + + once( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + once(event: string, listener: Listener): this; + once(event: string, listener: Listener): this { + return super.once(event, listener); + } + + listen(options?: ListenOptions | BoundSocketBase, listeningListener?: NetCallback): this; + listen(port?: number, hostname?: string | NetCallback, listeningListener?: NetCallback): this; + listen(path: string, listeningListener?: NetCallback): this; + listen(...args: unknown[]): this { + if (this.#listening || this.#bound) { + throw serverAlreadyListening(); + } + const callback = callbackFrom(args); + if (callback) { + this.once("listening", callback as Listener); + } + const rawOptions = listenOptions(args); + let boundSocket: BoundSocketBase | undefined; + let options: ListenOptions; + if (rawOptions instanceof BoundSocketBase) { + boundSocket = rawOptions; + options = {}; + } else { + options = rawOptions; + if (options.handle instanceof BoundSocketBase) { + boundSocket = options.handle; + } + } + if (options.path !== undefined) { + return unsupported( + "net.Server.listen path", + "wasi:sockets Preview 2 exposes IP sockets but not Unix-domain sockets or named pipes", + ); + } + if (options.handle !== undefined && !boundSocket) { + return unsupported( + "net.Server.listen handle", + "components can adopt only a net.BoundSocket, not a libuv or file-descriptor handle", + ); + } + if (options.exclusive !== undefined && typeof options.exclusive !== "boolean") { + throw invalidArgType("options.exclusive", "boolean", options.exclusive); + } + for (const name of ["ipv6Only", "reusePort"] as const) { + if (options[name] !== undefined && typeof options[name] !== "boolean") { + throw invalidArgType(`options.${name}`, "boolean", options[name]); + } + if (options[name]) { + return unsupported( + `net.Server.listen ${name}`, + "wasi:sockets Preview 2 does not expose this listen flag", + ); + } + } + const backlog = validateBacklog(options.backlog ?? backlogFrom(args)); + const port = validatePort(options.port ?? 0, "options.port"); + try { + this.#bound = + boundSocket?.[consumeBoundSocket]() ?? + bind(this.#provider, options.host ?? "::", port, backlog); + if (backlog !== undefined) { + this.#bound.socket.setListenBacklogSize?.(this.#provider.u64?.(backlog) ?? BigInt(backlog)); + } + listen(this.#bound.socket); + this.#address = this.#bound.address; + this.#listening = true; + this.#closing = false; + const signal = options.signal; + if (signal) { + const abort = () => this.close(); + if (signal.aborted) { + queueMicrotask(abort); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + } + queueMicrotask(() => { + if (!this.#listening) { + return; + } + this.emit("listening"); + this.#scheduleAccept(); + }); + } catch (error) { + this.#disposeListener(); + const failure = error instanceof Error ? error : socketError(error, "listen"); + queueMicrotask(() => this.emit("error", failure)); + } + return this; + } + + close(callback?: CloseCallback): this { + if (callback) { + if (this.#listening || this.#closing) { + this.once("close", callback as Listener); + } else { + queueMicrotask(() => callback(serverNotRunning())); + } + } + if (!this.#listening && !this.#closing) { + return this; + } + this.#listening = false; + this.#closing = true; + this.#disposeListener(); + this.#finishClose(); + return this; + } + + closeAllConnections(): void { + for (const socket of this.#connections) { + socket.destroy(); + } + } + + closeIdleConnections(): void { + // Raw TCP has no protocol-level notion of idle. Preserve established sockets. + } + + address(): AddressInfo | null { + return this.#address ? { ...this.#address } : null; + } + + getConnections(callback: ConnectionsCallback): void { + if (typeof callback !== "function") { + throw invalidArgType("callback", "Function", callback); + } + queueMicrotask(() => callback(null, this.#connections.size)); + } + + ref(): this { + return this; + } + + unref(): this { + return this; + } + + async [Symbol.asyncDispose](): Promise { + if (!this.#listening && !this.#closing) { + return; + } + await new Promise((resolve, reject) => { + this.close((error) => (error ? reject(error) : resolve())); + }); + } + + #scheduleAccept(): void { + if (!this.#listening) { + return; + } + schedule(this.#provider, () => this.#acceptOne()); + } + + #acceptOne(): void { + const listener = this.#bound?.socket; + if (!listener || !this.#listening) { + return; + } + try { + const [socketHandle, input, output] = accept(listener); + const local = socketHandle.localAddress + ? nodeAddress(socketHandle.localAddress()) + : this.#address; + const remote = socketHandle.remoteAddress + ? nodeAddress(socketHandle.remoteAddress()) + : undefined; + const drop = this.#dropReason(remote); + if (drop) { + closeTransport(socketHandle, input, output); + this.emit("drop", drop); + } else { + const socket = new this.#Socket({ + allowHalfOpen: this.allowHalfOpen, + noDelay: this.noDelay, + keepAlive: this.keepAlive, + keepAliveInitialDelay: this.keepAliveInitialDelay * 1_000, + }); + socket[attachAcceptedTransport]({ + socket: socketHandle, + input, + output, + localAddress: local, + remoteAddress: remote, + }); + socket.server = this; + socket._server = this; + this.#connections.add(socket); + socket.once("close", (() => { + this.#connections.delete(socket); + this.#finishClose(); + }) as Listener); + if (this.pauseOnConnect) { + socket.pause(); + } + this.emit("connection", socket); + if (!this.pauseOnConnect) { + socket[startSocketReading](); + } + } + } catch (error) { + if (this.#listening) { + const failure = error instanceof Error ? error : socketError(error, "accept"); + this.emit("error", failure); + } + } finally { + if (this.#listening) { + this.#scheduleAccept(); + } + } + } + + #dropReason(remote: NodeTcpAddress | undefined): DropArgument | undefined { + if ( + remote && + this.blockList?.check(remote.address, remote.family === "IPv4" ? "ipv4" : "ipv6") + ) { + return { + localAddress: this.#address?.address, + localPort: this.#address?.port, + localFamily: this.#address?.family, + remoteAddress: remote.address, + remotePort: remote.port, + remoteFamily: remote.family, + }; + } + if (this.maxConnections !== undefined && this.#connections.size >= this.maxConnections) { + return { + localAddress: this.#address?.address, + localPort: this.#address?.port, + localFamily: this.#address?.family, + remoteAddress: remote?.address, + remotePort: remote?.port, + remoteFamily: remote?.family, + }; + } + return undefined; + } + + #disposeListener(): void { + if (!this.#bound) { + return; + } + dispose(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + this.#address = undefined; + } + + #finishClose(): void { + if (!this.#closing || this.#connections.size !== 0) { + return; + } + this.#closing = false; + queueMicrotask(() => this.emit("close")); + } +} + +export interface ServerConstructor { + ( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ): ServerBase; + new ( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ): ServerBase; + readonly prototype: ServerBase; +} + +export function createServerConstructor( + provider: WasiSocketsProvider, + Socket: SocketConstructor, +): ServerConstructor { + class Server extends ServerBase { + constructor( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ) { + super(provider, Socket, options, connectionListener); + } + } + return new Proxy(Server, { + apply(_target, _thisArgument, argumentsList) { + return new Server( + argumentsList[0] as ServerOptions | ConnectionListener | null | undefined, + argumentsList[1] as ConnectionListener | undefined, + ); + }, + }) as ServerConstructor; +} diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index f571596e4..47aa89f4e 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -11,6 +11,9 @@ "declarationMap": true, "skipLibCheck": true, "paths": { + "wasi:sockets/instance-network@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], + "wasi:sockets/ip-name-lookup@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], + "wasi:sockets/tcp-create-socket@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], "jco:node/child-process@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/child-process-interface.d.ts"], "jco:node/cluster@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/cluster-interface.d.ts"], "jco:node/console@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/console-interface.d.ts"], From c24bc99c16390c14b6b3e8f1b865a27bc6f4d088 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:41:47 +0000 Subject: [PATCH 62/68] test(std): cover node:net conformance and socket lifecycles --- .../wasi/0.2.x/node/24.x.x/net/address.ts | 85 ++++++ .../0.2.x/node/24.x.x/net/helpers/provider.ts | 86 ++++++ .../test/wasi/0.2.x/node/24.x.x/net/module.ts | 76 ++++++ .../test/wasi/0.2.x/node/24.x.x/net/server.ts | 151 +++++++++++ .../test/wasi/0.2.x/node/24.x.x/net/socket.ts | 255 ++++++++++++++++++ .../wasi/0.2.x/node/24.x.x/net/transport.ts | 28 ++ 6 files changed, 681 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts new file mode 100644 index 000000000..d45f34216 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts @@ -0,0 +1,85 @@ +import nodeNet from "node:net"; + +import { describe, expect, test } from "vitest"; + +import { BlockList } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/block-list.js"; +import { isIP, isIPv4, isIPv6 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/ip.js"; +import { SocketAddress } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/socket-address.js"; + +describe("node:net address utilities", () => { + test("matches Node IP predicates", () => { + for (const input of [ + "127.0.0.1", + "255.255.255.255", + "256.0.0.1", + "::", + "2001:0db8::1", + "::ffff:192.0.2.1", + "fe80::1%eth0", + "not-an-address", + 4, + null, + ]) { + expect(isIP(input)).toBe(nodeNet.isIP(input as string)); + expect(isIPv4(input)).toBe(nodeNet.isIPv4(input as string)); + expect(isIPv6(input)).toBe(nodeNet.isIPv6(input as string)); + } + }); + + test("matches SocketAddress construction and parsing", () => { + for (const options of [ + {}, + { family: "IPv6" }, + { address: "2001:0db8::1", family: "ipv6", port: 81 }, + { address: "2001:db8:0:1:2:3:4:5", family: "ipv6", port: 81 }, + { address: "0:0:0:0:0:ffff:192.0.2.1", family: "ipv6" }, + { address: "::ffff:192.0.2.1", family: "ipv6", flowlabel: 7 }, + ] as const) { + expect(new SocketAddress(options).toJSON()).toEqual( + new nodeNet.SocketAddress(options).toJSON(), + ); + } + for (const input of ["127.0.0.1:81", "127.0.0.1:80", "[2001:db8::1]:443", "localhost:80"]) { + expect(SocketAddress.parse(input)?.toJSON()).toEqual( + nodeNet.SocketAddress.parse(input)?.toJSON(), + ); + } + expect(Object.keys(new SocketAddress())).toEqual(Object.keys(new nodeNet.SocketAddress())); + expect(new SocketAddress({ flowlabel: 1 }).flowlabel).toBe(0); + expect(() => new SocketAddress({ address: "invalid" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ADDRESS" }), + ); + }); + + test("matches BlockList ordering, ranges, subnets, mapping, and JSON", () => { + const actual = new BlockList(); + const expected = new nodeNet.BlockList(); + for (const blockList of [actual, expected]) { + blockList.addAddress("192.0.2.1"); + blockList.addRange("192.0.2.5", "192.0.2.10"); + blockList.addSubnet("2001:db8::", 32, "ipv6"); + } + expect(actual.rules).toEqual(expected.rules); + expect(actual.toJSON()).toEqual(expected.toJSON()); + for (const [address, family] of [ + ["192.0.2.1", "ipv4"], + ["192.0.2.7", "ipv4"], + ["192.0.2.11", "ipv4"], + ["2001:db8::abcd", "ipv6"], + ["::ffff:192.0.2.1", "ipv6"], + ] as const) { + expect(actual.check(address, family)).toBe(expected.check(address, family)); + } + expect(() => actual.check(123 as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + const mapped = new BlockList(); + mapped.addSubnet("::ffff:192.0.2.0", 120, "ipv6"); + expect(mapped.check("192.0.2.1")).toBe(true); + const restored = new BlockList(); + const expectedRestored = new nodeNet.BlockList(); + restored.fromJSON(JSON.stringify(actual)); + expectedRestored.fromJSON(JSON.stringify(expected)); + expect(restored.rules).toEqual(expectedRestored.rules); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts new file mode 100644 index 000000000..5c4f717d5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts @@ -0,0 +1,86 @@ +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +/** Deterministic transport that obeys the WASI read limit and tracks resource ownership. */ +export function createProvider(chunks: Uint8Array[] = []): { + provider: WasiSocketsProvider; + tasks: Array<() => void | Promise>; + disposed: string[]; + writes: Uint8Array[]; + readLengths: bigint[]; + failRead(error: unknown): void; +} { + const tasks: Array<() => void | Promise> = []; + const disposed: string[] = []; + const writes: Uint8Array[] = []; + const readLengths: bigint[] = []; + const queue = chunks.map((chunk) => chunk.slice()); + let readError: unknown; + const socket: WasiTcpSocket = { + startConnect: () => undefined, + finishConnect: () => [ + { + blockingRead(length) { + readLengths.push(length); + if (readError !== undefined) { + throw readError; + } + const chunk = queue.shift(); + if (!chunk) { + throw { tag: "closed" }; + } + const count = Math.min(chunk.length, Number(length)); + if (count < chunk.length) { + queue.unshift(chunk.subarray(count)); + } + return chunk.subarray(0, count); + }, + [Symbol.dispose]: () => { + disposed.push("input"); + }, + }, + { + blockingWriteAndFlush: (bytes) => { + writes.push(Uint8Array.from(bytes)); + }, + [Symbol.dispose]: () => { + disposed.push("output"); + }, + }, + ], + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + [Symbol.dispose]: () => { + disposed.push("socket"); + }, + }; + return { + provider: { + instanceNetwork: { + instanceNetwork: () => ({ + [Symbol.dispose]: () => { + disposed.push("network"); + }, + }), + }, + ipNameLookup: { + resolveAddresses: () => { + throw new Error("Unexpected DNS lookup"); + }, + }, + tcpCreateSocket: { createTcpSocket: () => socket }, + schedule: (task) => { + tasks.push(task); + }, + }, + tasks, + disposed, + writes, + readLengths, + failRead: (error) => { + readError = error; + }, + }; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts new file mode 100644 index 000000000..06feb086a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts @@ -0,0 +1,76 @@ +import nodeNet from "node:net"; + +import { describe, expect, test } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +const provider = { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { resolveAddresses: () => void 0 as never }, + tcpCreateSocket: { createTcpSocket: () => void 0 as never }, +} satisfies WasiSocketsProvider; + +describe("node:net module", () => { + test("matches the Node 24.19 export surface and aliases", () => { + const net = createNet(provider); + expect(Object.keys(net).sort()).toEqual(Object.keys(nodeNet).sort()); + expect(net.connect).toBe(net.createConnection); + expect(net.Socket).toBe(net.Stream); + }); + + test("provides callable Socket and Server constructors", () => { + const net = createNet(provider); + expect(net.Socket()).toBeInstanceOf(net.Socket); + expect(net.Server()).toBeInstanceOf(net.Server); + expect(net.Socket({ allowHalfOpen: true }).allowHalfOpen).toBe(true); + expect(net.Server({ keepAliveInitialDelay: 2_000, highWaterMark: -1 })).toMatchObject({ + keepAliveInitialDelay: 2, + highWaterMark: 65_536, + }); + }); + + test("normalizes the stable connect and listen overloads", () => { + const callback = () => undefined; + for (const args of [ + [80, "example.com", callback], + ["/tmp/example.sock", callback], + [{ port: 443, host: "example.com" }, callback], + ] as const) { + const [actualOptions, actualCallback] = createNet(provider)._normalizeArgs(args); + const [expectedOptions, expectedCallback] = nodeNet._normalizeArgs(args); + expect(actualOptions).toEqual(expectedOptions); + expect(actualCallback).toBe(expectedCallback); + } + }); + + test("matches family-default state and validation", () => { + const net = createNet(provider); + const originalFamily = nodeNet.getDefaultAutoSelectFamily(); + const originalTimeout = nodeNet.getDefaultAutoSelectFamilyAttemptTimeout(); + try { + net.setDefaultAutoSelectFamily(false); + expect(net.getDefaultAutoSelectFamily()).toBe(false); + net.setDefaultAutoSelectFamilyAttemptTimeout(1); + expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(10); + expect(() => net.setDefaultAutoSelectFamily("yes" as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => net.setDefaultAutoSelectFamilyAttemptTimeout(0)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + } finally { + net.setDefaultAutoSelectFamily(originalFamily); + net.setDefaultAutoSelectFamilyAttemptTimeout(originalTimeout); + } + }); + + test("fails raw libuv handle creation explicitly", () => { + expect(() => createNet(provider)._createServerHandle()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => createNet(provider).Socket({ fd: 1 })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts new file mode 100644 index 000000000..0665aeebb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; +import type { SocketBase } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/socket.js"; + +function serverProvider(): { + provider: WasiSocketsProvider; + scheduled: Array<() => void | Promise>; + shutdowns: string[]; +} { + const scheduled: Array<() => void | Promise> = []; + const shutdowns: string[] = []; + let accepted = false; + const connection: WasiTcpSocket = { + startConnect: () => undefined, + finishConnect: () => void 0 as never, + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 8080 } }), + remoteAddress: () => ({ tag: "ipv4", val: { address: [192, 0, 2, 10], port: 54321 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: (direction) => shutdowns.push(direction), + }; + const listener: WasiTcpSocket = { + startBind(_network, address) { + expect(address).toEqual({ + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: 8080 }, + }); + }, + finishBind: () => undefined, + startConnect: () => undefined, + finishConnect: () => void 0 as never, + startListen: () => undefined, + finishListen: () => undefined, + accept() { + if (accepted) { + throw { tag: "would-block" }; + } + accepted = true; + return [ + connection, + { blockingRead: () => void 0 as never }, + { blockingWriteAndFlush: () => undefined }, + ]; + }, + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 8080 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + }; + return { + provider: { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { resolveAddresses: () => void 0 as never }, + tcpCreateSocket: { createTcpSocket: () => listener }, + schedule: (task) => scheduled.push(task), + }, + scheduled, + shutdowns, + }; +} + +describe("node:net Server", () => { + test("rejects invalid listen ports synchronously", () => { + const net = createNet(serverProvider().provider); + for (const port of [-1, 65_536, 1.5, NaN]) { + expect(() => net.createServer().listen({ port })).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_BAD_PORT" }), + ); + } + }); + test("listens, accepts a Socket, tracks it, and waits to close", async () => { + const { provider, scheduled, shutdowns } = serverProvider(); + const net = createNet(provider); + let accepted: SocketBase | undefined; + const server = net.createServer((socket) => { + accepted = socket; + }); + const listening = new Promise((resolve) => server.once("listening", resolve as never)); + server.listen(8080, "127.0.0.1"); + await listening; + expect(server.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 8080 }); + await scheduled.shift()?.(); + expect(accepted).toBeInstanceOf(net.Socket); + expect(accepted).toMatchObject({ + remoteAddress: "192.0.2.10", + remotePort: 54321, + server, + }); + await expect( + new Promise((resolve, reject) => + server.getConnections((error, count) => (error ? reject(error) : resolve(count))), + ), + ).resolves.toBe(1); + + const closed = new Promise((resolve) => + server.close((error) => (error ? void 0 : resolve())), + ); + let settled = false; + void closed.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + accepted!.destroy(); + await closed; + expect(shutdowns).toContain("both"); + expect(server.address()).toBeNull(); + }); + + test("drops blocked peers before emitting connection", async () => { + const { provider, scheduled } = serverProvider(); + const net = createNet(provider); + const blockList = new net.BlockList(); + blockList.addSubnet("192.0.2.0", 24); + const server = net.createServer({ blockList }); + const dropped = new Promise((resolve) => server.once("drop", resolve as never)); + server.listen({ port: 8080, host: "127.0.0.1" }); + await Promise.resolve(); + await scheduled.shift()?.(); + await expect(dropped).resolves.toMatchObject({ + remoteAddress: "192.0.2.10", + remotePort: 54321, + }); + server.close(); + }); + + test("transfers a BoundSocket exactly once", () => { + const { provider } = serverProvider(); + const net = createNet(provider); + const bound = new net.BoundSocket({ port: 8080, host: "127.0.0.1" }); + const server = net.createServer(); + server.listen(bound); + expect(() => bound.close()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_HANDLE_ADOPTED" }), + ); + server.close(); + }); + + test("rejects IPC and native listen flags explicitly", () => { + const net = createNet(serverProvider().provider); + expect(() => net.createServer().listen("/tmp/example.sock")).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => net.createServer().listen({ port: 80, reusePort: true })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts new file mode 100644 index 000000000..a2c995974 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts @@ -0,0 +1,255 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import { createProvider } from "./helpers/provider.js"; +import type { + WasiInputStream, + WasiOutputStream, + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function clientProvider(): { provider: WasiSocketsProvider; writes: Uint8Array[] } { + const writes: Uint8Array[] = []; + let read = false; + const input: WasiInputStream = { + blockingRead() { + if (read) { + throw { tag: "closed" }; + } + read = true; + return encoder.encode("reply"); + }, + }; + 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, 8], port: 8080 }, + }); + }, + finishConnect: () => [input, output], + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 49152 } }), + remoteAddress: () => ({ tag: "ipv4", val: { address: [192, 0, 2, 8], port: 8080 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + }; + 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, 8] }; + }, + subscribe: () => ({ block: () => undefined }), + }; + }, + }, + tcpCreateSocket: { createTcpSocket: () => socket }, + }; + return { provider, writes }; +} + +describe("node:net Socket", () => { + test("does not acquire a transport for an already aborted signal", async () => { + const { provider, disposed } = createProvider(); + const acquire = vi.spyOn(provider.instanceNetwork, "instanceNetwork"); + const socket = createNet(provider).connect({ port: 80, signal: AbortSignal.abort() }); + const error = new Promise((resolve) => socket.once("error", resolve)); + await expect(error).resolves.toMatchObject({ code: "ABORT_ERR" }); + expect(acquire).not.toHaveBeenCalled(); + expect(disposed).toEqual([]); + }); + + test("removes the abort listener when a socket is destroyed", async () => { + const { provider } = createProvider(); + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + signal: controller.signal, + }); + await Promise.resolve(); + socket.destroy(); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + test("keeps the receive side open after end() and honors pause/resume", async () => { + const { provider, tasks, disposed } = createProvider([encoder.encode("reply")]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("utf8"); + const received: string[] = []; + socket.on("data", (chunk: string) => received.push(chunk)); + await Promise.resolve(); + socket.pause().end("request"); + await tasks.shift()?.(); + expect(received).toEqual([]); + expect(socket.readyState).toBe("readOnly"); + expect(disposed).toEqual(["network", "output"]); + socket.resume(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(received).toEqual(["reply"]); + expect(socket.destroyed).toBe(true); + }); + + test("preserves unread data for read() and delayed async iteration", async () => { + const { provider, tasks } = createProvider([encoder.encode("one"), encoder.encode("two")]); + const socket = createNet(provider).connect(80, "127.0.0.1"); + socket.setEncoding("utf8"); + await Promise.resolve(); + await tasks.shift()?.(); + expect(socket.read()).toBe("one"); + await tasks.shift()?.(); + await tasks.shift()?.(); + const received: string[] = []; + for await (const chunk of socket) { + received.push(String(chunk)); + } + expect(received).toEqual(["two"]); + }); + + test("decodes split UTF-8 and flushes incomplete characters at EOF", async () => { + const { provider, tasks } = createProvider([ + new Uint8Array([0xe2]), + new Uint8Array([0x82, 0xac, 0xe2]), + ]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("utf8"); + const data: string[] = []; + socket.on("data", (chunk: string) => data.push(chunk)); + await Promise.resolve(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(data).toEqual(["€", "�"]); + }); + + test("supports Node encodings for reads and writes", async () => { + const { provider, tasks, writes } = createProvider([new Uint8Array([0x80, 0xff])]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("latin1"); + socket.write("cafe", "hex"); + await Promise.resolve(); + await tasks.shift()?.(); + expect(socket.read()).toBe("\u0080\u00ff"); + expect(writes).toEqual([new Uint8Array([0xca, 0xfe])]); + socket.destroy(); + }); + + test("limits onread reads to the supplied buffer without losing bytes", async () => { + const { provider, tasks, readLengths } = createProvider([encoder.encode("abcdef")]); + const data: string[] = []; + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + onread: { + buffer: new Uint8Array(2), + callback(length, buffer) { + data.push(decoder.decode(buffer.subarray(0, length))); + return true; + }, + }, + }); + await Promise.resolve(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(data.join("")).toBe("abcdef"); + expect(readLengths).toEqual([2n, 2n, 2n, 2n]); + expect(socket.bytesRead).toBe(6); + }); + + test("rejects async iteration on a transport error and disposes resources once", async () => { + const { provider, tasks, disposed, failRead } = createProvider(); + const socket = createNet(provider).connect(80, "127.0.0.1"); + await Promise.resolve(); + const iterator = socket[Symbol.asyncIterator](); + const next = expect(iterator.next()).rejects.toMatchObject({ code: "ECONNRESET" }); + failRead({ tag: "connection-reset" }); + await tasks.shift()?.(); + await next; + socket.destroy(); + expect(disposed.sort()).toEqual(["input", "network", "output", "socket"]); + }); + + test("destroySoon closes a half-open socket after its writes finish", async () => { + const { provider, disposed, writes } = createProvider(); + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + allowHalfOpen: true, + }); + await Promise.resolve(); + socket.write("done"); + socket.destroySoon(); + await Promise.resolve(); + expect(socket.destroyed).toBe(true); + expect(writes).toEqual([encoder.encode("done")]); + expect(disposed.sort()).toEqual(["input", "network", "output", "socket"]); + }); + + test("does not emit lookup for a numeric address", async () => { + const { provider } = createProvider(); + const socket = createNet(provider).connect(80, "127.0.0.1"); + const lookups: unknown[] = []; + socket.on("lookup", (...args: unknown[]) => { + lookups.push(args); + }); + await Promise.resolve(); + expect(lookups).toEqual([]); + socket.destroy(); + }); + + test("connects, exposes addresses, writes, reads, and closes", async () => { + const { provider, writes } = clientProvider(); + const net = createNet(provider); + const socket = net.createConnection(8080, "example.com"); + socket.setEncoding("utf8"); + const events: string[] = []; + socket.on("lookup", (() => events.push("lookup")) as never); + socket.on("connect", (() => { + events.push("connect"); + socket.write("request"); + }) as never); + const data = new Promise((resolve) => socket.once("data", resolve as never)); + const close = new Promise((resolve) => socket.once("close", resolve as never)); + expect(await data).toBe("reply"); + await close; + expect(events).toEqual(["lookup", "connect"]); + expect(decoder.decode(writes[0])).toBe("request"); + expect(socket.bytesWritten).toBe(7); + expect(socket.bytesRead).toBe(5); + expect(socket.remoteAddress).toBe("192.0.2.8"); + expect(socket.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 49152 }); + }); + + test("rejects IPC, custom lookup, and native-only socket options explicitly", () => { + const net = createNet(clientProvider().provider); + expect(() => net.connect("/tmp/example.sock")).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => net.connect({ port: 80, lookup: () => undefined })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => new net.Socket().setTypeOfService(1)).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => new net.Socket().resetAndDestroy()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts new file mode 100644 index 000000000..d738d7e50 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; + +import { + connect, + errorCode, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; +import { createProvider } from "./helpers/provider.js"; + +describe("shared WASI TCP transport", () => { + test("recognizes ComponentizeJS result errors", () => { + expect(errorCode(Object.assign(new Error("closed"), { payload: { tag: "closed" } }))).toBe( + "closed", + ); + expect(errorCode({ payload: "would-block" })).toBe("would-block"); + expect(errorCode(new Error("unrelated"))).toBeUndefined(); + }); + test("terminates when a literal address is filtered by family", () => { + const { provider, disposed } = createProvider(); + expect(() => connect(provider, "127.0.0.1", 80, { family: 6 })).toThrow(); + expect(disposed).toEqual(["network"]); + }); + + test("terminates when a literal address is denied", () => { + const { provider, disposed } = createProvider(); + expect(() => connect(provider, "127.0.0.1", 80, { allowAddress: () => false })).toThrow(); + expect(disposed).toEqual(["network"]); + }); +}); From 0ea125b967bd90da4b568f34bb5dd810e1fa2a85 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:42:00 +0000 Subject: [PATCH 63/68] feat(jco): resolve node:net through the builtin plugin --- packages/jco/src/node-builtins.ts | 94 ++++++++++++++++++++++++------- packages/jco/src/node-wit.ts | 38 ++++++++----- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 134b86dfc..6d2eaec3a 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -22,6 +22,8 @@ import { HTTP2_WIT_REQUIREMENT, INSPECTOR_PROMISES_WIT_REQUIREMENT, INSPECTOR_WIT_REQUIREMENT, + NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + NET_WASI_SOCKETS_WIT_REQUIREMENTS, OS_WIT_REQUIREMENT, type NodeWitRequirement, } from "./node-wit.js"; @@ -61,6 +63,7 @@ const STREAM_ITER_SPECIFIER = "node:stream/iter"; const DNS_SPECIFIERS = new Set(["node:dns", "node:dns/promises"]); const HTTP_SPECIFIER = "node:http"; const HTTPS_SPECIFIER = "node:https"; +const NET_SPECIFIER = "node:net"; export const HTTP_CALLBACKS_SPECIFIER = "jco:node-http-callbacks"; const HTTP2_SPECIFIER = "node:http2"; export const HTTP2_CALLBACKS_SPECIFIER = "jco:node-http2-callbacks"; @@ -311,6 +314,8 @@ export interface NodeBuiltinOptions { /** Paths to jco-std's HTTPS modules (overridable for tests). */ httpsModule?: string; httpsCoreModule?: string; + /** Path to jco-std's portable `node:net` core module (overridable for tests). */ + netCoreModule?: string; /** Implementation used for `node:http2` host operations. */ nodejsHttp2Via?: NodejsHttp2Via; /** WASI socket module version supplied by the selected component engine. */ @@ -739,6 +744,24 @@ function httpCallbacksAdapter(httpModule: string): string { return `export { httpCallbacks } from ${JSON.stringify(httpModule)};`; } +interface WasiSocketsProviderSource { + imports: string; + value: string; +} + +/** Shared Preview 2 provider source used by net, HTTP/1, HTTP/2, and HTTPS adapters. */ +function wasiSocketsProviderSource(version: string): WasiSocketsProviderSource { + const u64 = version === "0.2.10" ? "BigInt(value)" : "value"; + const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; + return { + imports: ` +import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; +import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; +import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}";`, + value: `{ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${u64}${schedule} }`, + }; +} + function protocolWasiSocketsAdapter( protocol: HttpProtocol, coreModule: string, @@ -746,16 +769,15 @@ function protocolWasiSocketsAdapter( version: string, ): string { const factory = PROTOCOL_FACTORY[protocol]; - const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; + const provider = wasiSocketsProviderSource(version); const tlsImports = protocol === "https" ? 'import * as tls from "wasi:tls/types@0.2.0-draft";' : ""; + const providerValue = protocol === "https" ? `{ ...${provider.value}, tls }` : provider.value; return ` -import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; -import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; -import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; +${provider.imports} ${tlsImports} import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiSocketsHttpImplementation } from ${JSON.stringify(implementationModule)}; -${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule}${protocol === "https" ? ", tls" : ""} }))`)} +${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation(${providerValue}))`)} `; } @@ -831,23 +853,44 @@ function http2PortableAdapter( ): string { const factory = via === "wasi-sockets" ? "createWasiSocketsHttp2Implementation" : "createWasiHttpHttp2Implementation"; - const provider = - via === "wasi-sockets" - ? ` -import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; -import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; -import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; -` - : ""; - const factoryArguments = - via === "wasi-sockets" - ? `{ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""} }` - : ""; + const provider = via === "wasi-sockets" ? wasiSocketsProviderSource(version) : undefined; return ` -${provider} +${provider?.imports ?? ""} import { createHttp2 } from ${JSON.stringify(coreModule)}; import { ${factory} } from ${JSON.stringify(implementationModule)}; -${http2Exports(`createHttp2(${factory}(${factoryArguments}))`)} +${http2Exports(`createHttp2(${factory}(${provider?.value ?? ""}))`)} +`; +} + +const NET_EXPORTS = [ + "BlockList", + "BoundSocket", + "Server", + "Socket", + "SocketAddress", + "Stream", + "_createServerHandle", + "_normalizeArgs", + "connect", + "createConnection", + "createServer", + "getDefaultAutoSelectFamily", + "getDefaultAutoSelectFamilyAttemptTimeout", + "isIP", + "isIPv4", + "isIPv6", + "setDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", +] as const; + +function netAdapter(coreModule: string, version: string): string { + const provider = wasiSocketsProviderSource(version); + return ` +${provider.imports} +import { createNet } from ${JSON.stringify(coreModule)}; +const net = createNet(${provider.value}); +export default net; +export const { ${NET_EXPORTS.join(", ")} } = net; `; } @@ -1079,6 +1122,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui stdModule(options.httpWasiHttpImplementationModule, "http/impl/wasi-http"); const httpsModule = () => stdModule(options.httpsModule, "https"); const httpsCoreModule = () => stdModule(options.httpsCoreModule, "https/core"); + const netCoreModule = () => stdModule(options.netCoreModule, "net/core"); const httpVia = options.nodejsHttpVia ?? "direct"; const wasiSocketsVersion = options.wasiSocketsVersion ?? "0.2.12"; const protocolOf = (specifier: string): HttpProtocol | undefined => @@ -1211,6 +1255,15 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui options.onWitRequirement?.(FS_WIT_REQUIREMENT); return `${VIRTUAL_PREFIX}${id}`; } + if (id === NET_SPECIFIER) { + requireWasiHttpVersion(worldMetadata, id, "wasi-sockets", wasiSocketsVersion); + for (const requirement of wasiSocketsVersion === "0.2.12" + ? NET_WASI_SOCKETS_WIT_REQUIREMENTS + : NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS) { + options.onWitRequirement?.(requirement); + } + return `${VIRTUAL_PREFIX}${id}`; + } const protocol = protocolOf(id); if (protocol !== undefined) { if (httpVia !== "direct") { @@ -1322,6 +1375,9 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (FS_SPECIFIERS.has(value)) { return fsAdapter(value, fsModule(), fsPromisesModule()); } + if (value === NET_SPECIFIER) { + return netAdapter(netCoreModule(), wasiSocketsVersion); + } const protocol = protocolOf(value); if (protocol !== undefined) { return protocolAdapter(protocol); diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 37d0bd8f8..90540546d 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -207,33 +207,43 @@ export const HTTP_WASI_HTTP_WIT_REQUIREMENTS = [ wasiRequirement("wasi:http/types@0.2.12", WASI_HTTP_DEPENDENCIES), ] as const; -function forHttps(requirements: readonly NodeWitRequirement[]): NodeWitRequirement[] { - return requirements.map((requirement) => ({ ...requirement, nodeSpecifier: "node:https" })); +function forNodeSpecifier(requirements: readonly NodeWitRequirement[], nodeSpecifier: string): NodeWitRequirement[] { + return requirements.map((requirement) => ({ ...requirement, nodeSpecifier })); } function tlsRequirements(): NodeWitRequirement[] { const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); - return forHttps([ - wasiRequirement("wasi:tls/types@0.2.0-draft", [ - { - dependencyDirectory: "wasi-tls-0.2.0-draft", - dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), - }, - WASI_IO_DEPENDENCY, - ]), - ]); + return forNodeSpecifier( + [ + wasiRequirement("wasi:tls/types@0.2.0-draft", [ + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), + }, + WASI_IO_DEPENDENCY, + ]), + ], + "node:https", + ); } export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ - ...forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS), + ...forNodeSpecifier(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, "node:https"), ...tlsRequirements(), ] as const; export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ - ...forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS), + ...forNodeSpecifier(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, "node:https"), ...tlsRequirements(), ]; -export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forHttps(HTTP_WASI_HTTP_WIT_REQUIREMENTS); +export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forNodeSpecifier(HTTP_WASI_HTTP_WIT_REQUIREMENTS, "node:https"); + +export const NET_WASI_SOCKETS_WIT_REQUIREMENTS = forNodeSpecifier(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, "node:net"); + +export const NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = forNodeSpecifier( + HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + "node:net", +); export interface WitInjectionResult { witPath: string; From e7e32f3a2a0fdf23b88121eb2ecbd59338484d48 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:42:11 +0000 Subject: [PATCH 64/68] test(jco): cover node:net adapters and WIT imports --- packages/jco/test/node/net.js | 113 ++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 packages/jco/test/node/net.js diff --git a/packages/jco/test/node/net.js b/packages/jco/test/node/net.js new file mode 100644 index 000000000..075dc503e --- /dev/null +++ b/packages/jco/test/node/net.js @@ -0,0 +1,113 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, test, vi } from "vitest"; + +import { bundleComponentSource } from "../../src/bundle.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins.js"; +import { + injectNodeWitImports, + NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + NET_WASI_SOCKETS_WIT_REQUIREMENTS, +} from "../../src/node-wit.js"; +import { getTmpDir } from "../helpers.js"; + +const NET_EXPORTS = [ + "BlockList", + "BoundSocket", + "Server", + "Socket", + "SocketAddress", + "Stream", + "_createServerHandle", + "_normalizeArgs", + "connect", + "createConnection", + "createServer", + "getDefaultAutoSelectFamily", + "getDefaultAutoSelectFamilyAttemptTimeout", + "isIP", + "isIPv4", + "isIPv6", + "setDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", +]; + +describe("node:net builtin adapter", () => { + test.each(["0.2.12", "0.2.10"])("generates the Preview 2 %s facade", (wasiSocketsVersion) => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { netCoreModule: "/jco/net/core.js", wasiSocketsVersion, onWitRequirement }, + ); + const id = plugin.resolveId("node:net"); + expect(id).toBe("\0jco-node-builtin:node:net"); + const source = plugin.load(id); + expect(source).toContain('from "/jco/net/core.js"'); + expect(source).toContain(`wasi:sockets/instance-network@${wasiSocketsVersion}`); + expect(source).toContain(`wasi:sockets/tcp-create-socket@${wasiSocketsVersion}`); + expect(source).toContain("createNet("); + expect(source).toContain("export default net"); + for (const name of NET_EXPORTS) { + expect(source).toMatch(new RegExp(`\\b${name}\\b`)); + } + expect(onWitRequirement).toHaveBeenCalledTimes(7); + expect(onWitRequirement).toHaveBeenCalledWith( + expect.objectContaining({ + nodeSpecifier: "node:net", + witImport: `wasi:sockets/instance-network@${wasiSocketsVersion}`, + }), + ); + if (wasiSocketsVersion === "0.2.10") { + expect(source).toContain("u64: value => BigInt(value)"); + expect(source).toContain("schedule: task => setTimeout(task, 0)"); + } + }); + + test("does not intercept bare net and tree-shakes an unused builtin", async () => { + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { netCoreModule: "/jco/net/core.js" }); + expect(plugin.resolveId("net")).toBeNull(); + const root = await getTmpDir(); + const entry = join(root, "entry.js"); + await writeFile(entry, "export const answer = 42;\n"); + const source = await bundleComponentSource(entry, { plugins: [plugin] }); + expect(source).not.toContain("wasi:sockets/instance-network"); + expect(source).not.toContain("/jco/net/core.js"); + }); + + test("rejects an incompatible Preview 2 sockets package", () => { + const plugin = nodeBuiltinPlugin( + { + imports: [ + { + namespace: "wasi", + package: "sockets", + interface: "tcp", + version: { major: 0n, minor: 2n, patch: 10n }, + }, + ], + exports: [], + }, + { netCoreModule: "/jco/net/core.js" }, + ); + expect(() => plugin.resolveId("node:net")).toThrow(/node:net via wasi-sockets requires wasi:sockets@0\.2\.12/); + }); +}); + +describe("node:net WIT installation", () => { + test.each([ + ["0.2.12", NET_WASI_SOCKETS_WIT_REQUIREMENTS], + ["0.2.10", NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS], + ])("installs only standard wasi:sockets %s requirements", async (version, requirements) => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:net;\nworld component {}\n"); + const result = await injectNodeWitImports(root, undefined, requirements); + expect(result?.imports).toContain(`wasi:sockets/instance-network@${version}`); + expect(result?.imports).toContain(`wasi:sockets/tcp@${version}`); + expect(result?.exports).toEqual([]); + const source = await readFile(world, "utf8"); + expect(source).toContain("bundled source imports node:net"); + expect(source).not.toContain("jco:node/net"); + }); +}); From 01c2b1dc8fc8f09ae26a3da322923bbb03c4b4a6 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:42:24 +0000 Subject: [PATCH 65/68] test(jco): exercise node:net in QuickJS and StarlingMonkey --- .../componentize/node-net/component.js | 50 +++++++++++++++ .../fixtures/componentize/node-net/peer.js | 8 +++ .../fixtures/componentize/node-net/run.js | 63 +++++++++++++++++++ .../node-net/wit-starling/component.wit | 8 +++ .../componentize/node-net/wit/component.wit | 8 +++ packages/jco/test/node/net.js | 50 ++++++++++++++- 6 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 packages/jco/test/fixtures/componentize/node-net/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-net/peer.js create mode 100644 packages/jco/test/fixtures/componentize/node-net/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit create mode 100644 packages/jco/test/fixtures/componentize/node-net/wit/component.wit diff --git a/packages/jco/test/fixtures/componentize/node-net/component.js b/packages/jco/test/fixtures/componentize/node-net/component.js new file mode 100644 index 000000000..089554da4 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/component.js @@ -0,0 +1,50 @@ +import net, { connect, createServer, Socket, Stream } from "node:net"; + +let server; +let handled; + +export function surface() { + const blockList = new net.BlockList(); + blockList.addSubnet("192.0.2.0", 24); + return JSON.stringify({ + exports: Object.keys(net).sort(), + aliases: connect === net.createConnection && Socket === Stream, + ipv6: net.isIP("::ffff:192.0.2.1"), + blocked: blockList.check("192.0.2.5"), + }); +} + +export async function runClient(port) { + const socket = connect(port, "127.0.0.1").setEncoding("utf8"); + const response = new Promise((resolve, reject) => { + let body = ""; + socket.on("data", (chunk) => { + body += chunk; + }); + socket.once("end", () => resolve(body)); + socket.once("error", reject); + }); + // preview2-shim 0.22.0 destroys both directions on shutdown("send"). Let the + // peer close after its reply; directional shutdown is covered by provider tests. + socket.write("client"); + return await response; +} + +export function startServer() { + handled = new Promise((resolve, reject) => { + server = createServer((socket) => { + server.close(); + socket.setEncoding("utf8"); + socket.once("error", reject); + socket.once("data", (chunk) => socket.end(`guest:${chunk}`, resolve)); + }); + server.once("error", reject); + }); + server.listen(0, "127.0.0.1"); + return server.address().port; +} + +export async function serveOne() { + await handled; + await server[Symbol.asyncDispose](); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/peer.js b/packages/jco/test/fixtures/componentize/node-net/peer.js new file mode 100644 index 000000000..289459da6 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/peer.js @@ -0,0 +1,8 @@ +import net from "node:net"; + +// WASI's synchronous pollables require the peer to run outside the guest process. +const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.once("data", (chunk) => socket.end(`host:${chunk}`)); +}); +server.listen(0, "127.0.0.1", () => process.stdout.write(`${server.address().port}\n`)); diff --git a/packages/jco/test/fixtures/componentize/node-net/run.js b/packages/jco/test/fixtures/componentize/node-net/run.js new file mode 100644 index 000000000..965f52158 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/run.js @@ -0,0 +1,63 @@ +import net from "node:net"; +import { spawn } from "node:child_process"; +import { argv, stdout } from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +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"]); +// The existing StarlingMonkey bindings require these compatibility members. +imports["wasi:sockets/network"].Network.prototype.noop ??= () => {}; +imports["wasi:sockets/network"].networkErrorCode ??= () => undefined; +const instance = await instantiate(undefined, imports); +if (argv[3] === "server") { + const port = await instance.startServer(); + stdout.write(`${port}\n`); + await instance.serveOne(); + process.exit(0); +} +const surface = JSON.parse(await instance.surface()); + +function childPort(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => reject(new Error(`Peer exited before listening: ${code}`))); + child.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim()))); + }); +} + +const host = spawn(process.execPath, [fileURLToPath(new URL("./peer.js", import.meta.url))], { + stdio: ["ignore", "pipe", "inherit"], +}); +let guest; +try { + const client = await instance.runClient(await childPort(host)); + guest = spawn(process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), argv[2], "server"], { + stdio: ["ignore", "pipe", "inherit"], + }); + const guestExit = new Promise((resolve, reject) => { + guest.once("error", reject); + guest.once("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Guest server exited: ${code}`)))); + }); + const port = await childPort(guest); + const response = new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1").setEncoding("utf8"); + let body = ""; + socket.on("data", (chunk) => { + body += chunk; + }); + socket.once("error", reject); + socket.once("end", () => resolve(body)); + socket.end("runner"); + }); + const server = await response; + await guestExit; + stdout.write(`${JSON.stringify({ surface, client, server })}\n`); +} finally { + guest?.kill(); + host.kill(); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit b/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit new file mode 100644 index 000000000..f4a42175a --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit @@ -0,0 +1,8 @@ +package test:net; + +world component { + export surface: func() -> string; + export run-client: func(port: u16) -> string; + export start-server: func() -> u16; + export serve-one: func(); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/wit/component.wit b/packages/jco/test/fixtures/componentize/node-net/wit/component.wit new file mode 100644 index 000000000..87497958f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/wit/component.wit @@ -0,0 +1,8 @@ +package test:net; + +world component { + export surface: func() -> string; + export run-client: async func(port: u16) -> string; + export start-server: func() -> u16; + export serve-one: async func(); +} diff --git a/packages/jco/test/node/net.js b/packages/jco/test/node/net.js index 075dc503e..5ceca4132 100644 --- a/packages/jco/test/node/net.js +++ b/packages/jco/test/node/net.js @@ -1,5 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { cp, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, test, vi } from "vitest"; @@ -10,7 +11,7 @@ import { NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, NET_WASI_SOCKETS_WIT_REQUIREMENTS, } from "../../src/node-wit.js"; -import { getTmpDir } from "../helpers.js"; +import { exec, getTmpDir, jcoPath, setupAsyncTest } from "../helpers.js"; const NET_EXPORTS = [ "BlockList", @@ -111,3 +112,48 @@ describe("node:net WIT installation", () => { expect(source).not.toContain("jco:node/net"); }); }); + +describe("node:net in a component", () => { + test.each(["quickjs", "starlingmonkey"])( + "runs TCP clients and servers using %s", + async (backend) => { + const root = await getTmpDir(); + const fixture = fileURLToPath(new URL("../fixtures/componentize/node-net/", import.meta.url)); + const wit = join(root, "wit"); + await cp(join(fixture, backend === "quickjs" ? "wit" : "wit-starling"), wit, { recursive: true }); + const requirements = []; + // Exercise the actual adapter against the workspace build, independent of npm publication. + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + netCoreModule: fileURLToPath( + new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/net/core.js", import.meta.url), + ), + wasiSocketsVersion: backend === "quickjs" ? "0.2.12" : "0.2.10", + onWitRequirement: (requirement) => requirements.push(requirement), + }, + ); + const source = await bundleComponentSource(join(fixture, "component.js"), { plugins: [plugin] }); + const entry = join(root, "component.js"); + await writeFile(entry, source); + await injectNodeWitImports(wit, undefined, requirements); + const componentPath = join(root, "component.wasm"); + await exec(jcoPath, "componentize", entry, "-w", wit, "-o", componentPath, "--backend", backend); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: `node-net-${backend}`, path: componentPath, skipInstantiation: true }, + jco: { transpile: { extraArgs: { asyncExports: ["*"] } } }, + }); + try { + const output = await exec(join(fixture, "run.js"), esModuleOutputPath); + expect(JSON.parse(output.stdout)).toEqual({ + surface: { exports: NET_EXPORTS, aliases: true, ipv6: 6, blocked: true }, + client: "host:client", + server: "guest:runner", + }); + } finally { + await cleanup(); + } + }, + 600_000, + ); +}); From 8ed64cdfea1cf05ca1688479dd9e93a522189c0e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 5 Sep 2026 14:42:46 +0000 Subject: [PATCH 66/68] docs(std): document node:net support and limitations --- docs/src/interop/nodejs-builtins.md | 45 ++++++++++++++++++++++++++++- packages/jco-std/README.md | 25 ++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index b820c15de..552cd9928 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -123,6 +123,7 @@ is planned. | `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | | `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation -- see below. Servers need `direct` or `wasi-sockets`. | | `node:https` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https` | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS uses the `direct` host or an explicit `wasi:tls` provider -- see below. | +| `node:net` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/net/core` | TCP clients, servers, and address utilities over Preview 2 `wasi:sockets`; native handles and IPC are unsupported -- see below. | | `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | | `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | | `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | @@ -776,6 +777,48 @@ boundary does not expose an outstanding c-ares request that a later guest call could cancel. The provider boundary otherwise remains Node-independent, leaving room for a future browser implementation. +### TCP sockets + +`node:net` implements Node 24.19's 18-export module surface over Preview 2 +`wasi:sockets`. It includes TCP `Socket` and `Server`, `BoundSocket`, +`SocketAddress`, `BlockList`, IP-family predicates, overload normalization, and +the auto-family defaults. `connect` and `createConnection` are the same function, +and `Socket` and `Stream` are the same constructor, as in Node. + +```js +import { connect, createServer } from 'node:net'; + +createServer((socket) => socket.end('hello')).listen(8080, '127.0.0.1'); + +connect(8080, '127.0.0.1').setEncoding('utf8').on('data', console.log); +``` + +Jco injects only the selected world's Preview 2 DNS, TCP, stream, and pollable +interfaces and their standard WIT packages. QuickJS worlds use 0.2.12; +StarlingMonkey worlds can use 0.2.10. There is no Jco-specific network host +interface and no bare `net` alias. + +Preview 2 has no Unix-domain sockets, Windows named pipes, OS file descriptors, +libuv handles, TCP reset, IP type-of-service, or custom JavaScript DNS callback. +Those operations throw `ERR_JCO_UNSUPPORTED_NODE_API`. Address attempts are +sequential rather than reproducing Node's exact Happy Eyeballs timing. Socket +objects provide the common readable/writable methods and events but do not yet +inherit from classic `node:stream.Duplex`, because Jco does not have a faithful +classic stream core. + +Reads support Node string encodings, buffered `read()`, and async iteration. +Writable operations complete through blocking WASI writes and do not yet provide +classic stream backpressure. `setNoDelay()`, `ref()`, and `unref()` preserve the +callable surface but cannot control the host's TCP_NODELAY or event-loop references. +Nonzero socket timeouts require an engine with JavaScript timers; engines without +them reject `setTimeout()` explicitly. The deprecated `bufferSize` getter throws +the Jco deprecated-API error; use `writableLength` instead. + +Half-close depends on the host honoring WASI's directional `shutdown`. The +Preview 2 Node host shim 0.22.0 currently closes both directions; applications +using that host should let the peer finish its response before closing the +socket's writable side. + ### HTTP and selectable implementations The `node:http` adapter implements both client and server NodeJS HTTP APIs, @@ -1069,7 +1112,7 @@ These modules contain useful portable pieces, but their complete public surfaces also require operating-system access, Node internals, an event loop, or a larger set of coordinated shims: -`node:crypto`, `node:dgram`, `node:http2`, `node:net`, +`node:crypto`, `node:dgram`, `node:http2`, `node:perf_hooks`, `node:process`, `node:repl`, `node:sqlite`, `node:stream`, `node:stream/promises`, `node:stream/web`, `node:timers`, `node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index a43f9fca7..8b7f47efd 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -37,6 +37,8 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/http` | `node:http` API with direct, WASI sockets, and WASI HTTP implementations | | `wasi/0.2.x/node/24.x.x/http2` | `node:http2` API with direct and cleartext WASI sockets implementations | | `wasi/0.2.x/node/24.x.x/https` | `node:https` API sharing the `node:http` core and implementations | +| `wasi/0.2.x/node/24.x.x/net` | `node:net` module over WASI Preview 2 0.2.12 sockets | +| `wasi/0.2.x/node/24.x.x/net/core` | `node:net` core over injected WASI Preview 2 sockets | | `wasi/0.2.x/node/24.x.x/os` | `node:os` guest adapter over an explicit host capability | | `wasi/0.2.x/node/24.x.x/path` | `node:path` adapter, Node 24 on WASI p2 | | `wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local `node:string_decoder` implementation for Node 24 | @@ -158,6 +160,7 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components calling back into the component through a guest-exported callbacks interface; - the `node:http` and `node:https` APIs, with selectable direct, `wasi:sockets`, and `wasi:http` implementations; +- the `node:net` TCP client/server and address APIs over `wasi:sockets`; - `node:buffer`, with its modern core provided by Jco's audited unenv compatibility layer; - `node:querystring`, provided by Jco's audited unenv compatibility layer; @@ -553,6 +556,28 @@ interface does not expose an in-flight c-ares request as a resource that a later guest call could cancel. The WIT boundary remains runtime-neutral so a browser DNS provider can be added later. +### Net + +`node:net` uses Preview 2 DNS, TCP, IO streams, and pollables directly. Jco adds +the matching `wasi:sockets` and `wasi:io` imports for the selected 0.2.12 or +0.2.10 world; it does not add a Jco-specific network capability. Ordinary TCP +client and server code remains unchanged: + +```js +import { connect, createServer } from "node:net"; + +createServer((socket) => socket.end("hello")).listen(8080, "127.0.0.1"); +connect(8080, "127.0.0.1").setEncoding("utf8").on("data", console.log); +``` + +The module also provides `BlockList`, `SocketAddress`, IP predicates, +`BoundSocket`, family-selection defaults, and Node 24.19's exact named-export +surface. Unix-domain sockets, named pipes, arbitrary file-descriptor/libuv +handles, custom DNS callbacks, and socket options missing from Preview 2 are +rejected explicitly. `Socket` provides the common Duplex-shaped API but cannot +inherit from a classic `node:stream.Duplex` until that stream implementation is +available. + ### HTTP Application code uses the ordinary Node API: From 0b18f0e09ee74a0468c6959d998c96881a4894d1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 9 Sep 2026 00:01:27 +0000 Subject: [PATCH 67/68] fix(std): retain narrowed socket methods during bind polling --- .../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts index 15800789b..751db2e66 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts @@ -377,7 +377,7 @@ export function connect( "net.Socket localAddress", ); socket.startBind(network, local); - finishPending(() => socket.finishBind!(), socket); + finishPending(socket.finishBind.bind(socket), socket); } socket.startConnect(network, remoteAddress(address, port)); for (;;) { @@ -456,7 +456,7 @@ export function bind( socket.setListenBacklogSize?.(wasiU64(provider, backlog)); } socket.startBind(network, local); - finishPending(() => socket.finishBind!(), socket); + finishPending(socket.finishBind.bind(socket), socket); return { socket, network, address: nodeAddress(socket.localAddress()) }; } catch (error) { dispose(socket); From 95af95f9fb4a8f7f6c23cf4707563903e9a73df2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 9 Sep 2026 00:01:27 +0000 Subject: [PATCH 68/68] test(std): cover shared socket allocation failure cleanup --- .../test/wasi/0.2.x/node/24.x.x/net/transport.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts index d738d7e50..1185a515c 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts @@ -14,6 +14,17 @@ describe("shared WASI TCP transport", () => { expect(errorCode({ payload: "would-block" })).toBe("would-block"); expect(errorCode(new Error("unrelated"))).toBeUndefined(); }); + test("maps socket allocation failures and releases the network", () => { + const { provider, disposed } = createProvider(); + provider.tcpCreateSocket.createTcpSocket = (): never => { + throw { payload: "access-denied" }; + }; + expect(() => connect(provider, "127.0.0.1", 443)).toThrow( + expect.objectContaining({ code: "EACCES", syscall: "connect", port: 443 }), + ); + expect(disposed).toEqual(["network"]); + }); + test("terminates when a literal address is filtered by family", () => { const { provider, disposed } = createProvider(); expect(() => connect(provider, "127.0.0.1", 80, { family: 6 })).toThrow();