From 36993b53a0adb53e0e67466756cb39e5acd165e1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 01/13] feat(std): define Node TLS capability contract --- .../wasi/0.2.x/node/24.x.x/tls-interface.d.ts | 14 ++ .../src/wasi/0.2.x/node/24.x.x/tls/errors.ts | 30 +++ .../wasi/0.2.x/node/24.x.x/tls/host-types.ts | 86 +++++++ .../wasi/0.2.x/node/24.x.x/tls/node-host.ts | 49 ++++ .../src/wasi/0.2.x/node/24.x.x/tls/runtime.ts | 31 +++ .../src/wasi/0.2.x/node/24.x.x/tls/types.ts | 232 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/tls/wire.ts | 121 +++++++++ .../test/wasi/0.2.x/node/24.x.x/tls/wire.ts | 24 ++ packages/jco-std/wit/node-0.1.0/tls.wit | 108 ++++++++ 9 files changed, 695 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/errors.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-types.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/node-host.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/runtime.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/types.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wire.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/wire.ts create mode 100644 packages/jco-std/wit/node-0.1.0/tls.wit diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts new file mode 100644 index 000000000..1bce8aa5a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts @@ -0,0 +1,14 @@ +import type { TlsHost } from "./tls/host-types.js"; +export const query: TlsHost["query"]; +export const setDefaultCa: TlsHost["setDefaultCa"]; +export const createContext: TlsHost["createContext"]; +export const connect: TlsHost["connect"]; +export const createServer: TlsHost["createServer"]; +export const socketOperation: TlsHost["socketOperation"]; +export const serverOperation: TlsHost["serverOperation"]; +export const write: TlsHost["write"]; +export const end: TlsHost["end"]; +export const release: TlsHost["release"]; +export const isAvailable: TlsHost["isAvailable"]; +export const startTls: TlsHost["startTls"]; +export const releaseContext: TlsHost["releaseContext"]; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/errors.ts new file mode 100644 index 000000000..fd1f5696b --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/errors.ts @@ -0,0 +1,30 @@ +import { unsupportedNodeApi } from "../errors/core.js"; + +export function unsupported( + api: string, + reason = "this operation requires a native object or synchronous callback that cannot cross the component boundary", +): never { + throw unsupportedNodeApi(`tls.${api}`, reason); +} + +export function hostCall(operation: () => T): T { + try { + return operation(); + } catch (error) { + const record = error as { payload?: unknown; name?: string; message?: string; code?: string }; + const value = (record?.payload ?? record) as { name?: string; message?: string; code?: string }; + if (!(error instanceof Error) || record?.payload !== undefined) { + if (typeof value?.message === "string") { + const result = + value.name === "TypeError" + ? new TypeError(value.message) + : value.name === "RangeError" + ? new RangeError(value.message) + : new Error(value.message); + Object.assign(result, value); + throw result; + } + } + throw error; + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-types.ts new file mode 100644 index 000000000..19149fcfe --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-types.ts @@ -0,0 +1,86 @@ +import type { ConnectionOptions, ServerOptions } from "./types.js"; +import type { WasiInputStream, WasiOutputStream } from "../internal/wasi-sockets.js"; +import type { WasiTlsFuture } from "../http/impl/wasi-sockets/tls.js"; +/** Runtime-neutral contract for the explicitly granted jco:node/tls capability. */ +export type SocketOperation = + | "state" + | "address" + | "certificate" + | "peer-certificate" + | "cipher" + | "ephemeral-key-info" + | "finished" + | "peer-finished" + | "protocol" + | "session" + | "shared-sigalgs" + | "tls-ticket" + | "session-reused" + | "export-keying-material" + | "disable-renegotiation" + | "enable-trace" + | "max-send-fragment" + | "key-cert" + | "timeout" + | "no-delay" + | "keep-alive" + | "ref" + | "unref" + | "resume" + | "pause" + | "destroy" + | "renegotiate" + | "set-session" + | "set-servername"; + +export type ServerOperation = + | "state" + | "address" + | "listen" + | "close" + | "connections" + | "ref" + | "unref" + | "ticket-keys" + | "set-ticket-keys" + | "secure-context" + | "add-context" + | "max-connections" + | "drop-max-connection"; + +export type Query = "ciphers" | "compression-algorithms" | "ca-certificates" | "check-identity"; + +export interface TlsEvent { + target: number; + name: string; + value: string; + data: Uint8Array; +} + +export interface TlsCallbacks { + dispatch(event: TlsEvent): void | Promise; +} + +export interface TlsStreamHost { + isAvailable(): boolean; + startTls(serverName: string, input: WasiInputStream, output: WasiOutputStream): WasiTlsFuture; +} + +export interface TlsHost extends TlsStreamHost { + query(operation: Query, args: string): string; + setDefaultCa(certs: string): void; + createContext(options: string): number; + releaseContext(id: number): void; + connect(id: number, options: string): void; + createServer(id: number, options: string): void; + socketOperation(id: number, operation: SocketOperation, args: string): string; + serverOperation(id: number, operation: ServerOperation, args: string): string; + write(id: number, token: number, data: Uint8Array): void; + end(id: number, token: number): void; + release(id: number): void; +} + +/** Local integration contract for HTTP hosts consuming one-use TLS configurations. */ +export interface TlsConfigurationProvider { + takeContextOptions(id: number): ConnectionOptions & ServerOptions; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/node-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/node-host.ts new file mode 100644 index 000000000..fc81b415c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/node-host.ts @@ -0,0 +1,49 @@ +import { dispose } from "../internal/wasi-sockets.js"; +import type { TlsHost } from "./host-types.js"; + +function denied(): never { + throw Object.assign( + new Error("node:tls requires an explicitly configured jco:node/tls@0.1.0 host provider"), + { + code: "ERR_JCO_TLS_ADAPTER_REQUIRED", + }, + ); +} + +export const isAvailable = (): boolean => false; +export const startTls: TlsHost["startTls"] = (_name, input, output) => { + try { + denied(); + } finally { + dispose(output); + dispose(input); + } +}; + +export const query: TlsHost["query"] = denied; +export const setDefaultCa: TlsHost["setDefaultCa"] = denied; +export const createContext: TlsHost["createContext"] = denied; +export const releaseContext: TlsHost["releaseContext"] = () => {}; +export const connect: TlsHost["connect"] = denied; +export const createServer: TlsHost["createServer"] = denied; +export const socketOperation: TlsHost["socketOperation"] = denied; +export const serverOperation: TlsHost["serverOperation"] = denied; +export const write: TlsHost["write"] = denied; +export const end: TlsHost["end"] = denied; +export const release: TlsHost["release"] = denied; + +export default { + isAvailable, + startTls, + query, + setDefaultCa, + createContext, + releaseContext, + connect, + createServer, + socketOperation, + serverOperation, + write, + end, + release, +} satisfies TlsHost; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/runtime.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/runtime.ts new file mode 100644 index 000000000..29d499c12 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/runtime.ts @@ -0,0 +1,31 @@ +import type { TlsHost } from "./host-types.js"; +import type { + ConnectionOptions, + SecureContextLike, + ServerOptions, + SocketState, + TlsSocket, +} from "./types.js"; + +export interface TlsRuntime { + host: TlsHost; + allocate(): number; + listeners: Map void>; + call(operation: () => string): T; + normalizeOptions(options: ConnectionOptions | ServerOptions): string; + contextId(value: SecureContextLike): number; + isSecureContext(value: unknown): value is SecureContextLike; +} + +export interface InternalTlsSocket extends TlsSocket { + start(options: ConnectionOptions): this; +} + +export interface InternalTlsSocketConstructor { + new ( + socket?: unknown, + options?: ConnectionOptions, + accepted?: { id: number; state: SocketState }, + factory?: boolean, + ): InternalTlsSocket; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/types.ts new file mode 100644 index 000000000..c2fa6b4ba --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/types.ts @@ -0,0 +1,232 @@ +import type { Duplex, Callback } from "../stream/types.js"; +import type { EventEmitter } from "../internal/event-emitter.js"; +/** Public data contracts reconciled with Node 24.20.0 and @types/node 24. */ +export type TLSVersion = "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3"; +export type Pem = string | Uint8Array; +export interface SecureContextOptions { + ca?: Pem | Pem[]; + cert?: Pem | Pem[]; + key?: Pem | Array; + pfx?: Pem | Array; + passphrase?: string; + ciphers?: string; + ciphersuites?: string; + sigalgs?: string; + ecdhCurve?: string; + dhparam?: Pem; + crl?: Pem | Pem[]; + honorCipherOrder?: boolean; + minVersion?: TLSVersion; + maxVersion?: TLSVersion; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + sessionTimeout?: number; + ticketKeys?: Uint8Array; + allowPartialTrustChain?: boolean; +} + +export interface PeerCertificate { + subject: Record; + issuer: Record; + subjectaltname?: string; + infoAccess?: Record; + valid_from: string; + valid_to: string; + fingerprint: string; + fingerprint256: string; + fingerprint512: string; + serialNumber: string; + raw: Uint8Array; + pubkey?: Uint8Array; + bits?: number; + issuerCertificate?: PeerCertificate; + ext_key_usage?: string[]; +} + +export interface ConnectionOptions extends SecureContextOptions { + port?: number; + host?: string; + path?: string; + servername?: string; + rejectUnauthorized?: boolean; + requestOCSP?: boolean; + ALPNProtocols?: string[] | Uint8Array; + session?: Uint8Array; + minDHSize?: number; + secureContext?: SecureContextLike; + checkServerIdentity?: (hostname: string, cert: PeerCertificate) => Error | undefined | null; + enableTrace?: boolean; + timeout?: number; + localAddress?: string; + localPort?: number; + family?: number; + allowHalfOpen?: boolean; + highWaterMark?: number; + signal?: AbortSignal; + socket?: unknown; + pskCallback?: unknown; + lookup?: unknown; +} + +export interface ServerOptions extends SecureContextOptions { + requestCert?: boolean; + rejectUnauthorized?: boolean; + ALPNProtocols?: string[] | Uint8Array; + handshakeTimeout?: number; + enableTrace?: boolean; + allowHalfOpen?: boolean; + highWaterMark?: number; + SNICallback?: unknown; + ALPNCallback?: unknown; + pskCallback?: unknown; +} + +export interface SecureContextLike { + readonly context: unknown; +} +export interface AddressInfo { + address: string; + family: string; + port: number; +} +export interface CipherNameAndProtocol { + name: string; + standardName: string; + version: string; +} +export interface EphemeralKeyInfo { + type: string; + name?: string; + size: number; +} +export interface ListenOptions { + port?: number; + host?: string; + backlog?: number; + path?: string; + exclusive?: boolean; + ipv6Only?: boolean; + reusePort?: boolean; +} +export interface SocketState { + authorized?: boolean; + authorizationError?: Error | string; + alpnProtocol?: string | false; + servername?: string | false; + localAddress?: string; + localPort?: number; + localFamily?: string; + remoteAddress?: string; + remotePort?: number; + remoteFamily?: string; + bytesRead?: number; + bytesWritten?: number; + connecting?: boolean; + pending?: boolean; +} + +export interface TlsConnect { + (options: ConnectionOptions, callback?: () => void): TlsSocket; + (port: number, options?: ConnectionOptions, callback?: () => void): TlsSocket; + (port: number, host?: string, options?: ConnectionOptions, callback?: () => void): TlsSocket; + (path: string, options?: ConnectionOptions, callback?: () => void): TlsSocket; +} + +export interface NodeTlsModule { + CLIENT_RENEG_LIMIT: number; + CLIENT_RENEG_WINDOW: number; + DEFAULT_CIPHERS: string; + DEFAULT_ECDH_CURVE: string; + DEFAULT_MIN_VERSION: TLSVersion; + DEFAULT_MAX_VERSION: TLSVersion; + SecureContext: new (options?: SecureContextOptions) => SecureContextLike; + TLSSocket: new (socket?: unknown, options?: ConnectionOptions) => TlsSocket; + Server: new ( + options?: ServerOptions | ((socket: TlsSocket) => void), + listener?: (socket: TlsSocket) => void, + ) => TlsServer; + connect: TlsConnect; + createSecureContext(options?: SecureContextOptions): SecureContextLike; + createServer( + options?: ServerOptions | ((socket: TlsSocket) => void), + listener?: (socket: TlsSocket) => void, + ): TlsServer; + convertALPNProtocols(protocols: unknown, out: { ALPNProtocols?: Uint8Array }): void; + getCiphers(): string[]; + getCertificateCompressionAlgorithms(): string[]; + getCACertificates(type?: "default" | "bundled" | "system" | "extra"): readonly string[]; + setDefaultCACertificates(certs: Array): void; + checkServerIdentity(hostname: string, cert: PeerCertificate): Error | undefined; + rootCertificates: readonly string[]; +} + +export interface TlsSocket extends Duplex { + encrypted: boolean; + authorized: boolean; + authorizationError: Error | string | undefined; + alpnProtocol: string | false; + servername: string | false; + connecting: boolean; + pending: boolean; + _read(): void; + _write(chunk: unknown, _encoding: string, callback: Callback): void; + _final(callback: Callback): void; + _destroy(error: Error | null, callback: Callback): void; + readonly localAddress: string | undefined; + readonly localPort: number | undefined; + readonly localFamily: string | undefined; + readonly remoteAddress: string | undefined; + readonly remotePort: number | undefined; + readonly remoteFamily: string | undefined; + readonly bytesRead: number; + readonly bytesWritten: number; + address(): AddressInfo | object; + getCertificate(): PeerCertificate | object; + getPeerCertificate(detailed?: boolean): PeerCertificate; + getCipher(): CipherNameAndProtocol; + getEphemeralKeyInfo(): EphemeralKeyInfo | object | null; + getFinished(): Uint8Array | undefined; + getPeerFinished(): Uint8Array | undefined; + getProtocol(): string | null; + setSession(session: Uint8Array | string): void; + setServername(name: string): void; + getSession(): Uint8Array | undefined; + getSharedSigalgs(): string[]; + getTLSTicket(): Uint8Array | undefined; + isSessionReused(): boolean; + exportKeyingMaterial(length: number, label: string, context?: Uint8Array): Uint8Array; + disableRenegotiation(): void; + enableTrace(): void; + setMaxSendFragment(size: number): boolean; + setKeyCert(value: SecureContextLike | SecureContextOptions): void; + getPeerX509Certificate(): never; + getX509Certificate(): never; + setTimeout(timeout: number, callback?: () => void): this; + setNoDelay(noDelay?: boolean): this; + setKeepAlive(enable?: boolean, initialDelay?: number): this; + ref(): this; + unref(): this; + renegotiate( + options: { rejectUnauthorized?: boolean; requestCert?: boolean }, + callback: Callback, + ): boolean; +} +export interface TlsServer extends EventEmitter { + maxConnections: number | undefined; + dropMaxConnection: boolean | undefined; + listening: boolean; + listen(options: ListenOptions, callback?: () => void): this; + listen(port: number, host?: string | (() => void), callback?: () => void): this; + listen(path: string, callback?: () => void): this; + address(): AddressInfo | string | null; + close(callback?: (error?: Error) => void): this; + getConnections(callback: (error: Error | null, count: number) => void): this; + getTicketKeys(): Uint8Array; + setTicketKeys(keys: Uint8Array): void; + setSecureContext(options: SecureContextOptions): void; + addContext(hostname: string, value: SecureContextLike | SecureContextOptions): void; + ref(): this; + unref(): this; + [Symbol.asyncDispose](): Promise; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wire.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wire.ts new file mode 100644 index 000000000..706ef91b1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wire.ts @@ -0,0 +1,121 @@ +import { Buffer } from "node:buffer"; + +/** Graph payloads preserve binary certificate fields, errors, and self-signed issuer cycles. */ +type Node = + | { kind: "bytes"; data: number[] } + | { kind: "array"; data: Value[] } + | { kind: "object"; data: [string, Value][]; error?: string }; +type Value = + | null + | boolean + | number + | string + | { ref: number } + | { undefined: true } + | { number: "NaN" | "Infinity" | "-Infinity" | "-0" }; + +export function encode(value: unknown): string { + const nodes: Node[] = []; + const seen = new Map(); + function visit(value: unknown): Value { + if (typeof value === "number" && (!Number.isFinite(value) || Object.is(value, -0))) { + return { + number: (Object.is(value, -0) ? "-0" : String(value)) as + | "NaN" + | "Infinity" + | "-Infinity" + | "-0", + }; + } + if (value === undefined) { + return { undefined: true }; + } + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return value; + } + if (typeof value !== "object") { + throw new TypeError("TLS capability values must be data, not functions or native handles"); + } + const existing = seen.get(value); + if (existing !== undefined) { + return { ref: existing }; + } + const ref = nodes.length; + seen.set(value, ref); + const node: Node = ArrayBuffer.isView(value) + ? { + kind: "bytes", + data: Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)), + } + : Array.isArray(value) + ? { kind: "array", data: [] } + : { kind: "object", data: [] }; + nodes.push(node); + if (node.kind === "array") { + node.data = (value as unknown[]).map(visit); + } + if (node.kind === "object") { + if (value instanceof Error) { + node.error = value.name; + node.data.push(["message", value.message]); + } + for (const [key, entry] of Object.entries(value)) { + node.data.push([key, visit(entry)]); + } + } + return { ref }; + } + const root = visit(value); + return JSON.stringify({ root, nodes }); +} + +export function decode(text: string): unknown { + const { root, nodes } = JSON.parse(text) as { root: Value; nodes: Node[] }; + const objects: unknown[] = nodes.map((node) => + node.kind === "bytes" + ? Buffer.from(node.data) + : node.kind === "array" + ? [] + : node.error + ? errorWithName(node.error) + : {}, + ); + function visit(value: Value): unknown { + if (value !== null && typeof value === "object") { + return "ref" in value + ? objects[value.ref] + : "number" in value + ? Number(value.number) + : undefined; + } + return value; + } + nodes.forEach((node, index) => { + if (node.kind === "array") { + (objects[index] as unknown[]).push(...node.data.map(visit)); + } + if (node.kind === "object") { + for (const [key, value] of node.data) { + Object.defineProperty(objects[index], key, { + value: visit(value), + writable: true, + configurable: true, + enumerable: true, + }); + } + } + }); + return visit(root); +} + +function errorWithName(name: string): Error { + const error = + name === "TypeError" ? new TypeError() : name === "RangeError" ? new RangeError() : new Error(); + error.name = name; + return error; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/wire.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/wire.ts new file mode 100644 index 000000000..c307ad874 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/wire.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { encode, decode } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/wire.js"; + +test.concurrent("wire metadata retains certificate cycles, views, errors and undefined", () => { + const value: Record = { + raw: new Uint8Array([0, 1, 2]).subarray(1), + absent: undefined, + error: Object.assign(new TypeError("bad option"), { code: "ERR_TEST" }), + }; + value.issuerCertificate = value; + const result = decode(encode(value)) as typeof value; + expect(result.issuerCertificate).toBe(result); + expect(result.raw).toEqual(Buffer.from([1, 2])); + expect(result).toHaveProperty("absent", undefined); + expect(result.error).toBeInstanceOf(TypeError); + expect(result.error).toMatchObject({ message: "bad option", code: "ERR_TEST" }); +}); + +test.concurrent.each([NaN, Infinity, -Infinity, -0])( + "wire preserves numeric validation input %s", + (value) => { + expect(Object.is(decode(encode(value)), value)).toBe(true); + }, +); diff --git a/packages/jco-std/wit/node-0.1.0/tls.wit b/packages/jco-std/wit/node-0.1.0/tls.wit new file mode 100644 index 000000000..acd071779 --- /dev/null +++ b/packages/jco-std/wit/node-0.1.0/tls.wit @@ -0,0 +1,108 @@ +package jco:node@0.1.0; + +/// Events are delivered after the initiating import returns, avoiding component reentrance. +interface tls-callbacks { + record event { + target: u32, + name: string, + value: string, + data: list, + } + + dispatch: func(event: event); +} + +/// Primary TLS capability for Node APIs. Providers may delegate to wasi:tls. +interface tls { + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:tls/types@0.2.0-draft.{future-client-streams}; + + /// Optional TLS upgrade over existing WASI streams. Ownership passes to the provider. + is-available: func() -> bool; + + start-tls: func(server-name: string, input: input-stream, output: output-stream) -> future-client-streams; + + record error { + name: string, + message: string, + code: option, + } + + enum query-operation { + ciphers, + compression-algorithms, + ca-certificates, + check-identity, + } + + enum socket-operation-kind { + state, + address, + certificate, + peer-certificate, + cipher, + ephemeral-key-info, + finished, + peer-finished, + protocol, + session, + shared-sigalgs, + tls-ticket, + session-reused, + export-keying-material, + disable-renegotiation, + enable-trace, + max-send-fragment, + key-cert, + timeout, + no-delay, + keep-alive, + ref, + unref, + resume, + pause, + destroy, + renegotiate, + set-session, + set-servername, + } + + enum server-operation-kind { + state, + address, + listen, + close, + connections, + ref, + unref, + ticket-keys, + set-ticket-keys, + secure-context, + add-context, + max-connections, + drop-max-connection, + } + + /// Options and inspection values use the documented TLS graph-data encoding. + query: func(operation: query-operation, args: string) -> result; + + set-default-ca: func(certs: string) -> result<_, error>; + + create-context: func(options: string) -> result; + + release-context: func(id: u32); + + connect: func(id: u32, options: string) -> result<_, error>; + + create-server: func(id: u32, options: string) -> result<_, error>; + + socket-operation: func(id: u32, operation: socket-operation-kind, args: string) -> result; + + server-operation: func(id: u32, operation: server-operation-kind, args: string) -> result; + + write: func(id: u32, token: u32, data: list) -> result<_, error>; + + end: func(id: u32, token: u32) -> result<_, error>; + + release: func(id: u32); +} From 632c76d95d11bc0e563db36a2dce58d802bb33dd Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 02/13] feat(jco): add the Node TLS capability WIT --- .../lib/wit/builtin/jco-node-0.1.0/tls.wit | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/jco/lib/wit/builtin/jco-node-0.1.0/tls.wit diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/tls.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/tls.wit new file mode 100644 index 000000000..acd071779 --- /dev/null +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/tls.wit @@ -0,0 +1,108 @@ +package jco:node@0.1.0; + +/// Events are delivered after the initiating import returns, avoiding component reentrance. +interface tls-callbacks { + record event { + target: u32, + name: string, + value: string, + data: list, + } + + dispatch: func(event: event); +} + +/// Primary TLS capability for Node APIs. Providers may delegate to wasi:tls. +interface tls { + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:tls/types@0.2.0-draft.{future-client-streams}; + + /// Optional TLS upgrade over existing WASI streams. Ownership passes to the provider. + is-available: func() -> bool; + + start-tls: func(server-name: string, input: input-stream, output: output-stream) -> future-client-streams; + + record error { + name: string, + message: string, + code: option, + } + + enum query-operation { + ciphers, + compression-algorithms, + ca-certificates, + check-identity, + } + + enum socket-operation-kind { + state, + address, + certificate, + peer-certificate, + cipher, + ephemeral-key-info, + finished, + peer-finished, + protocol, + session, + shared-sigalgs, + tls-ticket, + session-reused, + export-keying-material, + disable-renegotiation, + enable-trace, + max-send-fragment, + key-cert, + timeout, + no-delay, + keep-alive, + ref, + unref, + resume, + pause, + destroy, + renegotiate, + set-session, + set-servername, + } + + enum server-operation-kind { + state, + address, + listen, + close, + connections, + ref, + unref, + ticket-keys, + set-ticket-keys, + secure-context, + add-context, + max-connections, + drop-max-connection, + } + + /// Options and inspection values use the documented TLS graph-data encoding. + query: func(operation: query-operation, args: string) -> result; + + set-default-ca: func(certs: string) -> result<_, error>; + + create-context: func(options: string) -> result; + + release-context: func(id: u32); + + connect: func(id: u32, options: string) -> result<_, error>; + + create-server: func(id: u32, options: string) -> result<_, error>; + + socket-operation: func(id: u32, operation: socket-operation-kind, args: string) -> result; + + server-operation: func(id: u32, operation: server-operation-kind, args: string) -> result; + + write: func(id: u32, token: u32, data: list) -> result<_, error>; + + end: func(id: u32, token: u32) -> result<_, error>; + + release: func(id: u32); +} From 3d0e464d078eb3a31568559b2a0079f024c9a527 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 14:06:51 +0000 Subject: [PATCH 03/13] feat(std): implement portable Node TLS facade --- packages/jco-std/package.json | 15 + .../jco-std/src/wasi/0.2.x/node/24.x.x/tls.ts | 28 ++ .../src/wasi/0.2.x/node/24.x.x/tls/alpn.ts | 59 +++ .../0.2.x/node/24.x.x/tls/certificates.ts | 25 ++ .../src/wasi/0.2.x/node/24.x.x/tls/core.ts | 215 +++++++++++ .../src/wasi/0.2.x/node/24.x.x/tls/server.ts | 206 ++++++++++ .../src/wasi/0.2.x/node/24.x.x/tls/socket.ts | 352 ++++++++++++++++++ packages/jco-std/tsconfig.json | 1 + 8 files changed, 901 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/alpn.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/certificates.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/core.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/server.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/socket.ts diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index d425da5b2..26713c536 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -493,6 +493,21 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/url/idna.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/url/idna.js", "default": "./dist/wasi/0.2.x/node/24.x.x/url/idna.js" + }, + "./wasi/0.2.x/node/24.x.x/tls": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/tls.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/tls/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls/core.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/node-host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls.ts new file mode 100644 index 000000000..9001caaf9 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls.ts @@ -0,0 +1,28 @@ +import * as host from "jco:node/tls@0.1.0"; +import { createTls } from "./tls/core.js"; + +const { api: tls, tlsCallbacks } = createTls(host); +export { tlsCallbacks }; +export const { + CLIENT_RENEG_LIMIT, + CLIENT_RENEG_WINDOW, + DEFAULT_CIPHERS, + DEFAULT_ECDH_CURVE, + DEFAULT_MIN_VERSION, + DEFAULT_MAX_VERSION, + SecureContext, + TLSSocket, + Server, + connect, + createSecureContext, + createServer, + getCiphers, + getCertificateCompressionAlgorithms, + getCACertificates, + setDefaultCACertificates, + convertALPNProtocols, + checkServerIdentity, + rootCertificates, +} = tls; +export type * from "./tls/types.js"; +export default tls; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/alpn.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/alpn.ts new file mode 100644 index 000000000..942feb8c2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/alpn.ts @@ -0,0 +1,59 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** Adapted from nodejs/node v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, + * lib/tls.js. Uses public Buffer APIs and an explicit RangeError code. */ +import { Buffer } from "node:buffer"; + +export function convertALPNProtocols( + protocols: unknown, + out: { ALPNProtocols?: Uint8Array }, +): void { + if (Array.isArray(protocols)) { + const lengths: number[] = []; + const buffer = Buffer.allocUnsafe( + protocols.reduce((total: number, protocol: string, index: number) => { + const length = Buffer.byteLength(protocol); + if (length > 255) { + throw Object.assign( + new RangeError( + `The byte length of the protocol at index ${index} exceeds the maximum length. It must be <= 255. Received ${length}`, + ), + { code: "ERR_OUT_OF_RANGE" }, + ); + } + lengths[index] = length; + return total + 1 + length; + }, 0), + ); + let offset = 0; + for (let index = 0; index < protocols.length; index++) { + buffer[offset++] = lengths[index]; + buffer.write(protocols[index], offset); + offset += lengths[index]; + } + out.ALPNProtocols = buffer; + } else if (ArrayBuffer.isView(protocols)) { + out.ALPNProtocols = Buffer.from( + new Uint8Array(protocols.buffer, protocols.byteOffset, protocols.byteLength), + ); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/certificates.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/certificates.ts new file mode 100644 index 000000000..f6e1ea55a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/certificates.ts @@ -0,0 +1,25 @@ +/** Preserve named/default identity without querying trust during component snapshots. */ +export function lazyCertificates(read: () => string[]): readonly string[] { + const certificates: string[] = []; + let loaded = false; + function load(): string[] { + if (!loaded) { + const values = read(); + certificates.push(...values); + Object.freeze(certificates); + loaded = true; + } + return certificates; + } + return new Proxy(certificates, { + get: (_target, key) => Reflect.get(load(), key), + set: (_target, key, value) => Reflect.set(load(), key, value), + has: (_target, key) => Reflect.has(load(), key), + ownKeys: () => Reflect.ownKeys(load()), + getOwnPropertyDescriptor: (_target, key) => Reflect.getOwnPropertyDescriptor(load(), key), + isExtensible: () => Reflect.isExtensible(load()), + preventExtensions: () => Reflect.preventExtensions(load()), + defineProperty: (_target, key, descriptor) => Reflect.defineProperty(load(), key, descriptor), + deleteProperty: (_target, key) => Reflect.deleteProperty(load(), key), + }); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/core.ts new file mode 100644 index 000000000..d8b3e2f28 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/core.ts @@ -0,0 +1,215 @@ +import { createTlsSocketClass } from "./socket.js"; +import { createTlsServerClass } from "./server.js"; +import type { InternalTlsSocket } from "./runtime.js"; +import { convertALPNProtocols } from "./alpn.js"; +import { lazyCertificates } from "./certificates.js"; +import { encode, decode } from "./wire.js"; +import { hostCall, unsupported } from "./errors.js"; +import type { TlsCallbacks, TlsHost } from "./host-types.js"; +import type { + ConnectionOptions, + SecureContextOptions, + ServerOptions, + TLSVersion, + TlsSocket, + TlsServer, + PeerCertificate, +} from "./types.js"; + +export function createTls(host: TlsHost): { + api: import("./types.js").NodeTlsModule; + tlsCallbacks: TlsCallbacks; +} { + let nextId = 1; + const listeners = new Map void>(); + const contexts = new WeakMap(); + const caCache = new Map(); + const call = (operation: () => string): T => decode(hostCall(operation)) as T; + + class SecureContext { + readonly context: object = {}; + constructor(options: SecureContextOptions = {}) { + contexts.set( + this, + hostCall(() => host.createContext(encode(defaultOptions(options)))), + ); + } + } + + function defaultOptions(options: T): T { + return { + ciphers: api.DEFAULT_CIPHERS, + ecdhCurve: api.DEFAULT_ECDH_CURVE, + ...(options.secureProtocol === undefined + ? { minVersion: api.DEFAULT_MIN_VERSION, maxVersion: api.DEFAULT_MAX_VERSION } + : {}), + ...options, + }; + } + + function contextId(value: SecureContext): number { + const id = contexts.get(value); + if (id === undefined) { + throw new TypeError("SecureContext belongs to another TLS implementation"); + } + return id; + } + + function normalizeOptions(options: ConnectionOptions | ServerOptions): string { + for (const name of [ + "socket", + "pskCallback", + "lookup", + "SNICallback", + "ALPNCallback", + "clientCertEngine", + "privateKeyEngine", + "privateKeyIdentifier", + ] as const) { + if (name in options && Reflect.get(options, name) !== undefined) { + unsupported(`options.${name}`); + } + } + if ( + "checkServerIdentity" in options && + options.checkServerIdentity !== undefined && + typeof options.checkServerIdentity !== "function" + ) { + throw Object.assign(new TypeError("checkServerIdentity must be a function"), { + code: "ERR_INVALID_ARG_TYPE", + }); + } + const { + secureContext, + checkServerIdentity, + signal: _signal, + ...values + } = options as ConnectionOptions; + return encode({ + ...defaultOptions(values), + ...(secureContext === undefined + ? {} + : { contextId: contextId(secureContext as SecureContext) }), + ...(checkServerIdentity === undefined ? {} : { customIdentity: true }), + }); + } + + const runtime = { + host, + listeners, + call, + normalizeOptions, + contextId, + allocate: () => { + if (nextId >= 0x40000000) { + throw new Error("TLS handle space exhausted"); + } + return nextId++; + }, + isSecureContext: (value: unknown): value is SecureContext => value instanceof SecureContext, + }; + const TLSSocket = createTlsSocketClass(runtime); + const Server = createTlsServerClass(runtime, TLSSocket); + + function connect(options: ConnectionOptions, callback?: () => void): InternalTlsSocket; + function connect( + port: number, + options?: ConnectionOptions, + callback?: () => void, + ): InternalTlsSocket; + function connect( + port: number, + host?: string, + options?: ConnectionOptions, + callback?: () => void, + ): InternalTlsSocket; + function connect( + path: string, + options?: ConnectionOptions, + callback?: () => void, + ): InternalTlsSocket; + function connect(...args: unknown[]): InternalTlsSocket { + const values = args.slice(); + const callback = typeof values.at(-1) === "function" ? (values.pop() as () => void) : undefined; + const first = values.shift(); + let options: ConnectionOptions; + if (typeof first === "number") { + const host = typeof values[0] === "string" ? (values.shift() as string) : undefined; + options = { port: first, host, ...(values[0] as ConnectionOptions) }; + } else if (typeof first === "string") { + options = { path: first, ...(values[0] as ConnectionOptions) }; + } else { + options = first as ConnectionOptions; + } + const socket = new TLSSocket(undefined, options, undefined, true); + if (callback) { + socket.once("secureConnect", callback); + } + return socket.start(options); + } + + const api = { + CLIENT_RENEG_LIMIT: 3, + CLIENT_RENEG_WINDOW: 600, + DEFAULT_CIPHERS: + "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA384:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA", + DEFAULT_ECDH_CURVE: "auto", + DEFAULT_MIN_VERSION: "TLSv1.2" as TLSVersion, + DEFAULT_MAX_VERSION: "TLSv1.3" as TLSVersion, + SecureContext, + TLSSocket, + Server, + connect, + convertALPNProtocols, + createSecureContext: (options?: SecureContextOptions): SecureContext => + new SecureContext(options), + createServer: ( + options?: ServerOptions | ((socket: TlsSocket) => void), + listener?: (socket: TlsSocket) => void, + ): TlsServer => new Server(options, listener), + getCiphers: (): string[] => call(() => host.query("ciphers", encode([]))), + getCertificateCompressionAlgorithms: (): string[] => + call(() => host.query("compression-algorithms", encode([]))), + getCACertificates: ( + type: "default" | "bundled" | "system" | "extra" = "default", + ): readonly string[] => { + if (type === "bundled") { + return api.rootCertificates; + } + let certs = caCache.get(type); + if (!certs) { + certs = Object.freeze(call(() => host.query("ca-certificates", encode([type])))); + caCache.set(type, certs); + } + return certs; + }, + setDefaultCACertificates: (certs: Array): void => { + hostCall(() => host.setDefaultCa(encode(certs))); + caCache.delete("default"); + }, + checkServerIdentity: (hostname: string, cert: PeerCertificate): Error | undefined => + call(() => host.query("check-identity", encode([hostname, cert]))), + rootCertificates: lazyCertificates(() => + call(() => host.query("ca-certificates", encode(["bundled"]))), + ), + }; + for (const name of ["CLIENT_RENEG_LIMIT", "CLIENT_RENEG_WINDOW"] as const) { + const value = api[name]; + Object.defineProperty(api, name, { + enumerable: true, + get: () => value, + set: () => unsupported(name, "renegotiation limits are host policy"), + }); + } + const tlsCallbacks: TlsCallbacks = { + dispatch: (event) => listeners.get(event.target)?.(event.name, decode(event.value), event.data), + }; + return { api, tlsCallbacks }; +} + +export type { + TlsHost, + TlsCallbacks, + TlsStreamHost, + TlsConfigurationProvider, +} from "./host-types.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/server.ts new file mode 100644 index 000000000..b2e2ed783 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/server.ts @@ -0,0 +1,206 @@ +import { EventEmitter } from "../internal/event-emitter.js"; +import { encode } from "./wire.js"; +import { hostCall, unsupported } from "./errors.js"; +import type { ServerOperation } from "./host-types.js"; +import type { + AddressInfo, + ListenOptions, + NodeTlsModule, + SecureContextLike, + SecureContextOptions, + ServerOptions, + SocketState, + TlsSocket, +} from "./types.js"; +import type { TlsRuntime, InternalTlsSocketConstructor } from "./runtime.js"; + +export function createTlsServerClass( + runtime: TlsRuntime, + Socket: InternalTlsSocketConstructor, +): NodeTlsModule["Server"] { + const { host, allocate, listeners, call, normalizeOptions, contextId, isSecureContext } = runtime; + class Server extends EventEmitter { + readonly #id = allocate(); + #address: AddressInfo | string | null = null; + listening = false; + #nextConnectionQuery = 1; + #closeCallbacks = new Map void>(); + #connectionQueries = new Map void>(); + + constructor( + optionsOrListener: ServerOptions | ((socket: TlsSocket) => void) = {}, + listener?: (socket: TlsSocket) => void, + ) { + super(); + const options = typeof optionsOrListener === "function" ? {} : optionsOrListener; + const connected = typeof optionsOrListener === "function" ? optionsOrListener : listener; + if (connected) { + this.on("secureConnection", connected); + } + listeners.set(this.#id, (name, value) => { + if (name === "close-complete") { + const { token, error } = value as { token: number; error?: Error }; + const callback = this.#closeCallbacks.get(token); + this.#closeCallbacks.delete(token); + callback?.(error); + } else if (name === "connections") { + const { token, error, count } = value as { token: number; error?: Error; count: number }; + const callback = this.#connectionQueries.get(token); + this.#connectionQueries.delete(token); + callback?.(error ?? null, count); + } else if (name === "secureConnection") { + const accepted = value as { id: number; state: SocketState }; + const socket = new Socket(undefined, options, accepted); + this.emit(name, socket); + } else if (name === "tlsClientError") { + const failure = value as { error: Error; socket: { id: number; state: SocketState } }; + this.emit(name, failure.error, new Socket(undefined, options, failure.socket)); + } else if (name === "listening") { + this.#address = value as AddressInfo; + this.listening = true; + this.emit(name); + } else if (name === "close") { + this.listening = false; + this.#address = null; + this.emit(name); + } else { + this.emit(name, value); + } + }); + try { + hostCall(() => host.createServer(this.#id, normalizeOptions(options))); + } catch (error) { + listeners.delete(this.#id); + throw error; + } + } + + #checkEvent(event: string): void { + if (["newSession", "resumeSession", "OCSPRequest", "keylog", "connection"].includes(event)) { + unsupported( + `Server event '${event}'`, + "this server callback is not supported across the component boundary", + ); + } + } + + override on(event: string, listener: (...args: never[]) => unknown): this { + this.#checkEvent(event); + return super.on(event, listener); + } + override addListener(event: string, listener: (...args: never[]) => unknown): this { + return this.on(event, listener); + } + override once(event: string, listener: (...args: never[]) => unknown): this { + this.#checkEvent(event); + return super.once(event, listener); + } + override prependListener(event: string, listener: (...args: never[]) => unknown): this { + this.#checkEvent(event); + return super.prependListener(event, listener); + } + override prependOnceListener(event: string, listener: (...args: never[]) => unknown): this { + this.#checkEvent(event); + return super.prependOnceListener(event, listener); + } + + #operation(operation: ServerOperation, ...args: unknown[]): T { + return call(() => host.serverOperation(this.#id, operation, encode(args))); + } + + listen(options: ListenOptions, callback?: () => void): this; + listen(port: number, host?: string | (() => void), callback?: () => void): this; + listen(path: string, callback?: () => void): this; + listen( + value: ListenOptions | number | string, + hostOrCallback?: string | (() => void), + callback?: () => void, + ): this { + const done = typeof hostOrCallback === "function" ? hostOrCallback : callback; + if (done) { + this.once("listening", done); + } + const options = + typeof value === "number" + ? { port: value, host: typeof hostOrCallback === "string" ? hostOrCallback : undefined } + : typeof value === "string" + ? { path: value } + : value; + this.#operation("listen", options); + return this; + } + address(): AddressInfo | string | null { + return this.#address; + } + close(callback?: (error?: Error) => void): this { + const token = this.#nextConnectionQuery++; + if (callback) { + this.#closeCallbacks.set(token, callback); + } + try { + this.#operation("close", token); + } catch (error) { + this.#closeCallbacks.delete(token); + throw error; + } + this.listening = false; + this.#address = null; + return this; + } + + get maxConnections(): number | undefined { + return this.#operation<{ maxConnections?: number }>("state").maxConnections; + } + set maxConnections(value: number | undefined) { + this.#operation("max-connections", value); + } + get dropMaxConnection(): boolean | undefined { + return this.#operation<{ dropMaxConnection?: boolean }>("state").dropMaxConnection; + } + set dropMaxConnection(value: boolean | undefined) { + this.#operation("drop-max-connection", value); + } + + getConnections(callback: (error: Error | null, count: number) => void): this { + const token = this.#nextConnectionQuery++; + this.#connectionQueries.set(token, callback); + try { + this.#operation("connections", token); + } catch (error) { + this.#connectionQueries.delete(token); + throw error; + } + return this; + } + + getTicketKeys(): Uint8Array { + return this.#operation("ticket-keys"); + } + setTicketKeys(keys: Uint8Array): void { + this.#operation("set-ticket-keys", keys); + } + setSecureContext(options: SecureContextOptions): void { + this.#operation("secure-context", options); + } + addContext(hostname: string, value: SecureContextLike | SecureContextOptions): void { + this.#operation("add-context", hostname, isSecureContext(value) ? contextId(value) : value); + } + ref(): this { + this.#operation("ref"); + return this; + } + unref(): this { + this.#operation("unref"); + return this; + } + async [Symbol.asyncDispose](): Promise { + if (this.listening) { + await new Promise((resolve, reject) => + this.close((error) => (error ? reject(error) : resolve())), + ); + } + } + } + + return Server; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/socket.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/socket.ts new file mode 100644 index 000000000..d0da137db --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/socket.ts @@ -0,0 +1,352 @@ +import { Buffer } from "node:buffer"; +import { Duplex } from "../stream/index.js"; +import type { Callback } from "../stream/types.js"; +import { encode } from "./wire.js"; +import { hostCall, unsupported } from "./errors.js"; +import type { SocketOperation } from "./host-types.js"; +import type { + AddressInfo, + CipherNameAndProtocol, + ConnectionOptions, + EphemeralKeyInfo, + PeerCertificate, + SecureContextLike, + SecureContextOptions, + SocketState, +} from "./types.js"; +import type { TlsRuntime, InternalTlsSocketConstructor } from "./runtime.js"; + +export function createTlsSocketClass(runtime: TlsRuntime): InternalTlsSocketConstructor { + const { host, allocate, listeners, call, normalizeOptions, contextId, isSecureContext } = runtime; + class TLSSocket extends Duplex { + readonly encrypted = true; + authorized = false; + authorizationError: Error | string | undefined; + alpnProtocol: string | false = false; + servername: string | false = false; + connecting = true; + pending = true; + readonly id: number; + #state: SocketState = {}; + #ready = false; + #started = false; + #abort: (() => void) | undefined; + #readRequested = false; + #token = 1; + #writes = new Map(); + #pendingWrite: (() => void) | undefined; + #options: ConnectionOptions; + + constructor( + socket?: unknown, + options: ConnectionOptions = {}, + accepted?: { id: number; state: SocketState }, + factory = false, + ) { + if (socket !== undefined || (!accepted && !factory)) { + unsupported( + "TLSSocket", + "use tls.connect(); wrapping arbitrary guest sockets is not supported", + ); + } + super({ + allowHalfOpen: options.allowHalfOpen ?? false, + highWaterMark: options.highWaterMark, + }); + this.id = accepted?.id ?? allocate(); + this.#options = options; + this.#started = accepted !== undefined; + if (options.signal) { + const signal = options.signal; + this.#abort = () => + this.destroy( + Object.assign(new Error("The operation was aborted", { cause: signal.reason }), { + name: "AbortError", + code: "ABORT_ERR", + }), + ); + signal.addEventListener("abort", this.#abort, { once: true }); + } + listeners.set(this.id, (name, value, data) => this.#dispatch(name, value, data)); + if (accepted) { + this.#setState(accepted.state); + this.#ready = true; + this.connecting = false; + this.pending = false; + } + } + + start(options: ConnectionOptions): this { + try { + const normalized = normalizeOptions(options); + if (this.#options.signal?.aborted) { + queueMicrotask(this.#abort!); + return this; + } + hostCall(() => host.connect(this.id, normalized)); + this.#started = true; + } catch (error) { + listeners.delete(this.id); + throw error; + } + return this; + } + + #operation(operation: SocketOperation, ...args: unknown[]): T { + return call(() => host.socketOperation(this.id, operation, encode(args))); + } + + #setState(state: SocketState): void { + this.#state = state; + this.authorized = state.authorized ?? false; + this.authorizationError = state.authorizationError; + this.alpnProtocol = state.alpnProtocol ?? false; + this.servername = state.servername ?? false; + } + + #dispatch(name: string, value: unknown, data: Uint8Array): void { + if (name === "write" || name === "renegotiate") { + const result = value as { token: number; error?: Error }; + const callback = this.#writes.get(result.token); + this.#writes.delete(result.token); + callback?.(result.error); + return; + } + if (name === "secureConnect") { + this.#setState(value as SocketState); + const check = this.#options.checkServerIdentity; + if (check && this.authorized) { + let error: Error | null | undefined; + try { + error = check( + this.#options.servername || this.#options.host || "localhost", + this.getPeerCertificate(), + ); + } catch (failure) { + error = failure as Error; + } + if (error) { + this.authorized = false; + this.authorizationError = (error as Error & { code?: string }).code || error.message; + if (this.#options.rejectUnauthorized !== false) { + this.destroy(error); + return; + } + } + } + this.connecting = false; + this.pending = false; + this.#ready = true; + this.emit("secureConnect"); + if (this.destroyed) { + return; + } + this.#pendingWrite?.(); + this.#pendingWrite = undefined; + if (this.#readRequested) { + this.#operation("resume"); + } + } else if (name === "data") { + if (this.push(Buffer.from(data))) { + this.#operation("resume"); + } + } else if (name === "end") { + this.push(null); + } else if (name === "error") { + this.destroy(value as Error); + } else if (name === "close") { + if (!this.destroyed) { + this.destroy(); + } + } else if (name === "session" || name === "keylog") { + this.emit(name, Buffer.from(data)); + } else if (name === "connect" || name === "timeout") { + this.emit(name); + } else { + this.emit(name, value); + } + } + + override _read(): void { + this.#readRequested = true; + if (this.#ready && !this.destroyed) { + this.#operation("resume"); + } + } + + override _write(chunk: unknown, _encoding: string, callback: Callback): void { + this.#sendWrite(chunk as Uint8Array, callback); + } + + #sendWrite(data: Uint8Array | undefined, callback: Callback): void { + const token = this.#token++; + this.#writes.set(token, callback); + const send = () => { + try { + hostCall(() => + data === undefined ? host.end(this.id, token) : host.write(this.id, token, data), + ); + } catch (error) { + this.#writes.delete(token); + callback(error as Error); + } + }; + if (this.#ready) { + send(); + } else { + this.#pendingWrite = send; + } + } + + override _final(callback: Callback): void { + this.#sendWrite(undefined, callback); + } + + override _destroy(error: Error | null, callback: Callback): void { + listeners.delete(this.id); + this.#pendingWrite = undefined; + if (this.#abort) { + this.#options.signal?.removeEventListener("abort", this.#abort); + } + if (this.#started) { + host.release(this.id); + } + for (const done of this.#writes.values()) { + done(error ?? new Error("TLS socket closed")); + } + this.#writes.clear(); + callback(error); + } + + get localAddress(): string | undefined { + return this.#state.localAddress; + } + get localPort(): number | undefined { + return this.#state.localPort; + } + get localFamily(): string | undefined { + return this.#state.localFamily; + } + get remoteAddress(): string | undefined { + return this.#state.remoteAddress; + } + get remotePort(): number | undefined { + return this.#state.remotePort; + } + get remoteFamily(): string | undefined { + return this.#state.remoteFamily; + } + get bytesRead(): number { + return this.destroyed + ? (this.#state.bytesRead ?? 0) + : (this.#operation("state").bytesRead ?? 0); + } + get bytesWritten(): number { + return this.destroyed + ? (this.#state.bytesWritten ?? 0) + : (this.#operation("state").bytesWritten ?? 0); + } + address(): AddressInfo | object { + return this.#operation("address"); + } + getCertificate(): PeerCertificate | object { + return this.#operation("certificate"); + } + getPeerCertificate(detailed = false): PeerCertificate { + return this.#operation("peer-certificate", detailed); + } + getCipher(): CipherNameAndProtocol { + return this.#operation("cipher"); + } + getEphemeralKeyInfo(): EphemeralKeyInfo | object | null { + return this.#operation("ephemeral-key-info"); + } + getFinished(): Uint8Array | undefined { + return this.#operation("finished"); + } + getPeerFinished(): Uint8Array | undefined { + return this.#operation("peer-finished"); + } + getProtocol(): string | null { + return this.#operation("protocol"); + } + setSession(session: Uint8Array | string): void { + this.#operation("set-session", session); + } + setServername(name: string): void { + this.#operation("set-servername", name); + } + getSession(): Uint8Array | undefined { + return this.#operation("session"); + } + getSharedSigalgs(): string[] { + return this.#operation("shared-sigalgs"); + } + getTLSTicket(): Uint8Array | undefined { + return this.#operation("tls-ticket"); + } + isSessionReused(): boolean { + return this.#operation("session-reused"); + } + exportKeyingMaterial(length: number, label: string, context?: Uint8Array): Uint8Array { + return this.#operation("export-keying-material", length, label, context); + } + disableRenegotiation(): void { + this.#operation("disable-renegotiation"); + } + enableTrace(): void { + this.#operation("enable-trace"); + } + setMaxSendFragment(size: number): boolean { + return this.#operation("max-send-fragment", size); + } + setKeyCert(value: SecureContextLike | SecureContextOptions): void { + this.#operation("key-cert", isSecureContext(value) ? contextId(value) : value); + } + getPeerX509Certificate(): never { + return unsupported( + "TLSSocket.getPeerX509Certificate", + "use getPeerCertificate(); native crypto.X509Certificate objects cannot cross WIT", + ); + } + getX509Certificate(): never { + return unsupported( + "TLSSocket.getX509Certificate", + "use getCertificate(); native crypto.X509Certificate objects cannot cross WIT", + ); + } + setTimeout(timeout: number, callback?: () => void): this { + if (callback) { + this.once("timeout", callback); + } + this.#operation("timeout", timeout); + return this; + } + setNoDelay(noDelay = true): this { + this.#operation("no-delay", noDelay); + return this; + } + setKeepAlive(enable = false, initialDelay = 0): this { + this.#operation("keep-alive", enable, initialDelay); + return this; + } + ref(): this { + this.#operation("ref"); + return this; + } + unref(): this { + this.#operation("unref"); + return this; + } + renegotiate( + options: { rejectUnauthorized?: boolean; requestCert?: boolean }, + callback: Callback, + ): boolean { + const token = this.#token++; + this.#writes.set(token, callback); + return this.#operation("renegotiate", options, token); + } + } + + return TLSSocket; +} diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index 38bf5c62c..e375419f4 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -13,6 +13,7 @@ "paths": { "whatwg-url": ["./src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts"], "punycode/punycode.js": ["./src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts"], + "jco:node/tls@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts"], "readable-stream/lib/stream.js": ["./src/wasi/0.2.x/node/24.x.x/stream/vendor-types.d.ts"], "jco:node/sqlite@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/sqlite-interface.d.ts"], "wasi:sockets/instance-network@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], From f23c28cac8bd1c7e276fe4e7967b8afc17f9e5d4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 14:07:06 +0000 Subject: [PATCH 04/13] feat(std): add native TLS provider and WASI bridge --- packages/jco-std/package.json | 8 + .../wasi/0.2.x/node/24.x.x/tls/host-node.ts | 521 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/tls/wasi.ts | 34 ++ .../test/wasi/0.2.x/node/24.x.x/tls/module.ts | 135 +++++ .../wasi/0.2.x/node/24.x.x/tls/transport.ts | 161 ++++++ 5 files changed, 859 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-node.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wasi.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/transport.ts diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 26713c536..4670319e4 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -508,6 +508,14 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.js", "default": "./dist/wasi/0.2.x/node/24.x.x/tls/node-host.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/node-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" + }, + "./wasi/0.2.x/node/24.x.x/tls/wasi": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls/wasi.d.ts", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls/wasi.js" } }, "scripts": { 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..0725b00d5 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/host-node.ts @@ -0,0 +1,521 @@ +import type { ConnectionOptions as GuestConnectionOptions, ServerOptions } from "./types.js"; +/** Opt-in native TLS provider. Every factory owns its handles, callbacks, and default CA policy. */ +import tls from "node:tls"; +import { X509Certificate } from "node:crypto"; +import type { Socket } from "node:net"; +import denied from "./node-host.js"; +import { createWasiTlsBridge } from "./wasi.js"; +import type { WasiTlsProvider } from "../http/impl/wasi-sockets/tls.js"; +import type { ConnectionOptions, TlsOptions } from "node:tls"; +import { encode, decode } from "./wire.js"; +import type { TlsCallbacks, TlsEvent, TlsHost, TlsConfigurationProvider } from "./host-types.js"; + +export interface NodeTlsProvider extends TlsHost, TlsConfigurationProvider { + attachCallbacks(callbacks: TlsCallbacks): void; + dispose(): void; + resourceCounts(): { sockets: number; servers: number; contexts: number }; +} + +// Keep configuration handles distinct across provider instances in this host realm. +let nextContext = 1; + +export function createTlsHost( + config: { wasiTls?: WasiTlsProvider; onCallbackError?: (error: unknown) => void } = {}, +): NodeTlsProvider { + const streamHost = config.wasiTls ? createWasiTlsBridge(config.wasiTls) : denied; + const onCallbackError = config.onCallbackError; + const sockets = new Map(); + const handshaking = new Set(); + const servers = new Map(); + const contexts = new Map(); + const configurations = new Map(); + let nextAccepted = 0x7fffffff; + let defaultCa: string[] | undefined; + let callbacks: TlsCallbacks | undefined; + const queue: TlsEvent[] = []; + let scheduled = false; + let disposed = false; + + function send(target: number, name: string, value?: unknown, data = new Uint8Array()): void { + if (disposed) { + return; + } + queue.push({ target, name, value: encode(value), data }); + schedule(); + } + + function schedule(): void { + if (scheduled || !callbacks || disposed) { + return; + } + scheduled = true; + setImmediate(async () => { + try { + while (queue.length && callbacks && !disposed) { + await callbacks.dispatch(queue.shift()!); + } + } catch (error) { + provider.dispose(); + // A guest trap cannot safely leave native listeners running. + onCallbackError?.(error); + } finally { + scheduled = false; + if (queue.length) { + schedule(); + } + } + }); + } + + function invoke(operation: () => T): T { + try { + if (disposed) { + throw Object.assign(new Error("TLS provider is disposed"), { + code: "ERR_JCO_TLS_PROVIDER_CLOSED", + }); + } + return operation(); + } catch (error) { + const value = error as Error & { code?: string }; + throw { + name: value.name ?? "Error", + message: value.message ?? String(error), + code: value.code, + }; + } + } + + function socket(id: number): tls.TLSSocket { + const value = sockets.get(id); + if (!value) { + throw Object.assign(new Error("TLS socket is closed or unknown"), { + code: "ERR_SOCKET_CLOSED", + }); + } + return value; + } + + function context(id: number): tls.SecureContext { + const value = contexts.get(id); + if (!value) { + throw new TypeError("SecureContext belongs to another TLS provider or has been released"); + } + return value; + } + + function options(text: string): ConnectionOptions & TlsOptions { + const value = decode(text) as ConnectionOptions & + TlsOptions & { contextId?: number; customIdentity?: boolean }; + if (value.contextId !== undefined) { + value.secureContext = context(value.contextId); + } + if (!value.ca && defaultCa !== undefined) { + value.ca = defaultCa; + } + // Trust-chain validation stays native. The guest checks its custom hostname/pinning policy + // before the host is allowed to resume application data. + if (value.customIdentity) { + value.checkServerIdentity = () => undefined; + } + return value; + } + + function state(value: tls.TLSSocket): object { + return { + authorized: value.authorized, + authorizationError: value.authorizationError, + alpnProtocol: value.alpnProtocol, + servername: value.servername, + localAddress: value.localAddress, + localPort: value.localPort, + localFamily: value.localFamily, + remoteAddress: value.remoteAddress, + remotePort: value.remotePort, + remoteFamily: value.remoteFamily, + bytesRead: value.bytesRead, + bytesWritten: value.bytesWritten, + connecting: value.connecting, + pending: value.pending, + }; + } + + function track(id: number, value: tls.TLSSocket): void { + sockets.set(id, value); + value.on("data", (data: Buffer) => { + value.pause(); + send(id, "data", undefined, new Uint8Array(data)); + }); + value.pause(); + value.on("end", () => send(id, "end")); + value.on("error", (error) => send(id, "error", error)); + value.on("close", (hadError) => send(id, "close", hadError)); + value.on("timeout", () => send(id, "timeout")); + value.on("session", (data: Buffer) => send(id, "session", undefined, new Uint8Array(data))); + value.on("keylog", (data: Buffer) => send(id, "keylog", undefined, new Uint8Array(data))); + value.on("OCSPResponse", (data: Buffer | null) => send(id, "OCSPResponse", data)); + value.on("secureConnect", () => send(id, "secureConnect", state(value))); + value.on("connect", () => send(id, "connect")); + } + + const provider: NodeTlsProvider = { + isAvailable: streamHost.isAvailable, + startTls: streamHost.startTls, + attachCallbacks(value) { + callbacks = value; + schedule(); + }, + query(operation, text) { + return invoke(() => { + const args = decode(text) as unknown[]; + switch (operation) { + case "ciphers": + return encode(tls.getCiphers()); + case "compression-algorithms": + return encode( + ( + tls as typeof tls & { getCertificateCompressionAlgorithms(): string[] } + ).getCertificateCompressionAlgorithms(), + ); + case "ca-certificates": + return encode( + args[0] === "default" && defaultCa + ? defaultCa + : tls.getCACertificates(args[0] as "default"), + ); + case "check-identity": + return encode( + tls.checkServerIdentity(args[0] as string, args[1] as tls.PeerCertificate), + ); + } + }); + }, + setDefaultCa(text) { + invoke(() => { + const certs = decode(text); + if (!Array.isArray(certs)) { + throw Object.assign(new TypeError("certs must be an array"), { + code: "ERR_INVALID_ARG_TYPE", + }); + } + const ca = certs.map((cert: unknown) => { + if (typeof cert === "string") { + return cert; + } + if (ArrayBuffer.isView(cert)) { + return Buffer.from(cert.buffer, cert.byteOffset, cert.byteLength).toString(); + } + throw Object.assign(new TypeError("certs entries must be strings or ArrayBuffer views"), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + const valid = new Set(); + for (const text of ca) { + for (const pem of text.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g, + ) ?? []) { + try { + valid.add(new X509Certificate(pem).toString()); + } catch { + /* Node's trust store ignores invalid individual certificates. */ + } + } + } + if (ca.length && !valid.size) { + throw Object.assign(new Error("No valid certificates found in the provided array"), { + code: "ERR_CRYPTO_OPERATION_FAILED", + }); + } + defaultCa = [...valid]; + }); + }, + createContext(text) { + return invoke(() => { + const config = options(text); + const value = tls.createSecureContext(config); + if (nextContext > 0x7fffffff) { + throw new Error("TLS context handle space exhausted"); + } + const id = nextContext++; + contexts.set(id, value); + configurations.set(id, config); + return id; + }); + }, + releaseContext(id) { + contexts.delete(id); + configurations.delete(id); + }, + takeContextOptions(id) { + context(id); + const config = configurations.get(id)!; + provider.releaseContext(id); + return config as GuestConnectionOptions & ServerOptions; + }, + connect(id, text) { + invoke(() => { + if (sockets.has(id)) { + throw new Error("TLS socket id already exists"); + } + const value = tls.connect(Object.assign(options(text), { allowHalfOpen: true })); + track(id, value); + }); + }, + createServer(id, text) { + invoke(() => { + if (servers.has(id)) { + throw new Error("TLS server id already exists"); + } + const value = tls.createServer({ ...options(text), allowHalfOpen: true }); + servers.set(id, value); + value.on("connection", (raw) => { + handshaking.add(raw); + raw.once("close", () => handshaking.delete(raw)); + }); + value.on("secureConnection", (accepted) => { + const socketId = nextAccepted--; + track(socketId, accepted); + send(id, "secureConnection", { id: socketId, state: state(accepted) }); + }); + value.on("tlsClientError", (error, failed) => { + const socketId = nextAccepted--; + track(socketId, failed); + send(id, "tlsClientError", { error, socket: { id: socketId, state: state(failed) } }); + }); + value.on("error", (error) => send(id, "error", error)); + value.on("close", () => send(id, "close")); + value.on("listening", () => send(id, "listening", value.address())); + value.on("drop", (info) => send(id, "drop", info)); + }); + }, + socketOperation(id, operation, text) { + return invoke(() => { + const value = socket(id); + const args = decode(text) as unknown[]; + let result: unknown; + switch (operation) { + case "state": + result = state(value); + break; + case "address": + result = value.address(); + break; + case "certificate": + result = value.getCertificate(); + break; + case "peer-certificate": + result = value.getPeerCertificate(args[0] as true); + break; + case "cipher": + result = value.getCipher(); + break; + case "ephemeral-key-info": + result = value.getEphemeralKeyInfo(); + break; + case "finished": + result = value.getFinished(); + break; + case "peer-finished": + result = value.getPeerFinished(); + break; + case "protocol": + result = value.getProtocol(); + break; + case "set-session": + (value as tls.TLSSocket & { setSession(session: Buffer | string): void }).setSession( + args[0] as Buffer | string, + ); + break; + case "set-servername": + (value as tls.TLSSocket & { setServername(name: string): void }).setServername( + args[0] as string, + ); + break; + case "session": + result = value.getSession(); + break; + case "shared-sigalgs": + result = value.getSharedSigalgs(); + break; + case "tls-ticket": + result = value.getTLSTicket(); + break; + case "session-reused": + result = value.isSessionReused(); + break; + case "export-keying-material": + result = value.exportKeyingMaterial( + args[0] as number, + args[1] as string, + args[2] as Buffer, + ); + break; + case "disable-renegotiation": + value.disableRenegotiation(); + break; + case "enable-trace": + value.enableTrace(); + break; + case "max-send-fragment": + result = value.setMaxSendFragment(args[0] as number); + break; + case "key-cert": + value.setKeyCert( + typeof args[0] === "number" ? context(args[0]) : options(encode(args[0])), + ); + break; + case "timeout": + value.setTimeout(args[0] as number); + break; + case "no-delay": + value.setNoDelay(args[0] as boolean); + break; + case "keep-alive": + value.setKeepAlive(args[0] as boolean, args[1] as number); + break; + case "ref": + value.ref(); + break; + case "unref": + value.unref(); + break; + case "resume": + value.resume(); + break; + case "pause": + value.pause(); + break; + case "destroy": + value.destroy(); + break; + case "renegotiate": + result = value.renegotiate(args[0] as tls.TlsOptions, (error) => + send(id, "renegotiate", { token: args[1], error }), + ); + break; + } + return encode(result); + }); + }, + serverOperation(id, operation, text) { + return invoke(() => { + const value = servers.get(id); + if (!value) { + throw new Error("TLS server is closed or unknown"); + } + const args = decode(text) as unknown[]; + let result: unknown; + switch (operation) { + case "state": + result = { + listening: value.listening, + maxConnections: value.maxConnections, + dropMaxConnection: Reflect.get(value, "dropMaxConnection"), + }; + break; + case "address": + result = value.address(); + break; + case "listen": + value.listen(args[0] as { port: number; host?: string }); + break; + case "close": + value.close((error) => send(id, "close-complete", { token: args[0], error })); + break; + case "connections": + value.getConnections((error, count) => + send(id, "connections", { token: args[0], error, count }), + ); + break; + case "ref": + value.ref(); + break; + case "unref": + value.unref(); + break; + case "ticket-keys": + result = value.getTicketKeys(); + break; + case "max-connections": + Reflect.set(value, "maxConnections", args[0]); + break; + case "drop-max-connection": + Reflect.set(value, "dropMaxConnection", args[0]); + break; + case "set-ticket-keys": + value.setTicketKeys(args[0] as Buffer); + break; + case "secure-context": + value.setSecureContext(options(encode(args[0]))); + break; + case "add-context": + value.addContext( + args[0] as string, + typeof args[1] === "number" ? context(args[1]) : options(encode(args[1])), + ); + break; + } + return encode(result); + }); + }, + write(id, token, data) { + invoke(() => { + socket(id).write(data, (error) => send(id, "write", { token, error })); + }); + }, + end(id, token) { + invoke(() => { + socket(id).end(() => send(id, "write", { token })); + }); + }, + release(id) { + const value = sockets.get(id); + if (value) { + value.destroy(); + sockets.delete(id); + } + }, + resourceCounts() { + return { sockets: sockets.size, servers: servers.size, contexts: contexts.size }; + }, + dispose() { + disposed = true; + queue.length = 0; + for (const socket of sockets.values()) { + socket.destroy(); + } + for (const socket of handshaking) { + socket.destroy(); + } + handshaking.clear(); + for (const server of servers.values()) { + server.close(); + } + sockets.clear(); + servers.clear(); + contexts.clear(); + configurations.clear(); + callbacks = undefined; + }, + }; + return provider; +} + +const provider = createTlsHost(); +export const { + isAvailable, + startTls, + query, + setDefaultCa, + createContext, + releaseContext, + connect, + createServer, + socketOperation, + serverOperation, + write, + end, + release, + attachCallbacks, + dispose, +} = provider; +export default provider; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wasi.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wasi.ts new file mode 100644 index 000000000..e412b0190 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/wasi.ts @@ -0,0 +1,34 @@ +/** Adapt a WASI TLS provider to the primary jco:node/tls stream capability. */ +import denied from "./node-host.js"; +import { dispose } from "../internal/wasi-sockets.js"; +import type { TlsHost } from "./host-types.js"; +import type { WasiTlsHandshake, WasiTlsProvider } from "../http/impl/wasi-sockets/tls.js"; + +/** + * WASI TLS currently supplies client upgrades only. Other Node TLS operations + * retain the explicit denial from the base provider. Pass the result directly + * as the component's jco:node/tls import. + */ +export function createWasiTlsBridge(provider: WasiTlsProvider): TlsHost { + return { + ...denied, + isAvailable: () => provider.isAvailable(), + startTls(serverName, input, output) { + let pending: WasiTlsHandshake | undefined; + try { + pending = new provider.ClientHandshake(serverName, input, output); + } catch (error) { + dispose(output); + dispose(input); + throw error; + } + try { + const future = provider.ClientHandshake.finish(pending); + pending = undefined; + return future; + } finally { + dispose(pending); + } + }, + }; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/module.ts new file mode 100644 index 000000000..7f170f88e --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/module.ts @@ -0,0 +1,135 @@ +import native from "node:tls"; +import { expect, test } from "vitest"; +import { createTls } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/core.js"; +import denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/node-host.js"; +import { createTlsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/host-node.js"; +import { encode } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/wire.js"; + +test.concurrent("exports the pinned Node TLS module surface without granting capabilities", () => { + const { api } = createTls(denied); + // Capture the pinned release; newer host majors may add exports of their own. + expect(Object.keys(api).sort()).toEqual([ + "CLIENT_RENEG_LIMIT", + "CLIENT_RENEG_WINDOW", + "DEFAULT_CIPHERS", + "DEFAULT_ECDH_CURVE", + "DEFAULT_MAX_VERSION", + "DEFAULT_MIN_VERSION", + "SecureContext", + "Server", + "TLSSocket", + "checkServerIdentity", + "connect", + "convertALPNProtocols", + "createSecureContext", + "createServer", + "getCACertificates", + "getCertificateCompressionAlgorithms", + "getCiphers", + "rootCertificates", + "setDefaultCACertificates", + ]); + if (process.versions.node === "24.20.0") { + expect(Object.keys(api).sort()).toEqual(Object.keys(native).sort()); + } + expect(() => api.connect(443)).toThrow( + expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }), + ); + expect(() => api.createServer()).toThrow( + expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }), + ); + expect(() => api.createSecureContext()).toThrow( + expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }), + ); +}); + +test.concurrent.each([ + [], + ["h2", "http/1.1"], + ["é"], + Buffer.from([2, 104, 50]), + new DataView(new Uint8Array([9, 1, 97, 9]).buffer, 1, 2), + undefined, +])("ALPN conversion matches Node for %j", (input) => { + const actual = {}; + const expected = {}; + createTls(denied).api.convertALPNProtocols(input, actual); + Reflect.get(native, "convertALPNProtocols")(input, expected); + expect(actual).toEqual(expected); +}); + +test.concurrent("ALPN checks encoded byte length and copies caller-owned bytes", () => { + const { api } = createTls(denied); + expect(() => api.convertALPNProtocols(["é".repeat(128)], {})).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + const input = Buffer.from([1, 97]); + const output: { ALPNProtocols?: Uint8Array } = {}; + api.convertALPNProtocols(input, output); + input.fill(0); + expect(output.ALPNProtocols).toEqual(Buffer.from([1, 97])); +}); + +test.concurrent("native queries match Node and providers keep CA policy isolated", () => { + const first = createTlsHost(); + const second = createTlsHost(); + try { + const { api } = createTls(first); + expect(api.getCiphers()).toEqual(native.getCiphers()); + expect([...api.rootCertificates]).toEqual(native.rootCertificates); + expect(Object.isFrozen(api.rootCertificates)).toBe(true); + expect(api.getCACertificates("bundled")).toBe(api.rootCertificates); + expect(api.getCACertificates("bundled")).toEqual(native.getCACertificates("bundled")); + const original = native.getCACertificates(); + api.setDefaultCACertificates([]); + expect(api.getCACertificates()).toEqual([]); + for (const ca of [undefined, null, ""]) { + const context = first.createContext(encode({ ca })); + expect(first.takeContextOptions(context).ca).toEqual([]); + } + expect(createTls(second).api.getCACertificates()).toEqual(original); + expect(native.getCACertificates()).toEqual(original); + expect(() => api.setDefaultCACertificates(["not a certificate"])).toThrow( + expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" }), + ); + expect(api.getCACertificates()).toEqual([]); + } finally { + first.dispose(); + second.dispose(); + } +}); + +test.concurrent("an already-aborted connection never acquires a host socket", async () => { + const controller = new AbortController(); + controller.abort("cancelled"); + const { api } = createTls(denied); + const socket = api.connect({ port: 443, signal: controller.signal }); + const error = await new Promise((resolve) => socket.once("error", resolve)); + expect(error).toMatchObject({ name: "AbortError", code: "ABORT_ERR", cause: "cancelled" }); + expect(socket.destroyed).toBe(true); +}); + +test.concurrent("HTTP configuration handles are one-use and cannot cross provider instances", () => { + const first = createTlsHost(); + const second = createTlsHost(); + try { + const id = first.createContext(encode({ rejectUnauthorized: false })); + second.createContext(encode({})); + expect(() => second.takeContextOptions(id)).toThrow(/another TLS provider/); + expect(first.takeContextOptions(id).rejectUnauthorized).toBe(false); + expect(() => first.takeContextOptions(id)).toThrow(/released/); + expect(first.resourceCounts().contexts).toBe(0); + } finally { + first.dispose(); + second.dispose(); + } +}); + +test.concurrent.each(["pskCallback", "lookup", "socket", "SNICallback", "ALPNCallback"])( + "rejects unsupported callback/native option %s before accessing a host", + (name) => { + expect(() => createTls(denied).api.connect({ port: 443, [name]: () => {} })).toThrow( + /not supported|unsupported|cannot cross/, + ); + }, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/transport.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/transport.ts new file mode 100644 index 000000000..d76260d1c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/tls/transport.ts @@ -0,0 +1,161 @@ +import native from "node:tls"; +import { readFile } from "node:fs/promises"; +import { expect, test } from "vitest"; +import { createTls } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/core.js"; +import { createTlsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/host-node.js"; +import type { TlsSocket } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/types.js"; + +const [key, cert] = await Promise.all( + ["key", "crt"].map((ext) => + readFile(new URL(`../https/helpers/tls/localhost.${ext}`, import.meta.url)), + ), +); + +function setup() { + const host = createTlsHost(); + const { api, tlsCallbacks } = createTls(host); + host.attachCallbacks(tlsCallbacks); + return { api, host }; +} + +test.concurrent("server close callbacks complete once, including an unstarted listener", async () => { + const { api, host } = setup(); + try { + const server = api.createServer({ key, cert }); + server.maxConnections = 5; + expect(server.maxConnections).toBe(5); + let count = 0; + const error = await new Promise((resolve) => + server.close((error) => { + count++; + resolve(error); + }), + ); + expect(error).toMatchObject({ code: "ERR_SERVER_NOT_RUNNING" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(count).toBe(1); + expect(server.listening).toBe(false); + } finally { + host.dispose(); + } +}); + +test.concurrent("explicit legacy protocol selection and unsupported server callbacks are deliberate", () => { + const { api, host } = setup(); + try { + expect(() => api.createSecureContext({ secureProtocol: "TLSv1_2_method" })).not.toThrow(); + const server = api.createServer({ key, cert }); + for (const name of ["newSession", "resumeSession", "OCSPRequest", "keylog", "connection"]) { + expect(() => server.on(name, () => {})).toThrow(/not supported/); + } + expect(() => { + api.CLIENT_RENEG_LIMIT = 100; + }).toThrow(/host policy/); + expect(() => new api.TLSSocket()).toThrow(/use tls.connect/); + } finally { + host.dispose(); + } +}); + +test.concurrent("documentation echo with mutual authentication and stream backpressure", async () => { + const { api, host } = setup(); + const payload = Buffer.alloc(256 * 1024, 37); + const server = api.createServer( + { key, cert, ca: [cert], requestCert: true, highWaterMark: 1024, ALPNProtocols: ["echo"] }, + (socket) => { + socket.on("error", () => {}); + expect(socket.authorized).toBe(true); + socket.pipe(socket); + }, + ); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP listener"); + } + const client = api.connect(address.port, "127.0.0.1", { + key, + cert, + ca: [cert], + servername: "localhost", + ALPNProtocols: ["echo"], + highWaterMark: 1024, + }); + const received: Buffer[] = []; + const ended = new Promise((resolve, reject) => { + client.on("end", resolve); + client.on("error", reject); + }); + client.on("data", (chunk: Buffer) => received.push(chunk)); + await new Promise((resolve, reject) => { + client.once("secureConnect", resolve); + client.once("error", reject); + }); + expect(client.alpnProtocol).toBe("echo"); + expect(client.getProtocol()).toMatch(/^TLSv1\.[23]$/); + expect(client.getPeerCertificate(true).issuerCertificate).toMatchObject({ + subject: { CN: "localhost" }, + }); + expect(Buffer.isBuffer(client.exportKeyingMaterial(32, "fixture"))).toBe(true); + expect(client.write(payload)).toBe(false); + client.end(); + await ended; + expect(Buffer.concat(received)).toEqual(payload); + client.destroy(); + await new Promise((resolve) => server.close(resolve)); + } finally { + host.dispose(); + } + expect(host.resourceCounts()).toEqual({ sockets: 0, servers: 0, contexts: 0 }); +}, 10_000); + +test.concurrent.each(["untrusted", "hostname", "custom", "permissive", "empty-end"])( + "client verification and termination: %s", + async (mode) => { + const peer = native.createServer({ key, cert }, (socket) => socket.end("ok")); + peer.on("tlsClientError", () => {}); + await new Promise((resolve) => peer.listen(0, "127.0.0.1", resolve)); + const address = peer.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP listener"); + } + const { api, host } = setup(); + let client: TlsSocket | undefined; + try { + client = api.connect({ + port: address.port, + host: "127.0.0.1", + servername: mode === "hostname" ? "wrong.example" : "localhost", + ca: mode === "untrusted" ? undefined : [cert], + rejectUnauthorized: mode === "permissive" ? false : true, + checkServerIdentity: + mode === "custom" + ? () => Object.assign(new Error("pin rejected"), { code: "ERR_TEST_PIN" }) + : undefined, + }); + const outcome = new Promise((resolve, reject) => { + client!.once("error", (error: Error & { code: string }) => resolve(error.code)); + client!.once("secureConnect", () => resolve("connected")); + setTimeout(() => reject(new Error("handshake timeout")), 5000).unref(); + }); + if (mode === "empty-end") { + client.end(); + } + expect(await outcome).toBe( + mode === "untrusted" + ? "DEPTH_ZERO_SELF_SIGNED_CERT" + : mode === "hostname" + ? "ERR_TLS_CERT_ALTNAME_INVALID" + : mode === "custom" + ? "ERR_TEST_PIN" + : "connected", + ); + } finally { + client?.destroy(); + host.dispose(); + await new Promise((resolve) => peer.close(() => resolve())); + } + }, + 10_000, +); From 84a2e4765065b86af2a5d1fc8ff9207f3807205b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 05/13] feat(std): route Node TLS and HTTP through shared capability --- .../wasi/0.2.x/node/24.x.x/http-host-node.ts | 84 +++++------ .../src/wasi/0.2.x/node/24.x.x/http.ts | 3 +- .../0.2.x/node/24.x.x/http/guest-callbacks.ts | 3 + .../0.2.x/node/24.x.x/http/impl/direct.ts | 61 +++++++- .../0.2.x/node/24.x.x/http/impl/wasi-http.ts | 6 +- .../24.x.x/http/impl/wasi-sockets/index.ts | 5 +- .../node/24.x.x/http/impl/wasi-sockets/tls.ts | 20 ++- .../src/wasi/0.2.x/node/24.x.x/http/types.ts | 4 +- .../wasi/0.2.x/node/24.x.x/http2-host-node.ts | 47 ++++-- .../src/wasi/0.2.x/node/24.x.x/http2.ts | 3 +- .../node/24.x.x/http2/impl/direct/client.ts | 34 ++++- .../node/24.x.x/http2/impl/direct/index.ts | 10 +- .../node/24.x.x/http2/impl/direct/server.ts | 40 +++-- .../src/wasi/0.2.x/node/24.x.x/http2/types.ts | 7 +- .../src/wasi/0.2.x/node/24.x.x/https.ts | 6 +- .../0.2.x/node/24.x.x/http2/default-deny.ts | 8 +- .../wasi/0.2.x/node/24.x.x/http2/direct.ts | 14 +- .../test/wasi/0.2.x/node/24.x.x/http2/host.ts | 29 +++- .../test/wasi/0.2.x/node/24.x.x/https/host.ts | 37 ++++- .../wasi/0.2.x/node/24.x.x/https/module.ts | 4 +- .../wasi/0.2.x/node/24.x.x/https/wasi-http.ts | 10 +- .../wasi/0.2.x/node/24.x.x/https/wasi-tls.ts | 141 ++++++++++-------- packages/jco-std/wit/node-0.1.0/http.wit | 29 +--- packages/jco-std/wit/node-0.1.0/http2.wit | 8 +- 24 files changed, 383 insertions(+), 230 deletions(-) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/guest-callbacks.ts 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 fce71f1e4..96a6cdd54 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 @@ -1,3 +1,4 @@ +import type { TlsConfigurationProvider } from "./tls/host-types.js"; /** * Opt-in Node.js HTTP provider. * @@ -8,7 +9,6 @@ * scheme and servers carrying a `tls` record go through real `node:https`, so * TLS is terminated by the host's own stack. */ -import { Buffer } from "node:buffer"; import * as nodeHttp from "node:http"; import { CallbackResource, @@ -40,49 +40,21 @@ import type { 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]; - } +/** HTTP receives a one-use configuration handle; TLS policy belongs to jco:node/tls. */ +function nodeTlsOptions( + options: DirectTlsOptions | undefined, + tls: TlsConfigurationProvider | undefined, +): nodeTls.ConnectionOptions & nodeTls.TlsOptions { + if (!tls || options === undefined) { + throw Object.assign( + new Error( + "HTTPS requires the same jco:node/tls provider passed to createHttpHost(callbacks, tls)", + ), + { code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }, + ); } - return options; + return tls.takeContextOptions(options.contextId) as nodeTls.ConnectionOptions & + nodeTls.TlsOptions; } function timeoutError(syscall: string): Error & { code: string; syscall: string } { @@ -92,7 +64,10 @@ function timeoutError(syscall: string): Error & { code: string; syscall: string }); } -export async function request(options: DirectHttpRequest): AsyncResult { +export async function request( + options: DirectHttpRequest, + tls?: TlsConfigurationProvider, +): AsyncResult { return new Promise((resolve) => { let connectTimer: Timer | undefined; let firstByteTimer: Timer | undefined; @@ -110,7 +85,7 @@ export async function request(options: DirectHttpRequest): AsyncResult { clearTimeout(connectTimer); @@ -203,6 +178,7 @@ class NodeHttpServer { constructor( options: DirectHttpServerOptions, handle: (request: DirectHttpIncomingRequest) => Promise, + tls?: TlsConfigurationProvider, ) { // A TLS record, including an empty one, selects a native HTTPS server. const create = @@ -211,7 +187,7 @@ class NodeHttpServer { nodeHttp.createServer(nodeServerOptions(options), handler) : (handler: nodeHttp.RequestListener) => nodeHttps.createServer( - { ...nodeServerOptions(options), ...nodeTlsOptions(options.tls!) }, + { ...nodeServerOptions(options), ...nodeTlsOptions(options.tls!, tls) }, handler, ); this.#server = create((request, response) => { @@ -380,7 +356,10 @@ class NodeHttpServer { } /** Bind one host provider to one component's exported callback resources. */ -export function createHttpHost(callbacks: () => DirectHttpCallbacks) { +export function createHttpHost( + callbacks: () => DirectHttpCallbacks, + tls?: TlsConfigurationProvider, +) { const enqueue = createCallbackQueue(); class Server extends NodeHttpServer { readonly #listener: CallbackResource; @@ -390,7 +369,11 @@ export function createHttpHost(callbacks: () => DirectHttpCallbacks) { () => callbacks().takeRequestListener(listener), "ERR_JCO_HTTP_CALLBACK_NOT_FOUND", ); - super(options, (incoming) => enqueue(async () => (await resource.get()).handle(incoming))); + super( + options, + (incoming) => enqueue(async () => (await resource.get()).handle(incoming)), + tls, + ); this.#listener = resource; } @@ -408,7 +391,10 @@ export function createHttpHost(callbacks: () => DirectHttpCallbacks) { void this.close(); } } - return { request, Server: Server as unknown as DirectHttpServerConstructor }; + return { + request: (options: DirectHttpRequest) => request(options, tls), + Server: Server as unknown as DirectHttpServerConstructor, + }; } // Client-only mappings may still import this module directly. Servers require diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts index c8261c1e5..ebb99a907 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http.ts @@ -1,9 +1,10 @@ +import { httpGuestCallbacks } from "./http/guest-callbacks.js"; import * as host from "jco:node/http@0.1.0"; import { createHttp } from "./http/core.js"; import { createDirectHttpImplementation } from "./http/impl/direct.js"; -const implementation = createDirectHttpImplementation(host); +const implementation = createDirectHttpImplementation(host, undefined, httpGuestCallbacks); const http = createHttp(implementation); export const httpCallbacks = implementation.httpCallbacks; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/guest-callbacks.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/guest-callbacks.ts new file mode 100644 index 000000000..1f007b5b3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/guest-callbacks.ts @@ -0,0 +1,3 @@ +/** HTTP and HTTPS share callbacks inside one bundled component instance. */ +import { createHttpCallbackRegistry } from "./impl/direct.js"; +export const httpGuestCallbacks = createHttpCallbackRegistry(); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts index 90f6cf9ad..41ced7742 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/direct.ts @@ -1,3 +1,7 @@ +import type { TlsHost } from "../../tls/host-types.js"; +import deniedTls from "../../tls/node-host.js"; +import { encode } from "../../tls/wire.js"; +import { hostCall } from "../../tls/errors.js"; import type { HostImports } from "../../internal/wit-types.js"; import { callHost } from "../../internal/host-error.js"; import { serializeNodeError } from "../../internal/http-host.js"; @@ -14,6 +18,12 @@ import type { HttpServerAddress, } from "../types.js"; +export function createHttpCallbackRegistry() { + const listeners = new Map(); + let next = 1; + return { listeners, allocate: () => next++ }; +} + function directAddress(address: DirectHttpServerAddress | undefined): HttpServerAddress | null { return address === undefined ? null @@ -40,10 +50,13 @@ class RequestListener implements DirectHttpRequestListener { [Symbol.dispose](): void {} } -export function createDirectHttpImplementation(host: HostImports) { +export function createDirectHttpImplementation( + host: HostImports, + tls: TlsHost = deniedTls, + registry = createHttpCallbackRegistry(), +) { // Each implementation (and bundled guest instance) owns its registrations. - const listeners = new Map(); - let nextListener = 1; + const { listeners } = registry; return { httpCallbacks: { RequestListener, @@ -55,18 +68,52 @@ export function createDirectHttpImplementation(host: HostImports }, request(options: Parameters[0]) { - return callHost(() => host.request(options), fromImplementationError); + if (options.scheme !== "https") { + return callHost( + () => host.request({ ...options, tls: undefined }), + fromImplementationError, + ); + } + const { alpnProtocols, ...material } = options.tls ?? {}; + const contextId = hostCall(() => + tls.createContext(encode({ ...material, ALPNProtocols: alpnProtocols })), + ); + try { + return callHost( + () => host.request({ ...options, tls: { contextId } }), + fromImplementationError, + ); + } finally { + tls.releaseContext(contextId); + } }, createServer(options: HttpServerOptions, handler: HttpRequestHandler) { - if (nextListener > 0xffff_ffff) { + const listener = registry.allocate(); + if (listener > 0x7fff_ffff) { throw codedError( new Error("HTTP callback registrations exhausted"), "ERR_JCO_HTTP_CALLBACK_LIMIT", ); } - const listener = nextListener++; - const server = new host.Server(options, listener); + const { alpnProtocols, ...material } = options.tls ?? {}; + const contextId = + options.tls === undefined + ? undefined + : hostCall(() => + tls.createContext(encode({ ...material, ALPNProtocols: alpnProtocols })), + ); + let server: InstanceType; + try { + server = new host.Server( + { ...options, tls: contextId === undefined ? undefined : { contextId } }, + listener, + ); + } finally { + if (contextId !== undefined) { + tls.releaseContext(contextId); + } + } return { listen(listenOptions: HttpListenOptions) { listeners.set(listener, new RequestListener(handler)); 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 4c993d6a9..97a57d7c3 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 @@ -240,10 +240,10 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm "wasi:http outgoing-handler cannot accept arbitrary inbound HTTP connections", request(request) { - if (request.tls !== undefined) { + if (request.scheme === "https" || 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", + `${request.scheme}.request with the wasi-http implementation`, + "wasi:http/outgoing-handler cannot use the jco:node/tls capability; select direct or wasi-sockets for HTTPS", ); } try { 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 543fe7fa0..9438a4068 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 @@ -1,3 +1,4 @@ +import type { TlsStreamHost } from "../../../tls/host-types.js"; import { handshake, validateTlsOptions, @@ -64,7 +65,7 @@ export type { } from "../../../internal/wasi-sockets.js"; export interface WasiSocketsProvider extends WasiTcpProvider { - tls?: WasiTlsProvider; + tls?: WasiTlsProvider | TlsStreamHost; } export function authority(value: string, scheme = "http"): { hostname: string; port: number } { @@ -342,7 +343,7 @@ export function createWasiSocketsHttpImplementation( name: "Error", code: "ERR_JCO_TLS_ADAPTER_REQUIRED", message: - "https: requests with the wasi-sockets implementation require the additional wasi:tls/types@0.2.0-draft TLS capability", + "https: requests with the wasi-sockets implementation require the jco:node/tls@0.1.0 TLS capability", }); } } else if (request.scheme !== "http") { 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 e90a5fd96..e9a951c69 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,3 +1,4 @@ +import type { TlsStreamHost } from "../../../tls/host-types.js"; /** * Guest contract for WebAssembly/wasi-tls wit/types.wit, revision * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). @@ -67,7 +68,7 @@ export function validateTlsOptions(options: HttpTlsMaterial | undefined): void { /** Takes ownership of input/output, including on handshake failure. */ export function handshake( - provider: WasiTlsProvider, + provider: WasiTlsProvider | TlsStreamHost, serverName: string, input: WasiInputStream, output: WasiOutputStream, @@ -77,11 +78,18 @@ export function handshake( let pending: WasiTlsHandshake | undefined; let future: WasiTlsFuture | undefined; try { - pending = new provider.ClientHandshake(serverName, ownedInput, ownedOutput); - ownedInput = undefined; - ownedOutput = undefined; - future = provider.ClientHandshake.finish(pending); - pending = undefined; + if ("startTls" in provider) { + // WIT transfers ownership at the call, including when the provider throws. + ownedInput = undefined; + ownedOutput = undefined; + future = provider.startTls(serverName, input, output); + } else { + pending = new provider.ClientHandshake(serverName, ownedInput, ownedOutput); + ownedInput = undefined; + ownedOutput = undefined; + future = provider.ClientHandshake.finish(pending); + pending = undefined; + } for (;;) { const result = future.get(); if (result) { 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 3fed1ee30..9854f11c2 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 @@ -293,7 +293,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 DirectTlsOptions { + contextId: number; +} export interface DirectHttpServerOptions { requestTimeout?: number; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts index 0abb5a748..fdfbab996 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2-host-node.ts @@ -1,3 +1,4 @@ +import type { TlsConfigurationProvider } from "./tls/host-types.js"; /** * Opt-in Node.js HTTP/2 provider. * @@ -6,7 +7,6 @@ * lib/internal/http2/core.js (MIT license). Native stream/session events are * adapted to typed, buffered WIT resources and guest callback resources. */ -import { Buffer } from "node:buffer"; import * as nodeHttp2 from "node:http2"; import { CallbackResource, @@ -182,12 +182,14 @@ class NodeHttp2ClientSession { readonly #ready: Promise>; #lastError: unknown; - constructor(authority: string, options: DirectHttp2ClientOptions) { + constructor( + authority: string, + options: DirectHttp2ClientOptions, + tls?: TlsConfigurationProvider, + ) { this.#session = nodeHttp2.connect(authority, { settings: nodeSettings(options.settings), - rejectUnauthorized: options.rejectUnauthorized, - servername: options.serverName, - ca: options.ca === undefined ? undefined : Buffer.from(options.ca), + ...(new URL(authority).protocol === "https:" ? tlsOptions(tls, options.tlsContext) : {}), }); this.#ready = new Promise((resolve) => { let settled = false; @@ -316,6 +318,7 @@ class NodeHttp2Server { options: DirectHttp2ServerOptions, handle: DirectHttp2StreamListener["handle"], onError: DirectHttp2ServerErrorListener["handle"], + tls?: TlsConfigurationProvider, ) { const common = { settings: nodeSettings(options.settings), @@ -325,8 +328,7 @@ class NodeHttp2Server { this.#server = options.secure ? nodeHttp2.createSecureServer({ ...common, - key: options.key === undefined ? undefined : Buffer.from(options.key), - cert: options.cert === undefined ? undefined : Buffer.from(options.cert), + ...tlsOptions(tls, options.tlsContext), }) : nodeHttp2.createServer(common); this.#server.on("session", (session) => { @@ -484,7 +486,10 @@ export const ClientSession = export const ClientStream = NodeHttp2ClientStream; /** Bind callback resource redemption and invocation to one component instance. */ -export function createHttp2Host(callbacks: () => DirectHttp2Callbacks) { +export function createHttp2Host( + callbacks: () => DirectHttp2Callbacks, + tls?: TlsConfigurationProvider, +) { const enqueue = createCallbackQueue(); class Server extends NodeHttp2Server { readonly #listener: CallbackResource; @@ -503,6 +508,7 @@ export function createHttp2Host(callbacks: () => DirectHttp2Callbacks) { options, (incoming) => enqueue(async () => (await stream.get()).handle(incoming)), (reason) => enqueue(async () => (await error.get()).handle(reason)), + tls, ); this.#listener = stream; this.#errorListener = error; @@ -521,7 +527,17 @@ export function createHttp2Host(callbacks: () => DirectHttp2Callbacks) { void this.close(); } } - return { ClientSession, ClientStream, Server: Server as unknown as DirectHttp2ServerConstructor }; + class BoundClientSession extends NodeHttp2ClientSession { + constructor(authority: string, options: DirectHttp2ClientOptions) { + super(authority, options, tls); + } + } + return { + ClientSession: + BoundClientSession as unknown as import("./http2/types.js").DirectHttp2ClientSessionConstructor, + ClientStream, + Server: Server as unknown as DirectHttp2ServerConstructor, + }; } export const Server = class { @@ -531,3 +547,16 @@ export const Server = class { } as unknown as DirectHttp2ServerConstructor; export default { ClientSession, ClientStream, Server, createHttp2Host }; + +function tlsOptions(tls: TlsConfigurationProvider | undefined, id: number | undefined) { + if (!tls || id === undefined) { + throw Object.assign( + new Error( + "Secure HTTP/2 requires the same jco:node/tls provider passed to createHttp2Host(callbacks, tls)", + ), + { code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }, + ); + } + return tls.takeContextOptions(id) as import("node:tls").ConnectionOptions & + import("node:tls").TlsOptions; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts index 46471f3fe..c13ae53ca 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2.ts @@ -1,9 +1,10 @@ +import * as tlsHost from "jco:node/tls@0.1.0"; import * as host from "jco:node/http2@0.1.0"; import { createHttp2 } from "./http2/core.js"; import { createDirectHttp2Implementation } from "./http2/impl/direct/index.js"; -const implementation = createDirectHttp2Implementation(host); +const implementation = createDirectHttp2Implementation(host, tlsHost); const http2 = createHttp2(implementation); export const http2Callbacks = implementation.http2Callbacks; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts index 2cf4ab1b5..2fd8a85da 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/client.ts @@ -1,3 +1,7 @@ +import type { TlsHost } from "../../../tls/host-types.js"; +import deniedTls from "../../../tls/node-host.js"; +import { encode } from "../../../tls/wire.js"; +import { hostCall } from "../../../tls/errors.js"; import { fromDirectSettings, toDirectSettings } from "../../settings.js"; import type { DirectHttp2Host, @@ -10,13 +14,31 @@ export function createDirectHttp2Client( host: DirectHttp2Host, authority: string, options: Http2ClientOptions, + tls: TlsHost = deniedTls, ): Http2ClientSessionImplementation { - const session = new host.ClientSession(authority, { - settings: toDirectSettings(options.settings), - rejectUnauthorized: options.rejectUnauthorized, - serverName: options.servername, - ca: tlsBytes(options.ca), - }); + const tlsContext = + new URL(authority).protocol === "https:" + ? hostCall(() => + tls.createContext( + encode({ + ca: tlsBytes(options.ca), + servername: options.servername, + rejectUnauthorized: options.rejectUnauthorized, + }), + ), + ) + : undefined; + let session: InstanceType; + try { + session = new host.ClientSession(authority, { + settings: toDirectSettings(options.settings), + tlsContext, + }); + } finally { + if (tlsContext !== undefined) { + tls.releaseContext(tlsContext); + } + } return { ready() { const info = unwrap(() => session.ready()); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts index 93918d5ca..8c58ceba9 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/index.ts @@ -1,15 +1,19 @@ +import type { TlsHost } from "../../../tls/host-types.js"; import type { DirectHttp2Host, Http2Implementation } from "../../types.js"; import { createDirectHttp2Client } from "./client.js"; import { createDirectHttp2Server, createHttp2CallbackRegistry } from "./server.js"; -export function createDirectHttp2Implementation(host: DirectHttp2Host): Http2Implementation & { +export function createDirectHttp2Implementation( + host: DirectHttp2Host, + tls?: TlsHost, +): Http2Implementation & { http2Callbacks: ReturnType["exports"]; } { const registry = createHttp2CallbackRegistry(); return { http2Callbacks: registry.exports, - connect: (authority, options) => createDirectHttp2Client(host, authority, options), + connect: (authority, options) => createDirectHttp2Client(host, authority, options, tls), createServer: (secure, options, handler, onError) => - createDirectHttp2Server(host, registry, secure, options, handler, onError), + createDirectHttp2Server(host, registry, secure, options, handler, onError, tls), }; } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts index 1a79b3342..57016e310 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/direct/server.ts @@ -1,3 +1,7 @@ +import type { TlsHost } from "../../../tls/host-types.js"; +import deniedTls from "../../../tls/node-host.js"; +import { encode } from "../../../tls/wire.js"; +import { hostCall } from "../../../tls/errors.js"; import { serializeNodeError } from "../../../internal/http-host.js"; import { codedError, fromImplementationError } from "../../errors.js"; import { toDirectSettings } from "../../settings.js"; @@ -95,20 +99,32 @@ export function createDirectHttp2Server( options: Http2ServerOptions, handler: Http2StreamHandler, onError: (error: Error) => void, + tls: TlsHost = deniedTls, ): Http2ServerImplementation { const [listener, errorListener] = registry.allocate(); - const server = new host.Server( - { - secure, - key: tlsBytes(options.key), - cert: tlsBytes(options.cert), - settings: toDirectSettings(options.settings), - allowHttp1: options.allowHTTP1, - strictFieldWhitespaceValidation: options.strictFieldWhitespaceValidation, - }, - listener, - errorListener, - ); + const tlsContext = secure + ? hostCall(() => + tls.createContext(encode({ key: tlsBytes(options.key), cert: tlsBytes(options.cert) })), + ) + : undefined; + let server: InstanceType; + try { + server = new host.Server( + { + secure, + tlsContext, + settings: toDirectSettings(options.settings), + allowHttp1: options.allowHTTP1, + strictFieldWhitespaceValidation: options.strictFieldWhitespaceValidation, + }, + listener, + errorListener, + ); + } finally { + if (tlsContext !== undefined) { + tls.releaseContext(tlsContext); + } + } const address = (value: ReturnType) => value === undefined ? null diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts index 0d92bc5dd..7569b4798 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/types.ts @@ -58,9 +58,7 @@ export interface Http2ClientOptions { export interface DirectHttp2ClientOptions { settings: DirectHttp2Settings; - rejectUnauthorized?: boolean; - serverName?: string; - ca?: Uint8Array; + tlsContext?: number; } export interface Http2RequestOptions { @@ -154,8 +152,7 @@ export interface Http2ServerOptions { export interface DirectHttp2ServerOptions { secure: boolean; - key?: Uint8Array; - cert?: Uint8Array; + tlsContext?: number; settings: DirectHttp2Settings; allowHttp1?: boolean; strictFieldWhitespaceValidation?: boolean; 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 index dd280eb34..ae9fb178b 100644 --- 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 @@ -1,9 +1,13 @@ +import { httpGuestCallbacks } from "./http/guest-callbacks.js"; +import * as tlsHost from "jco:node/tls@0.1.0"; 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)); +const implementation = createDirectHttpImplementation(host, tlsHost, httpGuestCallbacks); +const https = createHttps(implementation); +export const httpsCallbacks = implementation.httpCallbacks; export const Agent = https.Agent; export const Server = https.Server; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/default-deny.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/default-deny.ts index 2d6c1d5b0..83338b242 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/default-deny.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/default-deny.ts @@ -13,12 +13,18 @@ describe("default-deny node:http2 provider", () => { ); }); + test("mixed-case HTTPS cannot bypass the TLS capability", () => { + expect(() => http2.connect("HTTPS://localhost:8000")).toThrow( + expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }), + ); + }); + test("denies cleartext and secure servers", () => { expect(() => http2.createServer()).toThrow( expect.objectContaining({ code: "ERR_JCO_HTTP2_ADAPTER_REQUIRED" }), ); expect(() => http2.createSecureServer({ key: "key", cert: "cert" })).toThrow( - expect.objectContaining({ code: "ERR_JCO_HTTP2_ADAPTER_REQUIRED" }), + expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }), ); }); }); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts index ddfd0cf02..f8a2db701 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/direct.ts @@ -1,3 +1,5 @@ +import deniedTls from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/node-host.js"; +const tls = { ...deniedTls, createContext: () => 1 }; import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; @@ -189,8 +191,8 @@ describe("direct node:http2 implementation", () => { [Symbol.dispose]() {} }, }; - const first = createDirectHttp2Implementation(host); - const second = createDirectHttp2Implementation(host); + const first = createDirectHttp2Implementation(host, tls); + const second = createDirectHttp2Implementation(host, tls); const handler = async () => { throw Object.assign(new Error("callback failed"), { code: "EHTTP2TEST" }); }; @@ -238,7 +240,7 @@ describe("direct node:http2 implementation", () => { test("round trips client headers, data, settings, ping, and lifecycle", async () => { const harness = fakeHost(); - const implementation = createDirectHttp2Implementation(harness.host); + const implementation = createDirectHttp2Implementation(harness.host, tls); harness.attach(implementation.http2Callbacks); const http2 = createHttp2(implementation); const session = http2.connect("http://example.com", { settings: { enablePush: false } }); @@ -281,7 +283,7 @@ describe("direct node:http2 implementation", () => { test("round trips stream and compatibility server callbacks", async () => { const harness = fakeHost(); - const implementation = createDirectHttp2Implementation(harness.host); + const implementation = createDirectHttp2Implementation(harness.host, tls); harness.attach(implementation.http2Callbacks); const http2 = createHttp2(implementation); const server = http2.createServer(); @@ -314,7 +316,7 @@ describe("direct node:http2 implementation", () => { test("rejects unsupported options before constructing resources", () => { const harness = fakeHost(); - const implementation = createDirectHttp2Implementation(harness.host); + const implementation = createDirectHttp2Implementation(harness.host, tls); harness.attach(implementation.http2Callbacks); const http2 = createHttp2(implementation); expect(() => http2.connect("http://example.com", { createConnection() {} })).toThrow( @@ -326,7 +328,7 @@ describe("direct node:http2 implementation", () => { }); test("uses Node's server class names", () => { - const http2 = createHttp2(createDirectHttp2Implementation(fakeHost().host)); + const http2 = createHttp2(createDirectHttp2Implementation(fakeHost().host, tls)); expect(http2.createServer().constructor.name).toBe("Http2Server"); expect(http2.createSecureServer().constructor.name).toBe("Http2SecureServer"); }); 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 754b67250..4a4cf6bf3 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts @@ -1,13 +1,16 @@ +import { createTlsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/host-node.js"; +import { encode } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/wire.js"; +const tls = createTlsHost(); +const { ClientSession } = createHttp2Host(() => { + throw new Error("client-only provider"); +}, tls); import { readFile } from "node:fs/promises"; import * as nodeHttp2 from "node:http2"; import { connect } from "node:net"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { - ClientSession, - createHttp2Host, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2-host-node.js"; +import { createHttp2Host } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2-host-node.js"; import type { DirectHttp2ClientSession, DirectHttp2Server, @@ -42,7 +45,10 @@ describe("Node HTTP/2 host provider", () => { const errorListener = { handle: vi.fn(), [Symbol.dispose]: vi.fn() }; const takeStreamListener = vi.fn(() => undefined); const takeServerErrorListener = vi.fn(() => errorListener); - const { Server } = createHttp2Host(() => ({ takeStreamListener, takeServerErrorListener })); + const { Server } = createHttp2Host( + () => ({ takeStreamListener, takeServerErrorListener }), + tls, + ); const server = new Server({ secure: false, settings: emptySettings }, 1, 2); closeables.push(() => server[Symbol.dispose]()); const address = await server.listen({ port: 0, host: "127.0.0.1" }); @@ -122,7 +128,7 @@ describe("Node HTTP/2 host provider", () => { const session = new ClientSession(`https://127.0.0.1:${address.port}`, { settings: emptySettings, - rejectUnauthorized: false, + tlsContext: tls.createContext(encode({ rejectUnauthorized: false })), }) as DirectHttp2ClientSession; closeables.push(() => session[Symbol.dispose]()); await expect(session.ready()).resolves.toMatchObject({ @@ -170,9 +176,16 @@ describe("Node HTTP/2 host provider", () => { expect(id).toBe(2); return errorListener; }); - const { Server } = createHttp2Host(() => ({ takeStreamListener, takeServerErrorListener })); + const { Server } = createHttp2Host( + () => ({ takeStreamListener, takeServerErrorListener }), + tls, + ); const server = new Server( - { secure, key, cert, settings: emptySettings }, + { + secure, + tlsContext: secure ? tls.createContext(encode({ key, cert })) : undefined, + settings: emptySettings, + }, 1, 2, ) as DirectHttp2Server; 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 cd49a2831..0d06e2b89 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 @@ -1,15 +1,20 @@ +import { createTlsHost } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/host-node.js"; +import { encode } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/wire.js"; +const tls = createTlsHost(); + import { readFileSync } from "node:fs"; import { afterEach, describe, expect, test } from "vitest"; import { createHttpHost, - request, + request as nativeRequest, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; import type { DirectHttpCallbacks, DirectHttpRequestListener, - DirectHttpServerOptions, + HttpServerOptions, + HttpImplementationRequest, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; const encoder = new TextEncoder(); @@ -37,7 +42,7 @@ const echo: DirectHttpRequestListener = { }; async function listen( - options: DirectHttpServerOptions, + options: HttpServerOptions, ): Promise<{ server: HttpHostServer; port: number }> { const callbacks: DirectHttpCallbacks = { takeRequestListener(id: number): DirectHttpRequestListener { @@ -45,8 +50,17 @@ async function listen( return echo; }, }; - const { Server } = createHttpHost(() => callbacks); - const server = new Server(options, 1); + const { Server } = createHttpHost(() => callbacks, tls); + const server = new Server( + { + ...options, + tls: + options.tls === undefined + ? undefined + : { contextId: tls.createContext(encode(options.tls)) }, + }, + 1, + ); servers.add(server); const started = await server.listen({ port: 0, host: "127.0.0.1" }); if (started.tag !== "ok" || started.val.tag !== "tcp") { @@ -131,3 +145,16 @@ describe("node:https direct Node host", () => { } }); }); + +async function request(options: HttpImplementationRequest) { + return nativeRequest( + { + ...options, + tls: + options.scheme === "https" + ? { contextId: tls.createContext(encode(options.tls ?? {})) } + : undefined, + }, + tls, + ); +} 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 index 692e8eb8b..5210dbfac 100644 --- 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 @@ -66,12 +66,12 @@ describe("node:https module", () => { 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" }), + expect.objectContaining({ code: "ERR_JCO_TLS_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" }); + await expect(error).resolves.toMatchObject({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }); }); test.concurrent("rejects server construction when an implementation cannot listen", () => { 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 index 0c445c89c..6798897e2 100644 --- 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 @@ -56,14 +56,14 @@ function refusingProvider(): { provider: WasiHttpProvider; schemes: WasiHttpSche } describe("node:https wasi:http implementation", () => { - test.concurrent("sets the HTTPS scheme variant rather than an `other` string", async () => { + test.concurrent("refuses HTTPS before outgoing-handler can bypass the TLS capability", 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" }]); + await expect(error).resolves.toMatchObject({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }); + expect(schemes).toEqual([]); }); test.concurrent("refuses per-request TLS options, which outgoing-handler cannot honour", async () => { @@ -74,9 +74,7 @@ describe("node:https wasi:http implementation", () => { 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", - ), + message: expect.stringContaining("https.request with the wasi-http implementation"), }); expect(schemes).toEqual([]); }); 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 71bf3ea14..21fb2c8b8 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 @@ -1,3 +1,4 @@ +import { createWasiTlsBridge } from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls/wasi.js"; import { tlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/tls.js"; import { expect, test } from "vitest"; import { @@ -66,77 +67,89 @@ test.concurrent("accepts servername and explicitly enabled verification", () => ).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 = { - isAvailable: () => true, - 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; - }, +test.concurrent.each([false, true])( + "polls and drops a pending handshake (jco:node/tls bridge: %s)", + (bridge) => { + const events: string[] = []; + let ready = false; + const input = { blockingRead: (): Uint8Array => new Uint8Array() }; + 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"); + 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("poll"); + events.push("future"); }, - }), - [Symbol.dispose]: (): void => { - events.push("future"); - }, - }; - } - }, - }; - expect(handshake(provider, "localhost", input, output)).toEqual([connection, input, output]); - expect(events).toEqual(["poll", "future"]); -}); + }; + } + }, + }; + expect( + handshake(bridge ? createWasiTlsBridge(provider) : provider, "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 = { - isAvailable: () => true, - ClientHandshake: class { - static finish(): ReturnType { - return { - get: (): WasiTlsResult => ({ - tag: "ok", - val: { - tag: "err", +test.concurrent.each([false, true])( + "drops a failed handshake (jco:node/tls bridge: %s)", + (bridge) => { + const events: string[] = []; + const input = { blockingRead: (): Uint8Array => new Uint8Array() }; + const output = { blockingWriteAndFlush: (): void => {} }; + const provider: WasiTlsProvider = { + isAvailable: () => true, + ClientHandshake: class { + static finish(): ReturnType { + return { + get: (): WasiTlsResult => ({ + tag: "ok", val: { - toDebugString: (): string => "untrusted certificate", - [Symbol.dispose]: (): void => { - events.push("error"); + tag: "err", + val: { + toDebugString: (): string => "untrusted certificate", + [Symbol.dispose]: (): void => { + events.push("error"); + }, }, }, + }), + subscribe: (): never => { + throw new Error("unexpected poll"); }, - }), - subscribe: (): never => { - throw new Error("unexpected poll"); - }, - [Symbol.dispose]: (): void => { - events.push("future"); - }, - }; - } - }, - }; - expect(() => handshake(provider, "localhost", input, output)).toThrow(/untrusted certificate/); - expect(events).toEqual(["error", "future"]); -}); + [Symbol.dispose]: (): void => { + events.push("future"); + }, + }; + } + }, + }; + expect(() => + handshake(bridge ? createWasiTlsBridge(provider) : 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({ 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 7862c1350..2c15aeedb 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -91,33 +91,8 @@ 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, - } + /// Configuration issued by the same jco:node/tls provider bound to this HTTP host. + record tls-options { context-id: u32 } record server-options { request-timeout: option, diff --git a/packages/jco-std/wit/node-0.1.0/http2.wit b/packages/jco-std/wit/node-0.1.0/http2.wit index 3fc4db7c8..a15c4a64c 100644 --- a/packages/jco-std/wit/node-0.1.0/http2.wit +++ b/packages/jco-std/wit/node-0.1.0/http2.wit @@ -89,9 +89,8 @@ interface http2 { record client-options { settings: http2-settings, - reject-unauthorized: option, - server-name: option, - ca: option>, + /// One-use configuration issued by the shared jco:node/tls provider. + tls-context: option, } record request-options { @@ -126,8 +125,7 @@ interface http2 { record server-options { secure: bool, - key: option>, - cert: option>, + tls-context: option, settings: http2-settings, allow-http1: option, strict-field-whitespace-validation: option, From 15a83aa96ba03d1ed3fe597897c2576dc863a071 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 06/13] feat(jco): route Node TLS and HTTP through shared capability --- .../lib/wit/builtin/jco-node-0.1.0/http.wit | 29 +--- .../lib/wit/builtin/jco-node-0.1.0/http2.wit | 8 +- packages/jco/src/cmd/componentize.ts | 6 +- packages/jco/src/cmd/transpile.ts | 8 + packages/jco/src/node-builtins/http-common.ts | 7 +- packages/jco/src/node-builtins/http2.ts | 6 + packages/jco/src/node-builtins/index.ts | 2 + packages/jco/src/node-builtins/tls.ts | 27 ++++ packages/jco/src/node-builtins/types.ts | 2 + packages/jco/src/node-wit.ts | 81 ++++++++-- .../componentize/node-http2/run-direct.js | 69 ++++---- .../componentize/node-https-wasi-tls/build.ts | 1 + .../componentize/node-https-wasi-tls/run.ts | 4 + .../fixtures/componentize/node-tls/run.js | 63 ++++++++ .../fixtures/componentize/node-tls/source.js | 114 +++++++++++++ packages/jco/test/node/builtins.js | 2 + packages/jco/test/node/http2.js | 56 +++++-- packages/jco/test/node/https-wasi-tls.ts | 3 +- packages/jco/test/node/tls-wit.ts | 4 +- packages/jco/test/node/tls.js | 153 ++++++++++++++++++ 20 files changed, 549 insertions(+), 96 deletions(-) create mode 100644 packages/jco/src/node-builtins/tls.ts create mode 100644 packages/jco/test/fixtures/componentize/node-tls/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-tls/source.js create mode 100644 packages/jco/test/node/tls.js 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 07869a7fe..86b611b2f 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 @@ -93,33 +93,8 @@ 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, - } + /// Configuration issued by the same jco:node/tls provider bound to this HTTP host. + record tls-options { context-id: u32 } record server-options { request-timeout: option, diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit index 3fc4db7c8..a15c4a64c 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http2.wit @@ -89,9 +89,8 @@ interface http2 { record client-options { settings: http2-settings, - reject-unauthorized: option, - server-name: option, - ca: option>, + /// One-use configuration issued by the shared jco:node/tls provider. + tls-context: option, } record request-options { @@ -126,8 +125,7 @@ interface http2 { record server-options { secure: bool, - key: option>, - cert: option>, + tls-context: option, settings: http2-settings, allow-http1: option, strict-field-whitespace-validation: option, diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index f11a67187..2b6a3ccfb 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -1,3 +1,4 @@ +import { mergeNodeWitRequirement } from "../node-wit.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"; @@ -206,7 +207,10 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): // 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); + witRequirements.set( + requirement.witImport, + mergeNodeWitRequirement(witRequirements.get(requirement.witImport), requirement), + ); }, }), ], diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index 813ebcd30..47a1038f3 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -45,6 +45,7 @@ const HTTP2_ASYNC_IMPORTS = [ `${HTTP2_CAPABILITY}#[method]server.close`, ]; const DEFAULT_NODE_CAPABILITY_MAP = { + "jco:node/tls@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host", "wasi:tls/types@0.2.0-draft": "@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", @@ -72,6 +73,13 @@ function appendUnique(values: string[] | undefined, value: string): string[] { /** Configure host-backed Node capabilities and their required binding mode. */ export function withDefaultNodeCapabilities(opts: TranspileOpts): TranspileOpts { + if ( + opts.map?.["jco:node/tls@0.1.0"] !== undefined && + opts.map["jco:node/tls@0.1.0"] !== DEFAULT_NODE_CAPABILITY_MAP["jco:node/tls@0.1.0"] + ) { + opts.asyncMode = "jspi"; + opts.asyncExports = appendUnique(opts.asyncExports, "*"); + } const hasAsyncDnsProvider = opts.map?.[DNS_CAPABILITY] !== undefined && opts.map[DNS_CAPABILITY] !== DEFAULT_NODE_CAPABILITY_MAP[DNS_CAPABILITY]; diff --git a/packages/jco/src/node-builtins/http-common.ts b/packages/jco/src/node-builtins/http-common.ts index 752637fd2..774d7187b 100644 --- a/packages/jco/src/node-builtins/http-common.ts +++ b/packages/jco/src/node-builtins/http-common.ts @@ -3,6 +3,7 @@ import { type NodejsHttpVia } from "./types.js"; import { type NodeWitRequirement, HTTPS_WIT_REQUIREMENT, + TLS_WIT_REQUIREMENT, HTTP_WIT_REQUIREMENT, HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, @@ -74,7 +75,7 @@ function protocolWasiSocketsAdapter( ): string { const factory = PROTOCOL_FACTORY[protocol]; const provider = wasiSocketsProviderSource(version); - const tlsImports = protocol === "https" ? 'import * as tls from "wasi:tls/types@0.2.0-draft";' : ""; + const tlsImports = protocol === "https" ? 'import * as tls from "jco:node/tls@0.1.0";' : ""; const providerValue = protocol === "https" ? `{ ...${provider.value}, tls }` : provider.value; return ` ${provider.imports} @@ -104,7 +105,9 @@ function protocolWitRequirements( ): readonly NodeWitRequirement[] { const https = protocol === "https"; if (via === "direct") { - return [https ? HTTPS_WIT_REQUIREMENT : HTTP_WIT_REQUIREMENT]; + return https + ? [HTTPS_WIT_REQUIREMENT, { ...TLS_WIT_REQUIREMENT, nodeSpecifier: "node:https", guestExports: [] }] + : [HTTP_WIT_REQUIREMENT]; } if (via === "wasi-sockets") { if (wasiSocketsVersion === "0.2.12") { diff --git a/packages/jco/src/node-builtins/http2.ts b/packages/jco/src/node-builtins/http2.ts index 1b3c6953c..350c3a51e 100644 --- a/packages/jco/src/node-builtins/http2.ts +++ b/packages/jco/src/node-builtins/http2.ts @@ -11,6 +11,7 @@ import { type NodejsHttp2Via } from "./types.js"; import { wasiSocketsProviderSource, requireWasiHttpVersion } from "./wasi-sockets.js"; import { HTTP2_WIT_REQUIREMENT, + TLS_WIT_REQUIREMENT, HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, } from "../node-wit.js"; @@ -97,6 +98,11 @@ export function createHttp2Builtin({ options, worldMetadata }: BuiltinContext): () => { if (via === "direct") { options.onWitRequirement?.(HTTP2_WIT_REQUIREMENT); + options.onWitRequirement?.({ + ...TLS_WIT_REQUIREMENT, + nodeSpecifier: "node:http2", + guestExports: [], + }); } else if (via === "wasi-sockets") { requireWasiHttpVersion(worldMetadata, HTTP2_SPECIFIER, via, socketsVersion); for (const requirement of socketsVersion === "0.2.12" diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 4b878719a..0eeb82b23 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -28,6 +28,7 @@ import { createFsBuiltin } from "./fs.js"; import { createNetBuiltin } from "./net.js"; import { createHttpBuiltin } from "./http.js"; import { createHttpsBuiltin } from "./https.js"; +import { createTlsBuiltin } from "./tls.js"; import { createHttp2Builtin } from "./http2.js"; import { createBufferBuiltin } from "./buffer.js"; import { createQuerystringBuiltin } from "./querystring.js"; @@ -84,6 +85,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createNetBuiltin, createHttpBuiltin, createHttpsBuiltin, + createTlsBuiltin, createHttp2Builtin, createBufferBuiltin, createQuerystringBuiltin, diff --git a/packages/jco/src/node-builtins/tls.ts b/packages/jco/src/node-builtins/tls.ts new file mode 100644 index 000000000..d442081c7 --- /dev/null +++ b/packages/jco/src/node-builtins/tls.ts @@ -0,0 +1,27 @@ +import { + type BuiltinContext, + type BuiltinAdapter, + builtin, + virtualBuiltin, + composeBuiltins, + stdModule, + starReexportAdapter, + VIRTUAL_PREFIX, +} from "./shared.js"; +import { TLS_WIT_REQUIREMENT } from "../node-wit.js"; + +export function createTlsBuiltin({ options }: BuiltinContext): BuiltinAdapter { + const module = () => stdModule(options.tlsModule, "tls"); + return composeBuiltins([ + builtin( + "node:tls", + () => starReexportAdapter(module(), "tls"), + () => options.onWitRequirement?.(TLS_WIT_REQUIREMENT), + ), + virtualBuiltin( + "jco:node-tls-callbacks", + `${VIRTUAL_PREFIX}tls-callbacks`, + () => `export { tlsCallbacks } from ${JSON.stringify(module())};`, + ), + ]); +} diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index d6ef54fec..bbc74940f 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -91,6 +91,8 @@ export interface NodeBuiltinOptions { httpWasiHttpImplementationModule?: string; /** Paths to jco-std's HTTPS modules (overridable for tests). */ httpsModule?: string; + /** Path to the versioned node:tls facade (overridable for tests). */ + tlsModule?: string; httpsCoreModule?: string; /** Path to jco-std's portable `node:net` core module (overridable for tests). */ netCoreModule?: string; diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index ff791403e..2b96c727b 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -41,6 +41,31 @@ export interface WitDependencyPackage { dependencySources: string[]; } +/** A shared capability must retain every user's callbacks and dependency files. */ +export function mergeNodeWitRequirement( + previous: NodeWitRequirement | undefined, + current: NodeWitRequirement, +): NodeWitRequirement { + if (!previous) { + return current; + } + return { + ...previous, + ...current, + witExport: current.witExport ?? previous.witExport, + guestExports: [ + ...new Map( + [...(previous.guestExports ?? []), ...(current.guestExports ?? [])].map((value) => [ + value.jsExport, + value, + ]), + ).values(), + ], + dependencySources: [...new Set([...previous.dependencySources, ...current.dependencySources])], + dependencyPackages: [...(previous.dependencyPackages ?? []), ...(current.dependencyPackages ?? [])], + }; +} + /** Types the Node API interfaces share; every interface file depends on it. */ const SHARED_TYPES_SOURCE = fileURLToPath(new URL("../lib/wit/builtin/jco-node-0.1.0/types.wit", import.meta.url)); @@ -88,6 +113,32 @@ export const PROCESS_WIT_REQUIREMENT = nodeRequirement("node:process", "process" export const SQLITE_WIT_REQUIREMENT = nodeRequirement("node:sqlite", "sqlite"); +export const TLS_WIT_REQUIREMENT: NodeWitRequirement = { + ...nodeRequirement("node:tls", "tls", { + guestExports: [ + { + witExport: "jco:node/tls-callbacks@0.1.0", + jsExport: "tlsCallbacks", + moduleSpecifier: "jco:node-tls-callbacks", + }, + ], + }), + dependencyPackages: [ + { + dependencyDirectory: "wasi-io-0.2.12", + dependencySources: [ + fileURLToPath(new URL("../lib/wit/builtin/0.2.12/wasi-io/package.wit", import.meta.url)), + ], + }, + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => + fileURLToPath(new URL(`../lib/wit/builtin/wasi-tls-0.2.0-draft/${name}`, import.meta.url)), + ), + }, + ], +}; + export const OS_WIT_REQUIREMENT = nodeRequirement("node:os", "os"); export const TTY_WIT_REQUIREMENT = nodeRequirement("node:tty", "tty", { sharedTypes: true }); @@ -218,19 +269,7 @@ function forNodeSpecifier(requirements: readonly NodeWitRequirement[], nodeSpeci } function tlsRequirements(): NodeWitRequirement[] { - const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); - return forNodeSpecifier( - [ - wasiRequirement("wasi:tls/types@0.2.0-draft", [ - { - dependencyDirectory: "wasi-tls-0.2.0-draft", - dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), - }, - WASI_IO_DEPENDENCY, - ]), - ], - "node:https", - ); + return [{ ...TLS_WIT_REQUIREMENT, nodeSpecifier: "node:https", guestExports: [] }]; } export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ @@ -447,9 +486,11 @@ export async function injectNodeWitImports( return undefined; } const world = await findWorld(witPath, worldName); - const uniqueRequirements = [ - ...new Map(requirements.map((requirement) => [requirement.witImport, requirement])).values(), - ]; + const merged = new Map(); + for (const requirement of requirements) { + merged.set(requirement.witImport, mergeNodeWitRequirement(merged.get(requirement.witImport), requirement)); + } + const uniqueRequirements = [...merged.values()]; const missingImports = uniqueRequirements.filter( ({ witImport }) => !worldHasDeclaration(world, "import", witImport), ); @@ -482,7 +523,13 @@ export async function injectNodeWitImports( ...(requirement.dependencyPackages ?? []), ]; for (const dependency of packages) { - dependencies.set(dependency.dependencyDirectory, dependency); + const previous = dependencies.get(dependency.dependencyDirectory); + dependencies.set(dependency.dependencyDirectory, { + ...dependency, + dependencySources: [ + ...new Set([...(previous?.dependencySources ?? []), ...dependency.dependencySources]), + ], + }); } } for (const dependency of dependencies.values()) { diff --git a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js index aecbbc59c..6b66f912f 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js +++ b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js @@ -17,42 +17,53 @@ const timeout = setTimeout(() => { }, 30_000); const { instantiate } = await import(pathToFileURL(argv[2])); const { createHttp2Host } = await import(argv[3]); +const { createTlsHost } = await import( + new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls/host-node.js", import.meta.url) +); async function createInstance() { const imports = new WASIShim().getImportObject(); + const tls = createTlsHost(); + imports["@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host"] = tls; + imports["@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host"] = await import( + new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host.js", import.meta.url) + ); let instance; const taken = []; const disposed = []; const errorHandled = Promise.withResolvers(); - imports[argv[3]] = createHttp2Host(() => ({ - async takeStreamListener(id) { - taken.push(id); - const resource = await instance.http2Callbacks.takeStreamListener(id); - assert.equal(await instance.http2Callbacks.takeStreamListener(id), undefined); - return { - handle: (stream) => resource.handle(stream), - [Symbol.dispose]() { - disposed.push(id); - return resource[Symbol.dispose](); - }, - }; - }, - async takeServerErrorListener(id) { - const resource = await instance.http2Callbacks.takeServerErrorListener(id); - assert.equal(await instance.http2Callbacks.takeServerErrorListener(id), undefined); - assert.equal(id, 2); - return { - async handle(reason) { - await resource.handle(reason); - errorHandled.resolve(reason); - }, - [Symbol.dispose]() { - disposed.push(id); - return resource[Symbol.dispose](); - }, - }; - }, - })); + imports[argv[3]] = createHttp2Host( + () => ({ + async takeStreamListener(id) { + taken.push(id); + const resource = await instance.http2Callbacks.takeStreamListener(id); + assert.equal(await instance.http2Callbacks.takeStreamListener(id), undefined); + return { + handle: (stream) => resource.handle(stream), + [Symbol.dispose]() { + disposed.push(id); + return resource[Symbol.dispose](); + }, + }; + }, + async takeServerErrorListener(id) { + const resource = await instance.http2Callbacks.takeServerErrorListener(id); + assert.equal(await instance.http2Callbacks.takeServerErrorListener(id), undefined); + assert.equal(id, 2); + return { + async handle(reason) { + await resource.handle(reason); + errorHandled.resolve(reason); + }, + [Symbol.dispose]() { + disposed.push(id); + return resource[Symbol.dispose](); + }, + }; + }, + }), + tls, + ); instance = await instantiate(undefined, imports); return { instance, taken, disposed, errorHandled: errorHandled.promise }; } 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 46440d675..1b4803a92 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,6 +60,7 @@ const { files } = await transpileBytes(bytes, { ]), ), "wasi:tls/types@0.2.0-draft": "tls", + "jco:node/tls@0.1.0": "node-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 7a6343876..c37d33efc 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 @@ -1,3 +1,4 @@ +import { createWasiTlsBridge } from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls/wasi.js"; import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -34,6 +35,9 @@ imports.sockets = { }, }; imports.tls = policy === "denied" ? denied : { ...provider, ClientHandshake: CountedHandshake }; +imports["node-tls"] = createWasiTlsBridge( + policy === "denied" ? denied : { ...provider, ClientHandshake: CountedHandshake }, +); interface Report { status: number; body: string; diff --git a/packages/jco/test/fixtures/componentize/node-tls/run.js b/packages/jco/test/fixtures/componentize/node-tls/run.js new file mode 100644 index 000000000..370a413d2 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tls/run.js @@ -0,0 +1,63 @@ +import https from "node:https"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const [modulePath, hostPath, keyPath, certPath] = process.argv.slice(2); +const { createTlsHost } = await import(pathToFileURL(hostPath)); +const host = createTlsHost({ + onCallbackError: (error) => { + console.error(error); + process.exitCode = 1; + }, +}); +const { instantiate } = await import(pathToFileURL(modulePath)); +const { createHttpHost } = await import(new URL("../http-host-node.js", pathToFileURL(hostPath))); +let instance; +instance = await instantiate(undefined, { + ...new WASIShim().getImportObject(), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host": createHttpHost(() => instance.httpCallbacks, host), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host": await import( + new URL("../tls-host.js", pathToFileURL(hostPath)) + ), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host": host, +}); +host.attachCallbacks(instance.tlsCallbacks); +try { + await instance.start(await readFile(keyPath, "utf8"), await readFile(certPath, "utf8")); + const deadline = Date.now() + 15_000; + let report; + while (!(report = await instance.status())) { + if (Date.now() > deadline) { + throw new Error("TLS fixture timed out"); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const key = await readFile(keyPath, "utf8"); + const cert = await readFile(certPath, "utf8"); + const port = await instance.startHttps(key, cert); + const response = await new Promise((resolve, reject) => { + https + .get({ hostname: "127.0.0.1", port, servername: "localhost", ca: [cert], agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve(body)); + res.on("error", reject); + }) + .on("error", reject); + }); + await instance.stopHttps(); + const peer = https.createServer({ key, cert }, (_req, res) => res.end("native HTTPS")); + await new Promise((resolve) => peer.listen(0, "127.0.0.1", resolve)); + try { + const client = await instance.fetchHttps(peer.address().port, cert); + console.log(JSON.stringify({ ...JSON.parse(report), https: { client, server: response } })); + } finally { + await new Promise((resolve) => peer.close(resolve)); + } +} finally { + host.dispose(); +} diff --git a/packages/jco/test/fixtures/componentize/node-tls/source.js b/packages/jco/test/fixtures/componentize/node-tls/source.js new file mode 100644 index 000000000..a7a2b77da --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-tls/source.js @@ -0,0 +1,114 @@ +import tls, { connect, createServer, createSecureContext } from "node:tls"; +import { Buffer } from "node:buffer"; +import https from "node:https"; + +// Adapted from Node's tls.connect/createServer examples: PEM material and a finite +// input replace filesystem reads and process.stdin, so the guest needs only TLS. +export async function run(key, cert) { + const server = createServer({ key, cert, requestCert: true, ca: [cert], ALPNProtocols: ["echo"] }, (socket) => { + socket.write("welcome!\n"); + socket.pipe(socket); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const secureContext = createSecureContext({ key, cert, ca: [cert] }); + let inspection; + const client = connect( + port, + "127.0.0.1", + { secureContext, servername: "localhost", ALPNProtocols: ["echo"] }, + () => { + const peer = client.getPeerCertificate(true); + inspection = { + buffer: Buffer.isBuffer(client.getSession()), + authorized: client.authorized, + alpn: client.alpnProtocol, + encrypted: client.encrypted, + protocol: client.getProtocol(), + cipher: Boolean(client.getCipher().name), + certificate: Boolean(peer.raw.length), + issuerCycle: peer.issuerCertificate === peer, + verified: tls.checkServerIdentity("localhost", peer) === undefined, + mismatch: tls.checkServerIdentity("wrong.example", peer).code, + keyingMaterial: client.exportKeyingMaterial(32, "component example").length, + maxFragment: client.setMaxSendFragment(1024), + localPort: client.localPort > 0, + remotePort: client.remotePort === port, + }; + client.end("hello from a component\n"); + }, + ); + client.setEncoding("utf8"); + let echo = ""; + client.on("data", (data) => { + echo += data; + }); + try { + await new Promise((resolve, reject) => { + client.on("end", resolve); + client.on("error", reject); + }); + } finally { + client.destroy(); + await new Promise((resolve) => server.close(resolve)); + } + return JSON.stringify({ + echo, + inspection, + ticketKeys: server.getTicketKeys().length, + identities: tls.connect === connect && tls.createServer === createServer, + ciphers: tls.getCiphers().length > 0, + roots: tls.rootCertificates.length > 0, + }); +} + +export function denied() { + try { + tls.createSecureContext(); + } catch (error) { + return error.code; + } + return "unexpected success"; +} + +let result = ""; +export function start(key, cert) { + void run(key, cert).then( + (value) => { + result = value; + }, + (error) => { + result = JSON.stringify({ error: String(error), stack: error.stack }); + }, + ); +} +export function status() { + return result; +} + +let httpsServer; + +export function startHttps(key, cert) { + httpsServer = https.createServer({ key, cert }, (_request, response) => response.end("component HTTPS")); + httpsServer.listen(0, "127.0.0.1"); + return httpsServer.address().port; +} + +export function stopHttps() { + httpsServer.close(); +} + +export function fetchHttps(port, cert) { + return new Promise((resolve, reject) => { + const request = https.get({ hostname: "127.0.0.1", port, servername: "localhost", ca: [cert] }, (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + text += chunk; + }); + response.on("end", () => resolve(text)); + response.on("error", reject); + }); + request.on("error", reject); + }); +} diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 848d0a0ce..50e1a7e18 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -37,6 +37,7 @@ const unenvAliases = { describe("Node builtin adapters", () => { test.concurrent("maps host-backed Node APIs to deny providers unless the application opts in", () => { expect(withDefaultNodeCapabilityMap()).toEqual({ + "jco:node/tls@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host", "wasi:tls/types@0.2.0-draft": "@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", @@ -60,6 +61,7 @@ describe("Node builtin adapters", () => { "jco:node/os@0.1.0": "/application/os-host.js", }), ).toEqual({ + "jco:node/tls@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host", "wasi:tls/types@0.2.0-draft": "@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", diff --git a/packages/jco/test/node/http2.js b/packages/jco/test/node/http2.js index e2f686334..630624b35 100644 --- a/packages/jco/test/node/http2.js +++ b/packages/jco/test/node/http2.js @@ -1,6 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { cp, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { worldMetadataFor } from "../../src/cmd/componentize.js"; import { describe, expect, test, vi } from "vitest"; @@ -10,7 +10,7 @@ import { bundleNodeGuestExportsWrapper } from "../../src/cmd/componentize.js"; import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; import { HTTP2_CALLBACKS_SPECIFIER, nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; import { HTTP2_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; -import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +import { componentizeFixture, exec, getTmpDir, jcoPath, setupAsyncTest } from "../helpers.js"; import { hasJspi } from "../common.js"; const modulePaths = { @@ -265,16 +265,46 @@ describe.skipIf(!hasJspi)("node:http2 in a fully formed component", () => { }, 600_000); test("runs client and server callbacks through the direct component boundary", async () => { - const { componentPath, stderr } = await componentizeFixture({ - fixture: "node-http2", - wit: "wit-starling", - bundle: true, - copy: true, - extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http2-via", "direct"], - }); - expect(stderr).toContain("Jco added generated WIT import jco:node/http2@0.1.0"); - expect(stderr).toContain("jco:node/http2-callbacks@0.1.0"); - const host = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node"); + // Exercise the workspace adapters while their new TLS contract is unpublished. + const dir = await getTmpDir(); + const fixture = fileURLToPath(new URL("../fixtures/componentize/node-http2/", import.meta.url)); + const std = fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/", import.meta.url)); + await cp(join(fixture, "wit-starling"), join(dir, "wit"), { recursive: true }); + const requirements = []; + const source = await bundleNodeGuestExportsWrapper( + join(fixture, "component.js"), + HTTP2_WIT_REQUIREMENT.guestExports, + { + external: [/^jco:node\//], + plugins: [ + nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + http2Module: join(std, "http2.js"), + onWitRequirement: (requirement) => requirements.push(requirement), + }, + ), + ], + }, + ); + const injection = await injectNodeWitImports(join(dir, "wit"), undefined, requirements); + expect(injection.imports).toEqual(expect.arrayContaining(["jco:node/http2@0.1.0", "jco:node/tls@0.1.0"])); + expect(injection.exports).toContain("jco:node/http2-callbacks@0.1.0"); + await writeFile(join(dir, "source.js"), source); + const componentPath = join(dir, "component.wasm"); + await exec( + jcoPath, + "componentize", + join(dir, "source.js"), + "-w", + join(dir, "wit"), + "-o", + componentPath, + "--backend", + "starlingmonkey", + { closeStdin: true }, + ); + const host = pathToFileURL(join(std, "http2-host-node.js")).href; const { esModuleOutputPath, cleanup } = await setupAsyncTest({ component: { name: "node-http2-direct", path: componentPath, skipInstantiation: true }, jco: { transpile: { extraArgs: { asyncExports: ["*"], map: { "jco:node/http2@0.1.0": host } } } }, diff --git a/packages/jco/test/node/https-wasi-tls.ts b/packages/jco/test/node/https-wasi-tls.ts index 5ccedac09..1afe5ac3b 100644 --- a/packages/jco/test/node/https-wasi-tls.ts +++ b/packages/jco/test/node/https-wasi-tls.ts @@ -95,6 +95,7 @@ for (const backend of ["starlingmonkey"]) { 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 jco:node/tls@0.1.0"); 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"); @@ -190,7 +191,7 @@ for (const backend of ["starlingmonkey"]) { } else { expect(result.report.status).toBe(0); expect(result.report.error).toMatch( - policy === "denied" ? /wasi:tls.*TLS capability/ : /TLS handshake failed/, + policy === "denied" ? /jco:node\/tls.*TLS capability/ : /TLS handshake failed/, ); if (policy === "public") { expect(result.report.error).toMatch(/certificate/i); diff --git a/packages/jco/test/node/tls-wit.ts b/packages/jco/test/node/tls-wit.ts index ce0d6aaf8..5ac19b9ce 100644 --- a/packages/jco/test/node/tls-wit.ts +++ b/packages/jco/test/node/tls-wit.ts @@ -37,7 +37,9 @@ test.concurrent("TLS WIT injection is idempotent and shares IO 0.2.12 without fe 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(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "jco", package: "node", interface: "tls" }), + ); expect(await readFile(tlsPath, "utf8")).toBe(contract); expect(await readFile(join(root, "world.wit"), "utf8")).toBe(world); expect((await worldMetadataFor(root, "component")).imports).toContainEqual( diff --git a/packages/jco/test/node/tls.js b/packages/jco/test/node/tls.js new file mode 100644 index 000000000..6ca4f27e0 --- /dev/null +++ b/packages/jco/test/node/tls.js @@ -0,0 +1,153 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { bundleNodeGuestExportsWrapper } from "../../src/cmd/componentize.js"; +import { TLS_WIT_REQUIREMENT, HTTP_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; +import { exec, getTmpDir, jcoPath, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; + +const fixture = fileURLToPath(new URL("../fixtures/componentize/node-tls/", import.meta.url)); +const std = fileURLToPath(new URL("../../../jco-std/", import.meta.url)); +const impl = join(std, "dist/wasi/0.2.x/node/24.x.x"); +const certificate = join(std, "test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost"); + +test.concurrent("node:tls declares its primary capability and opt-in callback binding mode", () => { + const requirements = []; + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + tlsModule: "/tls.js", + onWitRequirement: (requirement) => requirements.push(requirement), + }, + ); + const id = plugin.resolveId("node:tls"); + expect(plugin.load(id)).toContain('export * from "/tls.js"'); + expect(plugin.resolveId("tls")).toBeNull(); + expect(requirements).toEqual([TLS_WIT_REQUIREMENT]); + expect(withDefaultNodeCapabilities({ map: { "jco:node/tls@0.1.0": "/my-tls.js" } })).toMatchObject({ + asyncMode: "jspi", + asyncExports: ["*"], + map: { "jco:node/tls@0.1.0": "/my-tls.js" }, + }); +}); + +test.concurrent("documentation echo fixture also runs unchanged on native Node", async () => { + const { run } = await import("../fixtures/componentize/node-tls/source.js"); + const [key, cert] = await Promise.all([ + readFile(certificate + ".key", "utf8"), + readFile(certificate + ".crt", "utf8"), + ]); + const report = JSON.parse(await run(key, cert)); + expect(report.echo).toBe("welcome!\nhello from a component\n"); + expect(report.inspection).toMatchObject({ authorized: true, issuerCycle: true, buffer: true, alpn: "echo" }); +}); + +test.concurrent.each([true, false])( + "retains TLS callbacks and shared WIT dependencies (TLS first: %s)", + async (tlsFirst) => { + const dir = await getTmpDir(); + await writeFile(join(dir, "world.wit"), "package test:tls; world component {}\n"); + const configOnly = { ...TLS_WIT_REQUIREMENT, nodeSpecifier: "node:https", guestExports: [] }; + const requirements = tlsFirst + ? [TLS_WIT_REQUIREMENT, HTTP_WIT_REQUIREMENT, configOnly] + : [configOnly, HTTP_WIT_REQUIREMENT, TLS_WIT_REQUIREMENT]; + const result = await injectNodeWitImports(dir, undefined, requirements); + expect(result.exports).toEqual( + expect.arrayContaining(["jco:node/tls-callbacks@0.1.0", "jco:node/http-callbacks@0.1.0"]), + ); + expect(await readFile(join(dir, "deps/jco-node-0.1.0/http.wit"), "utf8")).toContain("context-id: u32"); + expect(await readFile(join(dir, "deps/jco-node-0.1.0/tls.wit"), "utf8")).toContain("use wasi:tls/types"); + expect(await injectNodeWitImports(dir, undefined, requirements)).toBeUndefined(); + }, +); + +async function documentationExample(backend) { + const dir = await getTmpDir(); + const source = await bundleNodeGuestExportsWrapper( + join(fixture, "source.js"), + [...TLS_WIT_REQUIREMENT.guestExports, ...HTTP_WIT_REQUIREMENT.guestExports], + { + external: [/^jco:node\//], + plugins: [ + nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + tlsModule: join(impl, "tls.js"), + streamSchedulerModule: join(impl, "stream/scheduler.js"), + streamEmitterModule: join(impl, "stream/emitter.js"), + httpsModule: join(impl, "https.js"), + httpModule: join(impl, "http.js"), + }, + ), + ], + }, + ); + await writeFile(join(dir, "source.js"), source); + await writeFile( + join(dir, "world.wit"), + `package test:tls;\nworld component { import wasi:io/streams@0.2.12; import wasi:io/poll@0.2.12; import wasi:io/error@0.2.12; import wasi:tls/types@0.2.0-draft; export start: func(key: string, cert: string); export status: func() -> string; export denied: func() -> string; export start-https: func(key: string, cert: string) -> u32; export stop-https: func(); export fetch-https: func(port: u32, cert: string) -> string; }\n`, + ); + await injectNodeWitImports(dir, undefined, [TLS_WIT_REQUIREMENT, HTTP_WIT_REQUIREMENT]); + const path = join(dir, "component.wasm"); + await exec(jcoPath, "componentize", join(dir, "source.js"), "-w", dir, "-o", path, "--backend", backend, { + closeStdin: true, + }); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { path, name: `tls-${backend}`, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: { + asyncMode: "jspi", + asyncExports: ["*"], + asyncImports: [ + "request", + "[method]server.listen", + "[method]server.close", + "[method]server.get-connections", + ].map((name) => `jco:node/http@0.1.0#${name}`), + }, + }, + }, + }); + try { + const { stdout } = await exec( + join(fixture, "run.js"), + esModuleOutputPath, + join(impl, "tls/host-node.js"), + certificate + ".key", + certificate + ".crt", + ); + const report = JSON.parse(stdout); + expect(report.echo).toBe("welcome!\nhello from a component\n"); + expect(report.inspection).toMatchObject({ + authorized: true, + alpn: "echo", + encrypted: true, + cipher: true, + certificate: true, + issuerCycle: true, + verified: true, + mismatch: "ERR_TLS_CERT_ALTNAME_INVALID", + keyingMaterial: 32, + maxFragment: true, + localPort: true, + remotePort: true, + }); + expect(report.identities).toBe(true); + expect(report.ticketKeys).toBe(48); + expect(report.https).toEqual({ client: "native HTTPS", server: "component HTTPS" }); + } finally { + await cleanup(); + } +} + +test.skipIf(!hasJspi).concurrent( + "TLS documentation echo examples execute in StarlingMonkey", + () => documentationExample("starlingmonkey"), + 60_000, +); +// TODO(unskip): componentize-qjs cannot link the TLS interface's shared WASI IO resource types. +test.skip("TLS documentation echo examples execute in QuickJS", () => documentationExample("quickjs"), 60_000); From 6756ab011a555018ed84b3e88356cfb08bc1b76b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 07/13] docs(std): explain shared TLS providers and compatibility limits --- .../src/wasi/0.2.x/node/24.x.x/tls/README.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md new file mode 100644 index 000000000..a5afec04e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md @@ -0,0 +1,162 @@ +# Node TLS capability + +Target: Node.js **24.20.0**, commit +`71b8b174857e25106d39b61a9e6f30d927da8b01`. The audit used `lib/tls.js`, +`lib/internal/tls/{wrap,secure-context}.js`, and `doc/api/tls.md`. +The rolling Node documentation may describe a newer major release. +`alpn.ts` adapts Node's MIT-licensed protocol conversion and retains its notice. +Cryptography, certificate parsing, and the TLS protocol remain in the injected +provider. The guest reuses the existing portable classic stream implementation. +The audited unenv 2.0.0-rc.24 TLS module consists largely of stubs. + +Applications continue to import `node:tls`. Jco replaces that import and adds +`jco:node/tls@0.1.0` plus the `tls-callbacks` guest export. Importing the module +does not open sockets or read the host's trust store. The default provider denies +operations with `ERR_JCO_TLS_ADAPTER_REQUIRED`. + +## One TLS capability + +`node:tls`, direct HTTPS, and secure direct HTTP/2 use `jco:node/tls`. +HTTP protocols pass one-use TLS configuration handles to their transport host, +so their WIT interfaces no longer duplicate certificate and cipher options. +Bind those HTTP hosts to the **same TLS provider instance**. The native provider +consumes the handle before starting the HTTP operation; handles from another +provider, or already consumed handles, are rejected. + +HTTPS over WASI sockets calls `jco:node/tls.start-tls`. `createWasiTlsBridge` +delegates that operation to a supplied `wasi:tls/types@0.2.0-draft` provider and +reuses its future, connection, and WASI IO resources. The draft only supports +verified client stream upgrades. It cannot supply Node servers, per-connection +trust options, certificate inspection, or cipher controls; the bridge denies +those operations. A native provider can also accept `{ wasiTls }` to provide +both paths. The older `/tls/host` exports remain WASI providers for existing +embedders; they are not the primary Node capability. + +HTTPS via `wasi-http` is unsupported: outgoing-handler cannot accept the TLS +provider or its configuration. Select `direct` or `wasi-sockets`. HTTP/2 over +WASI sockets remains h2c-only. + +## Binding the native provider + +Use one factory per component. With Jco's default import mappings and explicit +instantiation, the import keys are the default provider module names: + +```js +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { createTlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node"; +import * as wasiTlsTypes from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host"; +import { createHttpHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node"; +import { createHttp2Host } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node"; +import { instantiate } from "./component.js"; + +const tls = createTlsHost({ onCallbackError: (error) => console.error(error) }); +let instance; +instance = await instantiate(undefined, { + ...new WASIShim().getImportObject(), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host": tls, + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host": wasiTlsTypes, + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host": createHttpHost( + () => instance.httpCallbacks, + tls, + ), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host": createHttp2Host( + () => instance.http2Callbacks, + tls, + ), +}); +if (instance.tlsCallbacks) tls.attachCallbacks(instance.tlsCallbacks); +// Call component exports. When the component is finished: +// tls.dispose(); +``` + +Transpile with JSPI and promising exports. For direct HTTP, also select async +imports `jco:node/http@0.1.0#request`, `#[method]server.listen`, +`#[method]server.close`, and `#[method]server.get-connections`. Direct HTTP/2 has +the existing async session/stream selectors. Explicit `--map` selections for +the HTTP providers add those selectors automatically. Supplying import objects +at instantiation does not retroactively change the generated binding mode. + +The native provider grants native network listeners/connections and trust-store +queries. `setDefaultCACertificates` changes only that provider's trust policy, +including its subsequent HTTPS and HTTP/2 configurations. It does not change +the embedding process's default CA store. Explicit `SecureContext` objects +retain their original trust settings. + +`dispose()` destroys sockets, including incomplete handshakes, closes listeners, +and releases contexts. Guest callback traps dispose the provider and are passed +to `onCallbackError`. Standalone contexts and closed server objects remain +provider-owned until disposal, allowing normal inspection and server reuse. + +## Supplying your own implementation + +Import `TlsHost` and `TlsCallbacks` from `/tls/core`. Start with the deny provider +and replace only the operations your component needs: + +```ts +import denied from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host"; +import type { TlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/core"; + +const host: TlsHost = { + ...denied, + // Implement permitted operations; every other operation still fails explicitly. +}; +``` + +For a WASI provider, `createWasiTlsBridge(provider)` already supplies such an +object. No alias mapping is necessary when supplying the object directly under +the binding's existing import key. A custom HTTP transport must understand its +TLS provider's configuration handles. `TlsConfigurationProvider.takeContextOptions` is the native HTTP adapters' local +integration contract, not an additional WIT import. It is exported as a type from +`/tls/core`; HTTP hosts only require this method from their TLS provider. + +The interface groups operations as follows: + +| Operations | Responsibility | +| -------------------------------------- | -------------------------------------------------------------------- | +| `query`, `set-default-ca` | Cipher/CA/identity queries and provider-local trust changes. | +| `create-context`, `release-context` | Validate TLS options and manage opaque configuration handles. | +| `connect`, `create-server` | Create a transport for the supplied guest identifier. | +| `socket-operation`, `server-operation` | Closed enums of inspection and control operations. | +| `write`, `end`, `release` | Stream writes, completion acknowledgements, and socket cleanup. | +| `is-available`, `start-tls` | Optional upgrades of owned WASI streams using the WASI TLS contract. | + +Dispatch events after the initiating import returns. `target` is the guest +socket/server ID. Accepted sockets use a separate positive 31-bit identifier +range. The guest installs an accepted socket when it receives `secureConnection`. +`write` events carry `{ token, error? }` and complete exactly one pending write or +end callback. Pause native reads after delivering a data chunk; the guest's +`resume` operation signals available buffer capacity. `core.ts` and `host-node.ts` +define the event payloads and closed operation argument lists together. + +Options and inspection values use `wire.ts`'s graph JSON format, `{ root, nodes }`. +Primitive values are inline; `{ ref: index }` references `bytes`, `array`, or +`object` nodes. Undefined and non-finite numbers have explicit tagged values. +Object nodes contain key/value pairs and may carry an error name. This preserves +binary fields, error codes, and self-signed certificate issuer cycles. Stream +data itself is `list`, not JSON. Functions and native handles never cross this +boundary. WIT result errors carry `name`, `message`, and optional `code`. + +## Compatibility boundaries and fixtures + +The full Node 24.20 module export list is present. Local tests cover mutual TLS, +hostname/trust rejection, custom identity checks, ALPN, certificate graphs, +keying material, early `end()`, and backpressure. The component fixture adapts +the documentation's echo client/server to use PEM arguments and finite input +instead of filesystem reads and `process.stdin`. It also exercises HTTPS in +both directions, and the HTTP/2 component fixture exercises the shared provider. + +Explicit unsupported operations include wrapping an arbitrary guest socket with +`new TLSSocket`, native `X509Certificate` return values, PSK/SNI/ALPN/lookup +callbacks, OpenSSL engine options, and server `newSession`, `resumeSession`, +`OCSPRequest`, `keylog`, and raw TCP `connection` events. Use `connect`, `getPeerCertificate`, +`getCertificate`, and `Server.addContext` where applicable. Socket `session`, +`keylog`, and `OCSPResponse` events are delivered. Renegotiation limits are host +policy; changing `CLIENT_RENEG_LIMIT` or `CLIENT_RENEG_WINDOW` throws. The socket +is a portable `Duplex`; it is not an instance of the separate `node:net` shim's +Socket class. + +StarlingMonkey runs the component fixture. QuickJS is explicitly skipped with +`TODO(unskip)` because componentize-qjs cannot link the shared WASI TLS resource +types. Export a synchronous starter for long-lived event-driven work: a guest +export cannot await a promise resolved solely by a future independent host +callback. The fixture's starter/status exports demonstrate this engine boundary. From 24270ffb05fb88945308e10ffbc10c475e3a78c0 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 08/13] docs(jco): explain shared TLS providers and compatibility limits --- docs/src/SUMMARY.md | 1 + docs/src/interop/nodejs-builtins.md | 2 +- .../supported-modules/http2.md | 7 +- .../supported-modules/https.md | 95 ++++++------------- .../supported-modules/index.md | 3 +- .../nodejs-builtins/supported-modules/tls.md | 37 ++++++++ 6 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 docs/src/interop/nodejs-builtins/supported-modules/tls.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index e321fb27b..78971852e 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -49,6 +49,7 @@ - [`node:string_decoder`](./interop/nodejs-builtins/supported-modules/string-decoder.md) - [`node:test`](./interop/nodejs-builtins/supported-modules/test.md) - [`node:timers`](./interop/nodejs-builtins/supported-modules/timers.md) + - [`node:tls`](./interop/nodejs-builtins/supported-modules/tls.md) - [`node:tty`](./interop/nodejs-builtins/supported-modules/tty.md) - [`node:url`](./interop/nodejs-builtins/supported-modules/url.md) - [Troubleshooting]() diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index 63d85a365..b17d2c05d 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -142,7 +142,7 @@ set of coordinated shims: `node:crypto`, `node:dgram`, `node:http2`, `node:perf_hooks`, `node:repl`, `node:stream`, `node:stream/promises`, `node:stream/web`, -`node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, +`node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, `node:worker_threads`, and `node:zlib`. #### Future composition diff --git a/docs/src/interop/nodejs-builtins/supported-modules/http2.md b/docs/src/interop/nodejs-builtins/supported-modules/http2.md index 79a277773..650469868 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/http2.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/http2.md @@ -35,8 +35,11 @@ jco componentize component.js --wit wit --bundle \ | `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. | -By default, the provider rejects both `connect()` and server construction with -`ERR_JCO_HTTP2_ADAPTER_REQUIRED`. +By default, cleartext operations fail with `ERR_JCO_HTTP2_ADAPTER_REQUIRED`; +secure operations first require `jco:node/tls` and fail with +`ERR_JCO_TLS_ADAPTER_REQUIRED` when it is denied. Direct secure sessions and +servers obtain one-use configuration handles from the TLS provider. Bind it +with `createHttp2Host(() => instance.http2Callbacks, tls)`. `direct` mode models sessions, streams, and servers as typed host-owned WIT resources, with a passthrough implementation to NodeJS underneath. The WIT diff --git a/docs/src/interop/nodejs-builtins/supported-modules/https.md b/docs/src/interop/nodejs-builtins/supported-modules/https.md index 0aba44f71..daa29427f 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/https.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/https.md @@ -15,71 +15,30 @@ 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` | 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`. | - -TLS support is part of the `wasi-sockets` implementation, which uses the -`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/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node' -``` - -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 the -TLS interface to a module exporting: - -```js -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, -}); -``` - -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 -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 temporarily skipped component tests in `https-wasi-tls.ts` include 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 -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. +TLS options are configured through `jco:node/tls@0.1.0`. Direct HTTP requests +and servers carry a one-use configuration handle, which the HTTP host consumes +from the same TLS provider. Certificate, cipher, ALPN, trust, and SNI settings +have one capability boundary instead of separate HTTP and TLS WIT records. +The existing serializable HTTPS option subset remains supported; native objects +and callback options such as `checkServerIdentity` and `SNICallback` remain +explicitly unsupported by the buffered HTTP adapter. + +| Value | `node:https` behavior | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `direct` | Clients and servers using `jco:node/http` and `jco:node/tls`. Bind `createHttpHost(callbacks, tls)` to the same TLS provider imported by the component. | +| `wasi-sockets` | Verified clients call `jco:node/tls.start-tls` over the existing TCP streams. A provider can delegate to `wasi:tls`. The pinned draft does not support HTTPS servers. | +| `wasi-http` | HTTPS is rejected because outgoing-handler cannot use the TLS capability. Select `direct` or `wasi-sockets`. | + +For WASI stream upgrades, bind `createWasiTlsBridge(yourWasiTlsProvider)` from +`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/wasi` as the primary +`jco:node/tls` import. The bridge reuses the WASI TLS future, connection, and IO +resources. Only `servername` and `rejectUnauthorized: true` are supported by +that draft; trust and ALPN are provider policy. The optional native TLS factory +also accepts `{ wasiTls: yourWasiTlsProvider }` to serve both transports. +The existing native WASI TLS provider still awaits publication of the +preview2-shim `io-worker` export; the new native Node TLS provider does not +have that dependency. + +Projects with checked-in `http.wit` or `http2.wit` dependencies must update them +together with `tls.wit`. Injection adds missing files but never overwrites +existing dependency files. Plain HTTP continues to work without granting TLS. diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index 6bb6120d8..602048241 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -47,7 +47,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:fs`](./fs.md), [`node:fs/promises`](./fs.md) | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | | [`node:http`](./http.md) | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation. Servers need `direct` or `wasi-sockets`. | | [`node:http2`](./http2.md) | Client and server sessions over selectable direct or WASI socket implementations. | -| [`node:https`](./https.md) | 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. | +| [`node:https`](./https.md) | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS uses `jco:node/tls`, optionally delegating to `wasi:tls`. | | [`node:inspector`](./inspector.md), [`node:inspector/promises`](./inspector.md) | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface. | | [`node:module`](./module.md) | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API`. Requires no WIT capability. | | [`node:net`](./net.md) | TCP clients, servers, and address utilities over Preview 2 `wasi:sockets`; native handles and IPC are unsupported. | @@ -65,6 +65,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:string_decoder`](./string-decoder.md) | Guest-local streaming decoder for Node 24. Requires no WIT capability. | | [`node:test`](./test.md), [`node:test/reporters`](./test.md) | Serial component tests, hooks, assertions, mocks, and reporters. No additional WIT imports. Runner requires engine `AbortController`; see the API page for engine limits. | | [`node:timers`](./timers.md), [`node:timers/promises`](./timers.md) | Node 24 timer handles and promise timers over engine task scheduling; see the API page for runtime limits. | +| [`node:tls`](./tls.md) | Encrypted sockets, contexts, and inspection over `jco:node/tls`; denied by default. | | [`node:tty`](./tty.md) | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default. | | [`node:url`](./url.md) | Node 24 URL, URLSearchParams, URLPattern, domain and file conversions; relative file paths use optional WASI environment imports. | diff --git a/docs/src/interop/nodejs-builtins/supported-modules/tls.md b/docs/src/interop/nodejs-builtins/supported-modules/tls.md new file mode 100644 index 000000000..5fb81ae53 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/tls.md @@ -0,0 +1,37 @@ +# `node:tls` + +| Imports | Implementation | +| --- | --- | +| `node:tls` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls` | + +`node:tls` targets Node 24.20.0 and exposes its complete module export list. +An injected `jco:node/tls@0.1.0` provider supplies encrypted sockets, listeners, +secure contexts, certificate inspection, cipher/CA queries, and TLS controls. +The guest socket uses the portable classic `Duplex` implementation for pipes +and backpressure. Importing the module grants no capabilities; the default +provider throws `ERR_JCO_TLS_ADAPTER_REQUIRED` when used. + +Use `createTlsHost()` from +`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node` for an +opt-in native provider, one instance per component. Supply it directly as the +TLS import, then call `tls.attachCallbacks(instance.tlsCallbacks)` for +standalone TLS socket events. Pass that same object to `createHttpHost` and +`createHttp2Host` when binding HTTPS or secure HTTP/2. Call `tls.dispose()` +when the component is finished. Default CA changes are local to this provider, +including its HTTP configuration handles; they do not change host-process trust. + +The [TLS provider contract](https://github.com/bytecodealliance/jco/blob/main/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md) +includes a complete instantiation example, the typed `TlsHost` boundary, +WASI delegation, event ordering, resource ownership, and compatibility limits. +Custom providers can extend the deny implementation and replace the operations +they permit. Unsupported operations fail explicitly, including native socket +wrapping, native X509 objects, synchronous PSK/SNI/ALPN callbacks, and server +session/OCSP callbacks. Use certificate records and `Server.addContext` where +applicable. + +The documentation echo fixture runs as a StarlingMonkey component and includes +HTTPS client/server coverage. QuickJS remains skipped with `TODO(unskip)` +because componentize-qjs cannot link the shared WASI TLS resource types. For +long-lived socket work, use a synchronous starter export and receive subsequent +events through the callback export; an export cannot await a promise resolved +solely by an independent future host callback. From 273937929a0bdf91373e626b3ebee6cde1360ca0 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:03:36 +0000 Subject: [PATCH 09/13] fix(jco): keep instantiation imports WIT-spelled instead of remapping them --- docs/src/transpiling.md | 5 ++++- packages/jco/src/cmd/transpile.ts | 7 ++++++- .../fixtures/componentize/node-http2/run-direct.js | 4 ++-- .../fixtures/componentize/node-process-custom/run.js | 4 ++-- .../jco/test/fixtures/componentize/node-tls/run.js | 8 +++----- packages/jco/test/node/builtins.js | 10 ++++++++++ 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index b94cf224c..976f3a1fb 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -269,7 +269,10 @@ export async function instantiate( ): Promise<{ [exportName: string]: any }>; ``` -`imports` allows customizing the imports provided for instantiation. +`imports` allows customizing the imports provided for instantiation. Its keys are the component's +import names as the WIT spells them, without versions (for example `wasi:cli/environment` or +`jco:node/process`). `--map` rewrites those keys the same way it rewrites ESM import specifiers; +Jco's default Node capability map is not applied to instantiation output. `instantiateCore` defaults to `WebAssembly.instantiate`. diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index 47a1038f3..2d923b5a3 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -122,7 +122,12 @@ export function withDefaultNodeCapabilities(opts: TranspileOpts): TranspileOpts opts.asyncImports = appendUnique(opts.asyncImports, `${sqlite}#backup`); opts.asyncExports = appendUnique(opts.asyncExports, "*"); } - opts.map = withDefaultNodeCapabilityMap(opts.map); + // Instantiation output takes an import object keyed by WIT interface name; the + // deny-by-default module map exists for ESM output, whose imports must resolve to + // a module, and would only rename those keys here. + if (!opts.instantiation) { + opts.map = withDefaultNodeCapabilityMap(opts.map); + } return opts; } diff --git a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js index 6b66f912f..43a2ffe6b 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js +++ b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js @@ -24,8 +24,8 @@ const { createTlsHost } = await import( async function createInstance() { const imports = new WASIShim().getImportObject(); const tls = createTlsHost(); - imports["@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host"] = tls; - imports["@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host"] = await import( + imports["jco:node/tls"] = tls; + imports["wasi:tls/types"] = await import( new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host.js", import.meta.url) ); let instance; diff --git a/packages/jco/test/fixtures/componentize/node-process-custom/run.js b/packages/jco/test/fixtures/componentize/node-process-custom/run.js index cb15761ba..e543e5288 100644 --- a/packages/jco/test/fixtures/componentize/node-process-custom/run.js +++ b/packages/jco/test/fixtures/componentize/node-process-custom/run.js @@ -8,11 +8,11 @@ const { host, exitRequests } = createProcessHost(); const other = createProcessHost(); const component = await instantiate(undefined, { ...new WASIShim().getImportObject(), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host": host, + "jco:node/process": host, }); const otherComponent = await instantiate(undefined, { ...new WASIShim().getImportObject(), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host": other.host, + "jco:node/process": other.host, }); assert.equal(await component.denied(), "ERR_JCO_PROCESS_ADAPTER_REQUIRED"); assert.deepEqual(exitRequests, []); diff --git a/packages/jco/test/fixtures/componentize/node-tls/run.js b/packages/jco/test/fixtures/componentize/node-tls/run.js index 370a413d2..48fb2ba76 100644 --- a/packages/jco/test/fixtures/componentize/node-tls/run.js +++ b/packages/jco/test/fixtures/componentize/node-tls/run.js @@ -16,11 +16,9 @@ const { createHttpHost } = await import(new URL("../http-host-node.js", pathToFi let instance; instance = await instantiate(undefined, { ...new WASIShim().getImportObject(), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host": createHttpHost(() => instance.httpCallbacks, host), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host": await import( - new URL("../tls-host.js", pathToFileURL(hostPath)) - ), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host": host, + "jco:node/http": createHttpHost(() => instance.httpCallbacks, host), + "wasi:tls/types": await import(new URL("../tls-host.js", pathToFileURL(hostPath))), + "jco:node/tls": host, }); host.attachCallbacks(instance.tlsCallbacks); try { diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 50e1a7e18..4846c61ed 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -96,6 +96,16 @@ describe("Node builtin adapters", () => { } }); + test.concurrent("keeps instantiation imports WIT-spelled instead of remapping them", () => { + expect(withDefaultNodeCapabilities({ instantiation: "async" }).map).toBeUndefined(); + const explicit = withDefaultNodeCapabilities({ + instantiation: "async", + map: { "jco:node/tls@0.1.0": "/application/tls-host.js" }, + }); + expect(explicit.map).toEqual({ "jco:node/tls@0.1.0": "/application/tls-host.js" }); + expect(explicit.asyncMode).toBe("jspi"); + }); + test.concurrent("configures custom DNS providers as JSPI imports", () => { const opts = withDefaultNodeCapabilities({ map: { "jco:node/dns@0.1.0": "/application/dns-host.js" }, From 73e2f860b09c0459cddb1e76ce38d6001a5f1491 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 10/13] docs(std): key the TLS instantiation example by WIT interface name --- .../src/wasi/0.2.x/node/24.x.x/tls/README.md | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md index a5afec04e..939af35ae 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md @@ -38,8 +38,9 @@ WASI sockets remains h2c-only. ## Binding the native provider -Use one factory per component. With Jco's default import mappings and explicit -instantiation, the import keys are the default provider module names: +Use one factory per component. With `--instantiation`, the import keys are the +WIT interface names without their versions; Jco's deny-by-default module map +applies only to ESM output: ```js import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; @@ -53,16 +54,10 @@ const tls = createTlsHost({ onCallbackError: (error) => console.error(error) }); let instance; instance = await instantiate(undefined, { ...new WASIShim().getImportObject(), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host": tls, - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host": wasiTlsTypes, - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host": createHttpHost( - () => instance.httpCallbacks, - tls, - ), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host": createHttp2Host( - () => instance.http2Callbacks, - tls, - ), + "jco:node/tls": tls, + "wasi:tls/types": wasiTlsTypes, + "jco:node/http": createHttpHost(() => instance.httpCallbacks, tls), + "jco:node/http2": createHttp2Host(() => instance.http2Callbacks, tls), }); if (instance.tlsCallbacks) tls.attachCallbacks(instance.tlsCallbacks); // Call component exports. When the component is finished: From 9e77e04822af5d4ba9d9b33e0e8bb347972c47eb Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:12:26 +0000 Subject: [PATCH 11/13] docs(jco): key the process instantiation example by WIT interface name --- .../nodejs-builtins/supported-modules/process.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/process.md b/docs/src/interop/nodejs-builtins/supported-modules/process.md index 432c09574..9a6883018 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/process.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/process.md @@ -98,10 +98,10 @@ Generate bindings for explicit instantiation; no custom mapping is needed: jco transpile app.wasm -o out --instantiation async ``` -Then pass your implementation object directly in the imports. Jco's default -mapping names the process import after the denial-provider package; that key does -not force you to use its implementation. The generated binding types list the -expected import keys: +Then pass your implementation object directly in the imports, keyed by the WIT +interface name without its version. Jco's deny-by-default module map applies only +to ESM output; instantiation output never renames imports. The generated binding +types list the expected import keys: ```js import { instantiate } from './out/app.js'; @@ -111,7 +111,7 @@ import { createProcessHost } from './my-process-provider.js'; const { host, exitRequests } = createProcessHost(); const component = await instantiate(undefined, { ...new WASIShim().getImportObject(), - '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host': host, + 'jco:node/process': host, }); ``` From 07241ae3c184537ed1cf5b2faae6282673d06a7e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:14:16 +0000 Subject: [PATCH 12/13] docs(jco): key the direct HTTP instantiation example by WIT interface name --- .../nodejs-builtins/supported-modules/http.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/http.md b/docs/src/interop/nodejs-builtins/supported-modules/http.md index fb3533c30..774334a81 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/http.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/http.md @@ -41,8 +41,18 @@ construction immediately because outgoing-handler cannot listen for arbitrary connections. For direct servers, instantiate with a provider bound to that component's -callback dispatcher. For example, after transpiling with -`--instantiation async --map jco:node/http@0.1.0=http-host`: +callback dispatcher. The provider is asynchronous, so select JSPI and its async +imports explicitly; instantiation output keeps the WIT import names and nothing +selects them for you. For example, after transpiling with: + +```sh +jco transpile component.wasm -o out --instantiation async \ + --async-mode jspi --async-exports '*' \ + --async-imports 'jco:node/http@0.1.0#request' \ + 'jco:node/http@0.1.0#[method]server.listen' \ + 'jco:node/http@0.1.0#[method]server.close' \ + 'jco:node/http@0.1.0#[method]server.get-connections' +``` ```js import { instantiate } from './component.js'; @@ -51,7 +61,7 @@ import { createHttpHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x let instance; const imports = new WASIShim().getImportObject(); -imports['http-host'] = createHttpHost(() => instance.httpCallbacks); +imports['jco:node/http'] = createHttpHost(() => instance.httpCallbacks); instance = await instantiate(undefined, imports); // Await application exports that create or control servers. await instance.start(); From 36969f2dd7c40c204b86ab58004e2f1ee7442af8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 11 Sep 2026 15:08:36 +0000 Subject: [PATCH 13/13] docs(jco): consolidate TLS provider documentation in API reference --- .../nodejs-builtins/supported-modules/tls.md | 171 +++++++++++++++--- .../src/wasi/0.2.x/node/24.x.x/tls/README.md | 157 ---------------- 2 files changed, 147 insertions(+), 181 deletions(-) delete mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md diff --git a/docs/src/interop/nodejs-builtins/supported-modules/tls.md b/docs/src/interop/nodejs-builtins/supported-modules/tls.md index 5fb81ae53..27bd93d4b 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/tls.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/tls.md @@ -11,27 +11,150 @@ The guest socket uses the portable classic `Duplex` implementation for pipes and backpressure. Importing the module grants no capabilities; the default provider throws `ERR_JCO_TLS_ADAPTER_REQUIRED` when used. -Use `createTlsHost()` from -`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node` for an -opt-in native provider, one instance per component. Supply it directly as the -TLS import, then call `tls.attachCallbacks(instance.tlsCallbacks)` for -standalone TLS socket events. Pass that same object to `createHttpHost` and -`createHttp2Host` when binding HTTPS or secure HTTP/2. Call `tls.dispose()` -when the component is finished. Default CA changes are local to this provider, -including its HTTP configuration handles; they do not change host-process trust. - -The [TLS provider contract](https://github.com/bytecodealliance/jco/blob/main/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md) -includes a complete instantiation example, the typed `TlsHost` boundary, -WASI delegation, event ordering, resource ownership, and compatibility limits. -Custom providers can extend the deny implementation and replace the operations -they permit. Unsupported operations fail explicitly, including native socket -wrapping, native X509 objects, synchronous PSK/SNI/ALPN callbacks, and server -session/OCSP callbacks. Use certificate records and `Server.addContext` where -applicable. - -The documentation echo fixture runs as a StarlingMonkey component and includes -HTTPS client/server coverage. QuickJS remains skipped with `TODO(unskip)` -because componentize-qjs cannot link the shared WASI TLS resource types. For -long-lived socket work, use a synchronous starter export and receive subsequent -events through the callback export; an export cannot await a promise resolved -solely by an independent future host callback. +Jco replaces ordinary `node:tls` imports and adds the `jco:node/tls@0.1.0` +capability plus the `tls-callbacks` guest export. Cryptography, certificate parsing, +and the TLS protocol run in the injected provider. Importing the module does not +open sockets or read the host's trust store. + +## Shared TLS provider + +`node:tls`, direct HTTPS, and secure direct HTTP/2 use `jco:node/tls`. +HTTP protocols pass one-use TLS configuration handles to their transport host, +so their WIT interfaces no longer duplicate certificate and cipher options. +Bind those HTTP hosts to the **same TLS provider instance**. The native provider +consumes the handle before starting the HTTP operation; handles from another +provider, or already consumed handles, are rejected. + +HTTPS over WASI sockets calls `jco:node/tls.start-tls`. `createWasiTlsBridge` +delegates that operation to a supplied `wasi:tls/types@0.2.0-draft` provider and +reuses its future, connection, and WASI IO resources. The draft only supports +verified client stream upgrades. It cannot supply Node servers, per-connection +trust options, certificate inspection, or cipher controls; the bridge denies +those operations. A native provider can also accept `{ wasiTls }` to provide +both paths. The older `/tls/host` exports remain WASI providers for existing +embedders; they are not the primary Node capability. + +HTTPS via `wasi-http` is unsupported: outgoing-handler cannot accept the TLS +provider or its configuration. Select `direct` or `wasi-sockets`. HTTP/2 over +WASI sockets remains h2c-only. + +## Binding the native provider + +Use one factory per component. With `--instantiation`, the import keys are the +WIT interface names without their versions; Jco's deny-by-default module map +applies only to ESM output: + +```js +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { createTlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node"; +import * as wasiTlsTypes from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host"; +import { createHttpHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node"; +import { createHttp2Host } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node"; +import { instantiate } from "./component.js"; + +const tls = createTlsHost({ onCallbackError: (error) => console.error(error) }); +let instance; +instance = await instantiate(undefined, { + ...new WASIShim().getImportObject(), + "jco:node/tls": tls, + "wasi:tls/types": wasiTlsTypes, + "jco:node/http": createHttpHost(() => instance.httpCallbacks, tls), + "jco:node/http2": createHttp2Host(() => instance.http2Callbacks, tls), +}); +if (instance.tlsCallbacks) tls.attachCallbacks(instance.tlsCallbacks); +// Call component exports. When the component is finished: +// tls.dispose(); +``` + +Transpile with JSPI and promising exports. For direct HTTP, also select async +imports `jco:node/http@0.1.0#request`, `#[method]server.listen`, +`#[method]server.close`, and `#[method]server.get-connections`. Direct HTTP/2 has +the existing async session/stream selectors. Explicit `--map` selections for +the HTTP providers add those selectors automatically. Supplying import objects +at instantiation does not retroactively change the generated binding mode. + +The native provider grants native network listeners/connections and trust-store +queries. `setDefaultCACertificates` changes only that provider's trust policy, +including its subsequent HTTPS and HTTP/2 configurations. It does not change +the embedding process's default CA store. Explicit `SecureContext` objects +retain their original trust settings. + +`dispose()` destroys sockets, including incomplete handshakes, closes listeners, +and releases contexts. Guest callback traps dispose the provider and are passed +to `onCallbackError`. Standalone contexts and closed server objects remain +provider-owned until disposal, allowing normal inspection and server reuse. + +## Supplying your own implementation + +Import `TlsHost` and `TlsCallbacks` from `/tls/core`. Start with the deny provider +and replace only the operations your component needs: + +```ts +import denied from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host"; +import type { TlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/core"; + +const host: TlsHost = { + ...denied, + // Implement permitted operations; every other operation still fails explicitly. +}; +``` + +For a WASI provider, `createWasiTlsBridge(provider)` already supplies such an +object. No alias mapping is necessary when supplying the object directly under +the binding's existing import key. A custom HTTP transport must understand its +TLS provider's configuration handles. +`TlsConfigurationProvider.takeContextOptions` is the native HTTP adapters' local +integration contract, not an additional WIT import. It is exported as a type from +`/tls/core`; HTTP hosts only require this method from their TLS provider. + +The interface groups operations as follows: + +| Operations | Responsibility | +| --- | --- | +| `query`, `set-default-ca` | Cipher/CA/identity queries and provider-local trust changes. | +| `create-context`, `release-context` | Validate TLS options and manage opaque configuration handles. | +| `connect`, `create-server` | Create a transport for the supplied guest identifier. | +| `socket-operation`, `server-operation` | Closed enums of inspection and control operations. | +| `write`, `end`, `release` | Stream writes, completion acknowledgements, and socket cleanup. | +| `is-available`, `start-tls` | Optional upgrades of owned WASI streams using the WASI TLS contract. | + +Dispatch events after the initiating import returns. `target` is the guest +socket/server ID. Accepted sockets use a separate positive 31-bit identifier +range. The guest installs an accepted socket when it receives `secureConnection`. +`write` events carry `{ token, error? }` and complete exactly one pending write or +end callback. Pause native reads after delivering a data chunk; the guest's +`resume` operation signals available buffer capacity. `core.ts` and `host-node.ts` +define the event payloads and closed operation argument lists together. + +Options and inspection values use `wire.ts`'s graph JSON format, `{ root, nodes }`. +Primitive values are inline; `{ ref: index }` references `bytes`, `array`, or +`object` nodes. Undefined and non-finite numbers have explicit tagged values. +Object nodes contain key/value pairs and may carry an error name. This preserves +binary fields, error codes, and self-signed certificate issuer cycles. Stream +data itself is `list`, not JSON. Functions and native handles never cross this +boundary. WIT result errors carry `name`, `message`, and optional `code`. + +## Compatibility and engine limits + +The full Node 24.20 module export list is present. Local tests cover mutual TLS, +hostname/trust rejection, custom identity checks, ALPN, certificate graphs, +keying material, early `end()`, and backpressure. The component fixture adapts +the documentation's echo client/server to use PEM arguments and finite input +instead of filesystem reads and `process.stdin`. It also exercises HTTPS in +both directions, and the HTTP/2 component fixture exercises the shared provider. + +Explicit unsupported operations include wrapping an arbitrary guest socket with +`new TLSSocket`, native `X509Certificate` return values, PSK/SNI/ALPN/lookup +callbacks, OpenSSL engine options, and server `newSession`, `resumeSession`, +`OCSPRequest`, `keylog`, and raw TCP `connection` events. Use `connect`, `getPeerCertificate`, +`getCertificate`, and `Server.addContext` where applicable. Socket `session`, +`keylog`, and `OCSPResponse` events are delivered. Renegotiation limits are host +policy; changing `CLIENT_RENEG_LIMIT` or `CLIENT_RENEG_WINDOW` throws. The socket +is a portable `Duplex`; it is not an instance of the separate `node:net` shim's +Socket class. + +StarlingMonkey runs the component fixture. QuickJS is explicitly skipped with +`TODO(unskip)` because componentize-qjs cannot link the shared WASI TLS resource +types. Export a synchronous starter for long-lived event-driven work: a guest +export cannot await a promise resolved solely by a future independent host +callback. The fixture's starter/status exports demonstrate this engine boundary. diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md deleted file mode 100644 index 939af35ae..000000000 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# Node TLS capability - -Target: Node.js **24.20.0**, commit -`71b8b174857e25106d39b61a9e6f30d927da8b01`. The audit used `lib/tls.js`, -`lib/internal/tls/{wrap,secure-context}.js`, and `doc/api/tls.md`. -The rolling Node documentation may describe a newer major release. -`alpn.ts` adapts Node's MIT-licensed protocol conversion and retains its notice. -Cryptography, certificate parsing, and the TLS protocol remain in the injected -provider. The guest reuses the existing portable classic stream implementation. -The audited unenv 2.0.0-rc.24 TLS module consists largely of stubs. - -Applications continue to import `node:tls`. Jco replaces that import and adds -`jco:node/tls@0.1.0` plus the `tls-callbacks` guest export. Importing the module -does not open sockets or read the host's trust store. The default provider denies -operations with `ERR_JCO_TLS_ADAPTER_REQUIRED`. - -## One TLS capability - -`node:tls`, direct HTTPS, and secure direct HTTP/2 use `jco:node/tls`. -HTTP protocols pass one-use TLS configuration handles to their transport host, -so their WIT interfaces no longer duplicate certificate and cipher options. -Bind those HTTP hosts to the **same TLS provider instance**. The native provider -consumes the handle before starting the HTTP operation; handles from another -provider, or already consumed handles, are rejected. - -HTTPS over WASI sockets calls `jco:node/tls.start-tls`. `createWasiTlsBridge` -delegates that operation to a supplied `wasi:tls/types@0.2.0-draft` provider and -reuses its future, connection, and WASI IO resources. The draft only supports -verified client stream upgrades. It cannot supply Node servers, per-connection -trust options, certificate inspection, or cipher controls; the bridge denies -those operations. A native provider can also accept `{ wasiTls }` to provide -both paths. The older `/tls/host` exports remain WASI providers for existing -embedders; they are not the primary Node capability. - -HTTPS via `wasi-http` is unsupported: outgoing-handler cannot accept the TLS -provider or its configuration. Select `direct` or `wasi-sockets`. HTTP/2 over -WASI sockets remains h2c-only. - -## Binding the native provider - -Use one factory per component. With `--instantiation`, the import keys are the -WIT interface names without their versions; Jco's deny-by-default module map -applies only to ESM output: - -```js -import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; -import { createTlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node"; -import * as wasiTlsTypes from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host"; -import { createHttpHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node"; -import { createHttp2Host } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node"; -import { instantiate } from "./component.js"; - -const tls = createTlsHost({ onCallbackError: (error) => console.error(error) }); -let instance; -instance = await instantiate(undefined, { - ...new WASIShim().getImportObject(), - "jco:node/tls": tls, - "wasi:tls/types": wasiTlsTypes, - "jco:node/http": createHttpHost(() => instance.httpCallbacks, tls), - "jco:node/http2": createHttp2Host(() => instance.http2Callbacks, tls), -}); -if (instance.tlsCallbacks) tls.attachCallbacks(instance.tlsCallbacks); -// Call component exports. When the component is finished: -// tls.dispose(); -``` - -Transpile with JSPI and promising exports. For direct HTTP, also select async -imports `jco:node/http@0.1.0#request`, `#[method]server.listen`, -`#[method]server.close`, and `#[method]server.get-connections`. Direct HTTP/2 has -the existing async session/stream selectors. Explicit `--map` selections for -the HTTP providers add those selectors automatically. Supplying import objects -at instantiation does not retroactively change the generated binding mode. - -The native provider grants native network listeners/connections and trust-store -queries. `setDefaultCACertificates` changes only that provider's trust policy, -including its subsequent HTTPS and HTTP/2 configurations. It does not change -the embedding process's default CA store. Explicit `SecureContext` objects -retain their original trust settings. - -`dispose()` destroys sockets, including incomplete handshakes, closes listeners, -and releases contexts. Guest callback traps dispose the provider and are passed -to `onCallbackError`. Standalone contexts and closed server objects remain -provider-owned until disposal, allowing normal inspection and server reuse. - -## Supplying your own implementation - -Import `TlsHost` and `TlsCallbacks` from `/tls/core`. Start with the deny provider -and replace only the operations your component needs: - -```ts -import denied from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host"; -import type { TlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/core"; - -const host: TlsHost = { - ...denied, - // Implement permitted operations; every other operation still fails explicitly. -}; -``` - -For a WASI provider, `createWasiTlsBridge(provider)` already supplies such an -object. No alias mapping is necessary when supplying the object directly under -the binding's existing import key. A custom HTTP transport must understand its -TLS provider's configuration handles. `TlsConfigurationProvider.takeContextOptions` is the native HTTP adapters' local -integration contract, not an additional WIT import. It is exported as a type from -`/tls/core`; HTTP hosts only require this method from their TLS provider. - -The interface groups operations as follows: - -| Operations | Responsibility | -| -------------------------------------- | -------------------------------------------------------------------- | -| `query`, `set-default-ca` | Cipher/CA/identity queries and provider-local trust changes. | -| `create-context`, `release-context` | Validate TLS options and manage opaque configuration handles. | -| `connect`, `create-server` | Create a transport for the supplied guest identifier. | -| `socket-operation`, `server-operation` | Closed enums of inspection and control operations. | -| `write`, `end`, `release` | Stream writes, completion acknowledgements, and socket cleanup. | -| `is-available`, `start-tls` | Optional upgrades of owned WASI streams using the WASI TLS contract. | - -Dispatch events after the initiating import returns. `target` is the guest -socket/server ID. Accepted sockets use a separate positive 31-bit identifier -range. The guest installs an accepted socket when it receives `secureConnection`. -`write` events carry `{ token, error? }` and complete exactly one pending write or -end callback. Pause native reads after delivering a data chunk; the guest's -`resume` operation signals available buffer capacity. `core.ts` and `host-node.ts` -define the event payloads and closed operation argument lists together. - -Options and inspection values use `wire.ts`'s graph JSON format, `{ root, nodes }`. -Primitive values are inline; `{ ref: index }` references `bytes`, `array`, or -`object` nodes. Undefined and non-finite numbers have explicit tagged values. -Object nodes contain key/value pairs and may carry an error name. This preserves -binary fields, error codes, and self-signed certificate issuer cycles. Stream -data itself is `list`, not JSON. Functions and native handles never cross this -boundary. WIT result errors carry `name`, `message`, and optional `code`. - -## Compatibility boundaries and fixtures - -The full Node 24.20 module export list is present. Local tests cover mutual TLS, -hostname/trust rejection, custom identity checks, ALPN, certificate graphs, -keying material, early `end()`, and backpressure. The component fixture adapts -the documentation's echo client/server to use PEM arguments and finite input -instead of filesystem reads and `process.stdin`. It also exercises HTTPS in -both directions, and the HTTP/2 component fixture exercises the shared provider. - -Explicit unsupported operations include wrapping an arbitrary guest socket with -`new TLSSocket`, native `X509Certificate` return values, PSK/SNI/ALPN/lookup -callbacks, OpenSSL engine options, and server `newSession`, `resumeSession`, -`OCSPRequest`, `keylog`, and raw TCP `connection` events. Use `connect`, `getPeerCertificate`, -`getCertificate`, and `Server.addContext` where applicable. Socket `session`, -`keylog`, and `OCSPResponse` events are delivered. Renegotiation limits are host -policy; changing `CLIENT_RENEG_LIMIT` or `CLIENT_RENEG_WINDOW` throws. The socket -is a portable `Duplex`; it is not an instance of the separate `node:net` shim's -Socket class. - -StarlingMonkey runs the component fixture. QuickJS is explicitly skipped with -`TODO(unskip)` because componentize-qjs cannot link the shared WASI TLS resource -types. Export a synchronous starter for long-lived event-driven work: a guest -export cannot await a promise resolved solely by a future independent host -callback. The fixture's starter/status exports demonstrate this engine boundary.