From 974b08dbfc62235a18efc9187ab42a0b26115c58 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:04:38 +0000 Subject: [PATCH 01/53] 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 6d8396c3a40a7ae2cea0d064c807330be4d8ca73 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:24:23 +0000 Subject: [PATCH 02/53] 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 8c948ab77..80ff8238a 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 @@ -14,7 +14,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 d8eb97a99b1e9667ebc70010d616b6c665651473 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:24:23 +0000 Subject: [PATCH 03/53] 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 d66f497e23eeb661b3f8338c318c6273de2dae48 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:34:33 +0000 Subject: [PATCH 04/53] 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 | 76 ++++- .../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 | 23 +- .../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, 1014 insertions(+), 158 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 45e59a185..02d587356 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -167,6 +167,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 1f57171b5..ab7d99b98 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,11 +2,16 @@ * 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 * as nodeHttps from "node:https"; +import type * as nodeTls from "node:tls"; import { fieldsToRawHeaders, @@ -22,10 +27,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", @@ -42,12 +93,16 @@ export async function request(options: DirectHttpRequest): AsyncResult { clearTimeout(connectTimer); @@ -135,11 +190,22 @@ function serverAddress( class NodeHttpServer { readonly #listener: DirectHttpRequestListener; - readonly #server: nodeHttp.Server; + readonly #server: nodeHttp.Server | nodeHttps.Server; constructor(options: DirectHttpServerOptions, listener: DirectHttpRequestListener) { this.#listener = listener; - this.#server = nodeHttp.createServer(nodeServerOptions(options), async (request, response) => { + // A `tls` record, even an empty one, means the guest constructed an https.Server; Node's + // own https.Server accepts a missing certificate at construction and fails the handshake. + 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(async (request, response) => { try { const chunks: Uint8Array[] = []; for await (const chunk of request) { 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 32514db6e..6e91444b2 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts @@ -1,6 +1,6 @@ import { concatBytes } from "../body.js"; import { STATUS_CODES } from "../constants.js"; -import { fromImplementationError } from "../errors.js"; +import { fromImplementationError, unsupported } from "../errors.js"; import type { HttpHeaderField, HttpImplementation } from "../types.js"; export type WasiHttpMethod = @@ -160,6 +160,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; } @@ -240,15 +251,19 @@ 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 fields = provider.types.Fields.fromList( request.headers.map(({ name, value }): [string, Uint8Array] => [name, value]), ); 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 38d452eb9..4100bd30e 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 @@ -565,12 +565,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 80ff8238a..663267b6a 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 @@ -3,7 +3,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; @@ -31,6 +37,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; @@ -50,6 +120,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 { @@ -93,6 +165,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; } @@ -200,6 +278,7 @@ export interface DirectHttpRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + tls?: DirectTlsOptions; } export interface DirectHttpResponse { @@ -212,6 +291,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; @@ -226,6 +308,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 484a06af3..f019e80d4 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -78,6 +78,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 { @@ -88,6 +90,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, @@ -102,6 +132,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 0af5c0f4eda1eca48d4763231deb85d97f64fde4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:34:33 +0000 Subject: [PATCH 05/53] 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 3c3b457033f1afca21fe79e14588e3376320fcaa Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 13:35:10 +0000 Subject: [PATCH 06/53] 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 32a1fce00..39fe8d24a 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 @@ -80,6 +80,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 { @@ -90,6 +92,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, @@ -104,6 +134,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 0cd59ff0d..ae82226ab 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"; @@ -294,6 +299,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. */ @@ -688,18 +696,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")} `; } @@ -708,28 +730,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", @@ -794,6 +842,7 @@ ${http2Exports(`createHttp2(${factory}(${factoryArguments}))`)} function requireWasiHttpVersion( worldMetadata: WorldMetadata, + specifier: string, via: Exclude, version = "0.2.12", ): void { @@ -809,7 +858,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}`, ); } } @@ -1016,8 +1065,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")); @@ -1124,24 +1196,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}`; } @@ -1149,7 +1215,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) { @@ -1238,17 +1304,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 9f6e7e17a89698ff20bce403129c131eb6ccf43b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 14:03:09 +0000 Subject: [PATCH 07/53] 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 b91b2b5c7..cfd958a1b 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 @@ -260,14 +261,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])); } ``` @@ -602,12 +602,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 @@ -619,7 +619,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 @@ -630,8 +630,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); ``` @@ -662,15 +662,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` @@ -683,10 +683,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` @@ -770,7 +770,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 @@ -804,10 +807,47 @@ connections. > [!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: @@ -835,12 +875,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`. @@ -954,7 +993,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 db5f7b6b3..07a6410c6 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -28,6 +28,7 @@ Below is a list of utilties provided by `@bytecodealliance/jco-std`: | `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 | @@ -63,7 +64,8 @@ Below is a list of utilties provided by `@bytecodealliance/jco-std`: | `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` | @@ -146,8 +148,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; @@ -577,7 +579,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 \ @@ -589,12 +603,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 @@ -608,9 +627,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 5cb6216625e32ae89c96982246d76944b06d0a3d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 4 Sep 2026 14:03:47 +0000 Subject: [PATCH 08/53] 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 661fc8c51..c81717fbc 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -235,7 +235,15 @@ describe("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 192a783bd0731bb7c88bc6d3241c53e40e5d0568 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:21:38 +0000 Subject: [PATCH 09/53] 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 17c60aa79aa4c9142d7bbacbe011454ba7c26140 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:21:53 +0000 Subject: [PATCH 10/53] 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 04885f69f..aa8f23477 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 49cf7acbbd153128259607febe27df04b6ff56a4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:24:02 +0000 Subject: [PATCH 11/53] 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 729da7c92e64a8d5739e11bafe7d1f80f99558c1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:25:22 +0000 Subject: [PATCH 12/53] 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 | 82 ++++- .../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, 1061 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 02d587356..dfab85a19 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": { @@ -347,6 +348,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": { @@ -358,11 +363,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 4100bd30e..7043227ac 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 } from "../errors.js"; import { @@ -76,6 +83,8 @@ export interface WasiNetwork { } export interface WasiSocketsProvider { + tls?: WasiTlsProvider; + tlsStreamBridge?: WasiTlsStreamBridge; instanceNetwork: { instanceNetwork(): WasiNetwork; }; @@ -99,6 +108,10 @@ export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): vo } export function errorCode(error: unknown): string | undefined { + // Component bindings wrap WIT result errors in ComponentError.payload. + if (typeof error === "object" && error !== null && "payload" in error) { + error = error.payload; + } if (typeof error === "string") { return error; } @@ -187,10 +200,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 d56012ffd073115bc4c948dbdd871aa8a8b71ca8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:25:38 +0000 Subject: [PATCH 13/53] 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 77760ea04012132724704c05bd127b8d049ed494 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:01 +0000 Subject: [PATCH 14/53] 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 abebaf915..717b2b5a1 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -27,6 +27,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 ae82226ab..9ab637005 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -738,13 +738,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 7123f22c602ea64a50af75b46b052fe9e0a7a74f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:18 +0000 Subject: [PATCH 15/53] 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 fa4f96908..ae4ae58f7 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 558d3f9243be070d9860c9cea9d7333911ffe530 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:38 +0000 Subject: [PATCH 16/53] 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 a4898e9f3ed4e09ad26f5a5358073902ade260f9 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:26:51 +0000 Subject: [PATCH 17/53] 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 07a6410c6..65faa950a 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -605,16 +605,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 4a64893a6b8039d1446d3fddbff4f3556bcf6cc4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:27:04 +0000 Subject: [PATCH 18/53] 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 0ab8510566c89306187eea78ad6c1ebe45acf800 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:27:34 +0000 Subject: [PATCH 19/53] 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 cfd958a1b..a6a8d9f8e 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 @@ -837,9 +837,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 69f2bea53a31fde67dcdfe3305317409c3ba55e4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:37:53 +0000 Subject: [PATCH 20/53] 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 a6a8d9f8e..56edee6b5 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -840,8 +840,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 ee9ca4f46772fbad3a62f04e5c91657ab6962f5b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:42:32 +0000 Subject: [PATCH 21/53] 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 dfab85a19..a3944ba46 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -116,9 +116,9 @@ "default": "./dist/wasi/0.2.x/node/24.x.x/http/core.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 7043227ac..6e66823e6 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 } from "../errors.js"; +} from "./tls.js"; +import { concatBytes } from "../../body.js"; +import { fromImplementationError, invalidArgValue, unsupported } 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 89474bbd9080e7f14dda8a0ba042a50a2ffc69e2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:42:49 +0000 Subject: [PATCH 22/53] 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 df2ee2e80..e89ba84b3 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 b53f877a2..983b6088a 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 ff5d62c0c..a8bc1b018 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 { DirectHttp2ServerErrorListener, DirectHttp2Settings, 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 cccbc1b67aedd844ebbcae00d0b54e55d4bd13fe Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:43:45 +0000 Subject: [PATCH 23/53] 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 7b637d94f3ef3c9d2add0555e693d247e9f6788e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:02 +0000 Subject: [PATCH 24/53] 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 57586398b2e808117324c860ade9de5faf70fcaa Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:23 +0000 Subject: [PATCH 25/53] 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 65faa950a..d6280dbfa 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -616,15 +616,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 6b727fd73e6853333c331ac4e6f9e09359ad79de Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:39 +0000 Subject: [PATCH 26/53] 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 56edee6b5..5f09a5be5 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -841,8 +841,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 \ @@ -850,6 +850,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. @@ -871,8 +873,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 cee25deecef044748dd7354e6622e8678fd891e1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:44:53 +0000 Subject: [PATCH 27/53] 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 a368cfbc93d2c4e80b54e071431b66fd2d8d4576 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:50:43 +0000 Subject: [PATCH 28/53] 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 70053771efc1634e9b077aef9eec650725697eed Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:51:00 +0000 Subject: [PATCH 29/53] 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 a5233ee6d8fcbbf45955c0d7a51bb55edb04d00e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:51:39 +0000 Subject: [PATCH 30/53] 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 c63caad0a1cd79740e166ff0cdcf1fe00b64e9b8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:02 +0000 Subject: [PATCH 31/53] 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 7b38b5721..6a6adb2ba 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 4526148280de531b410512bb02143f6571500563 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:17 +0000 Subject: [PATCH 32/53] 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 9d0c4e383c0b759515d81acb3db8f824f22c4c03 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:52:47 +0000 Subject: [PATCH 33/53] 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 f043783e2..edfe2c535 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 78052a5c101342b1e3275726c2c4da32d70b944d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:03 +0000 Subject: [PATCH 34/53] 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 0700fff1e13222b8eea5dfbde8f315cf7f0eeff9 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:22 +0000 Subject: [PATCH 35/53] 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 6586af3f06bfef18e78fbbc747396b9bd9dc31f5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:53:38 +0000 Subject: [PATCH 36/53] 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 b8cb0b51fc449b9ea0e0ad651bba33f8b74cc08d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 12:54:02 +0000 Subject: [PATCH 37/53] 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 5f09a5be5..ab6ade379 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -880,7 +880,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 f65e31a5854421844cf96c71897b0d53ed1f064e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:10:43 +0000 Subject: [PATCH 38/53] 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 aa0e5d5a8945a4446ac2b573c46154338d82d2e3 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:11:17 +0000 Subject: [PATCH 39/53] 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 d90849a1c9d18f0bf2206f6b0f67e40e87e8128d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:11:56 +0000 Subject: [PATCH 40/53] 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 835c4a37b3ab05a0135a8f4968fa8f6f94af854e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:13:47 +0000 Subject: [PATCH 41/53] 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 6e66823e6..d6e486ad3 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; }; @@ -595,7 +593,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", @@ -628,7 +626,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 be7e70d726d09bd2868155971fcece6fc9711998 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:15:15 +0000 Subject: [PATCH 42/53] 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 73f08af17f0ef5457e3688b18a57ebaf09d1b307 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:15:44 +0000 Subject: [PATCH 43/53] 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 30bebd2c6428b3e52fcbd72a34908d0745706353 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:16:26 +0000 Subject: [PATCH 44/53] 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 717b2b5a1..991824f7b 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -28,8 +28,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 9ab637005..2f9b7f0e2 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -738,11 +738,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}"; @@ -750,7 +746,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 a6bb0be80ed2b60f9b0561367da5e22d413b438f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:17:26 +0000 Subject: [PATCH 45/53] 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 ae4ae58f7..f360fce95 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 72027c0f930d226af4314475f9d4c2b2e9fdcb73 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:18:34 +0000 Subject: [PATCH 46/53] 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 aa8f23477..e9103997b 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 65ad0befe37a48721bc6b33487dfaf65e6b17a8d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:19:10 +0000 Subject: [PATCH 47/53] 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 a3944ba46..75438f40c 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -352,6 +352,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": { @@ -385,5 +389,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 09a6a85744958ae7e3b44aa63aafb29674630d1e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:20:06 +0000 Subject: [PATCH 48/53] 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 36b41986717961b5f831ef921b307fb3b17efca1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:20:31 +0000 Subject: [PATCH 49/53] 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 688b132ff..49bee918b 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 @@ -79,18 +79,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()))); @@ -126,18 +116,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 d2c603bfc65ec5cbad5f7a269020bd1f49d34a82 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:21:12 +0000 Subject: [PATCH 50/53] 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 c6c830712e7511e1f7d50076f850ad28f5bb9056 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:22:21 +0000 Subject: [PATCH 51/53] 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 d6280dbfa..efabd2217 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -616,11 +616,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 7b1d7e8b51f0262345f293e784ec52ba462ee18b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:23:07 +0000 Subject: [PATCH 52/53] 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 917b10e02b7c49cb70ba134ec875716bd2b608a6 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 7 Sep 2026 14:23:41 +0000 Subject: [PATCH 53/53] 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 ab6ade379..93b1104fd 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -841,35 +841,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