diff --git a/crates/xtask/src/generate/webidl_tests.rs b/crates/xtask/src/generate/webidl_tests.rs index 097436975..d4e114f62 100644 --- a/crates/xtask/src/generate/webidl_tests.rs +++ b/crates/xtask/src/generate/webidl_tests.rs @@ -12,7 +12,7 @@ const IDL_VERSION_MINOR: u64 = 0; const IDL_VERSION_PATCH: u64 = 1; pub(crate) fn run() -> Result<()> { - for file in read_dir("packages/jco/test/fixtures/idl")? { + for file in read_dir("packages/jco/test/fixtures/wit/idl")? { let file = file?; let file_name = file.file_name(); let file_name_str = file_name.to_string_lossy().to_string(); @@ -55,7 +55,12 @@ pub(crate) fn run() -> Result<()> { }, )?; - let wit_str = wit.to_string(); + // Preserve the fixture's existing workaround for the window.window name + // collision until webidl2wit disambiguates resource and method names. + let wit_str = wit.to_string().replace( + " window: func() -> window-proxy;", + " get-window: func() -> window-proxy;", + ); let world_definition = if interface_name == "console" { format!( @@ -72,7 +77,7 @@ pub(crate) fn run() -> Result<()> { .to_string() }; - let output_file = format!("packages/jco/test/fixtures/idl/{name}.wit"); + let output_file = format!("packages/jco/test/fixtures/wit/idl/{name}.wit"); write( &output_file, format!( diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index b91b2b5c7..93b1104fd 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -106,29 +106,30 @@ is planned. > It is the only such alias: modules added after the split, including `node:assert`, are > available only under a versioned entry point. -| Imports | Implementation | Notes | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | -| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | -| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | -| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | -| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | -| `node:module` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module` | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API` -- see below. Requires no WIT capability. | -| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | -| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | -| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | -| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | -| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | -| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | -| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | -| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Outbound client API over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP transport. Server listening is explicitly unsupported. | -| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | -| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | -| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | -| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | -| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | -| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | -| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | +| Imports | Implementation | Notes | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:assert`, `node:assert/strict` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assert` | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. | +| `node:path`, `node:path/posix`, `node:path/win32` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path` | Jco's portable path implementation, connected to `wasi:cli/environment` for the guest working directory and environment. | +| `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | +| `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | +| `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | +| `node:module` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module` | Classification, source maps and `require.resolve` are exact. Everything that **loads** throws `ERR_JCO_UNSUPPORTED_NODE_API` -- see below. Requires no WIT capability. | +| `node:async_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks` | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store -- see below. | +| `node:diagnostics_channel` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel` | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | +| `node:child_process` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process` | Synchronous APIs over an explicit application-provided host capability; denied by default. | +| `node:cluster` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster` | Primary/worker control over an explicit host capability. Partly unsupported -- see below. | +| `node:console` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console` | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | +| `node:dns`, `node:dns/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns` | Name resolution over an explicit host capability; denied by default. | +| `node:fs`, `node:fs/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs` | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. | +| `node:http` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http` | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation -- see below. Servers need `direct` or `wasi-sockets`. | +| `node:https` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https` | The `node:http` core with the `https:` profile and a TLS-aware `Agent`; same implementation selection. TLS uses the `direct` host or an explicit `wasi:tls` provider -- see below. | +| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | +| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | +| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | +| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | +| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | +| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | +| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | ### Stream consumers and iterable streams @@ -260,14 +261,13 @@ need this import. Bundled source can use the documented Node 24 streaming decoder directly: ```js -import { Buffer } from "node:buffer"; -import { StringDecoder } from "node:string_decoder"; +import { Buffer } from 'node:buffer'; +import { StringDecoder } from 'node:string_decoder'; -const decoder = new StringDecoder("utf8"); +const decoder = new StringDecoder('utf8'); export function decode() { - return decoder.write(Buffer.from([0xf0, 0x9f])) + - decoder.end(Buffer.from([0x8c, 0x8d])); + return decoder.write(Buffer.from([0xf0, 0x9f])) + decoder.end(Buffer.from([0x8c, 0x8d])); } ``` @@ -602,12 +602,12 @@ jco transpile component.wasm \ The guest code is ordinary Node: ```js -import { Session } from "node:inspector/promises"; +import { Session } from 'node:inspector/promises'; const session = new Session(); session.connect(); -const { result } = await session.post("Runtime.evaluate", { expression: "6 * 7" }); -result.value; // 42, evaluated in the host isolate +const { result } = await session.post('Runtime.evaluate', { expression: '6 * 7' }); +result.value; // 42, evaluated in the host isolate ``` Argument validation, session state, the `EventEmitter` surface, and error reconstruction all run @@ -619,7 +619,7 @@ which is best-effort for functions, symbols, and cycles. #### The host calls back into the component The inspector's two callbacks -- a `post` response and a session notification -- run the other way, -from host to guest. A component cannot implement a resource declared on an *imported* interface (its +from host to guest. A component cannot implement a resource declared on an _imported_ interface (its methods would run host-side), so the callbacks are a guest-**exported** interface, `jco:node/inspector-callbacks@0.1.0`, holding one resource per callback kind: a one-shot `post-callback` and a long-lived `notification-listener`. When bundled source imports @@ -630,8 +630,8 @@ alongside the entry -- neither is written by hand. The embedder wires the exported interface to the host adapter after instantiation: ```js -import * as inspectorHost from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node"; -import * as component from "./transpiled/component.js"; +import * as inspectorHost from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node'; +import * as component from './transpiled/component.js'; inspectorHost.attachCallbacks(component.inspectorCallbacks); ``` @@ -662,15 +662,15 @@ internals (`_load`, `_resolveFilename`, `_findPath`, `_nodeModulePaths`, and the **Everything else is real**, because it is classification or arithmetic: -| Surface | Behavior | -| --- | --- | -| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | -| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | -| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | -| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | -| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | -| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | -| `new Module(id)` | Constructs, with Node's own-property shape. Its *methods* are what need a loader | +| Surface | Behavior | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | +| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | +| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | +| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | +| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | +| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | +| `new Module(id)` | Constructs, with Node's own-property shape. Its _methods_ are what need a loader | #### `createRequire` @@ -683,10 +683,10 @@ a refusal -- it answers truthfully: ```js const require = createRequire(import.meta.url); -require.resolve("node:path"); // "node:path", exactly as Node answers -require.resolve("lodash"); // throws MODULE_NOT_FOUND -- which is the truth here -require.cache; // genuinely empty -require.main; // genuinely undefined +require.resolve('node:path'); // "node:path", exactly as Node answers +require.resolve('lodash'); // throws MODULE_NOT_FOUND -- which is the truth here +require.cache; // genuinely empty +require.main; // genuinely undefined ``` #### A caveat on `builtinModules` @@ -770,7 +770,10 @@ room for a future browser implementation. The `node:http` adapter implements both client and server NodeJS HTTP APIs, with outbound `request()` and `get()` calls with Node-style `ClientRequest` -and buffered `IncomingMessage` objects along with `http.Server`. +and buffered `IncomingMessage` objects along with `http.Server`. `node:https` +is the same core driven with the `https:` protocol, port 443, and a TLS-aware +`Agent`, exactly as `lib/https.js` reuses `_http_client` and `_http_server` +upstream; it shares the implementation selection below. As this API obviously requires access to the outside world of some sort, and there are actually many ways to achieve that on the host side, you must select @@ -804,10 +807,89 @@ connections. > [!WARNING] > All modes currently buffer complete request and response bodies. -Connection pooling, upgrades, CONNECT tunnels, and HTTPS are explicit gaps. +Connection pooling, upgrades, and CONNECT proxy tunnels are explicit gaps. Unavailable operations throw `ERR_JCO_UNSUPPORTED_NODE_API` rather than silently doing nothing. +#### HTTPS + +`node:https` exposes Node 24's six exports: `Agent`, `globalAgent`, `Server`, +`createServer`, `get`, and `request`. `https.Agent` subclasses `http.Agent` on +both prototype chains, keeps Node's `defaultPort`/`protocol`/`maxCachedSessions` +defaults and its TLS session cache, and produces the same 23-field `getName()` +key as Node, so option bags pool the way they would natively. Requests reject +non-`https:` protocols with `ERR_INVALID_PROTOCOL` and elide `:443` from the +authority, and `https.get()` ends the request itself. + +TLS crosses the component boundary as a typed `tls-options` record on the +`jco:node/http@0.1.0` request and server options. It carries the serializable +subset of Node's `tls.connect` / `tls.createServer` options: `key`, `cert`, +`pfx`, `passphrase`, `ca`, `crl`, `dhparam`, `ciphers`, `ecdhCurve`, `sigalgs`, +`minVersion`, `maxVersion`, `secureProtocol`, `secureOptions`, +`sessionIdContext`, `honorCipherOrder`, `ALPNProtocols`, `servername`, +`rejectUnauthorized`, and `requestCert`. Material fields stay lists, so a +`key: [rsa, ecdsa]` bundle reaches the host intact. Options with no typed +representation -- `checkServerIdentity`, `SNICallback`, `ALPNCallback`, +`pskCallback`, `secureContext`, `session`, `ticketKeys`, and the OpenSSL engine +options -- throw `ERR_JCO_UNSUPPORTED_NODE_API` naming the option rather than +being dropped. + +| Value | `node:https` behaviour | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `direct` | Clients and servers. The opt-in Node provider routes `https` requests to `node:https.request` with the carried TLS options, and a server carrying a `tls` record to `node:https.createServer`, so the host's own TLS stack terminates the connection. | +| `wasi-sockets` | 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 enabled `https-wasi-tls.ts` suite includes deterministic +local TLS tests and a separately named public test requiring DNS and TCP/443 to +`example.com` (20-second execution deadline). + +An `https.Server` always carries its `tls` record, even when no material was +supplied, so an implementation without a TLS stack refuses it; the `direct` +host then behaves like Node, which constructs the server and fails each +handshake. Because `jco:node/http@0.1.0` gained the record in place, a project +whose `wit/deps/jco-node-0.1.0/http.wit` predates it must delete that file so +the next `jco componentize` reinstalls the current interface: injection never +overwrites an existing dependency file. + ### HTTP/2 Client and server code uses Node's normal session and stream APIs: @@ -835,12 +917,11 @@ jco componentize component.js --wit wit --bundle \ --with-nodejs-http2-via direct -o component.wasm ``` -| Value | Behavior | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | +| Value | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | | `wasi-sockets` | Cleartext prior-knowledge HTTP/2 (`h2c`) clients and TCP servers, with guest-side framing, HPACK, settings, ping, reset, and stream/connection flow control. | -| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | - +| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | By default, the provider rejects both `connect()` and server construction with `ERR_JCO_HTTP2_ADAPTER_REQUIRED`. @@ -954,7 +1035,7 @@ These modules contain useful portable pieces, but their complete public surfaces also require operating-system access, Node internals, an event loop, or a larger set of coordinated shims: -`node:crypto`, `node:dgram`, `node:http2`, `node:https`, `node:net`, +`node:crypto`, `node:dgram`, `node:http2`, `node:net`, `node:perf_hooks`, `node:process`, `node:repl`, `node:sqlite`, `node:stream`, `node:stream/promises`, `node:stream/web`, `node:timers`, `node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, diff --git a/package.json b/package.json index 588d9da88..84191e8a6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint:fix": "pnpm run -r lint:fix", "test:setup:puppeteer": "node scripts/install-puppeteer.mjs", "test:setup:firefox": "node scripts/install-puppeteer.mjs firefox", - "test:examples": "pnpm run -r all" + "test:examples": "pnpm run -r all", + "build:test:idl": "node scripts/create-idl-component.mjs" }, "devDependencies": { "@actions/github": "^6.0.1", diff --git a/packages/jco-node-fs/package.json b/packages/jco-node-fs/package.json index 7b38b5721..6a6adb2ba 100644 --- a/packages/jco-node-fs/package.json +++ b/packages/jco-node-fs/package.json @@ -36,7 +36,7 @@ "lint": "cargo clippy --all-targets -- -D warnings", "prepare-release": "napi prepublish -t npm --no-gh-release --skip-optional-publish --root-publisher pnpm", "pretest": "pnpm run build:debug", - "test": "node --test test/*.test.js" + "test": "node --test test/*.js" }, "devDependencies": { "@napi-rs/cli": "^3.4.1", diff --git a/packages/jco-node-fs/test/fadvise.test.js b/packages/jco-node-fs/test/fadvise.js similarity index 100% rename from packages/jco-node-fs/test/fadvise.test.js rename to packages/jco-node-fs/test/fadvise.js diff --git a/packages/jco-node-fs/test/rename.test.js b/packages/jco-node-fs/test/rename.js similarity index 100% rename from packages/jco-node-fs/test/rename.test.js rename to packages/jco-node-fs/test/rename.js diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index db5f7b6b3..efabd2217 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -28,6 +28,7 @@ Below is a list of utilties provided by `@bytecodealliance/jco-std`: | `wasi/0.2.x/node/24.x.x/fs` | `node:fs` and `node:fs/promises` over an explicit host capability | | `wasi/0.2.x/node/24.x.x/http` | `node:http` API with direct, WASI sockets, and WASI HTTP implementations | | `wasi/0.2.x/node/24.x.x/http2` | `node:http2` API with direct and cleartext WASI sockets implementations | +| `wasi/0.2.x/node/24.x.x/https` | `node:https` API sharing the `node:http` core and implementations | | `wasi/0.2.x/node/24.x.x/os` | `node:os` guest adapter over an explicit host capability | | `wasi/0.2.x/node/24.x.x/path` | `node:path` adapter, Node 24 on WASI p2 | | `wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local `node:string_decoder` implementation for Node 24 | @@ -63,7 +64,8 @@ Below is a list of utilties provided by `@bytecodealliance/jco-std`: | `wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets` | `node:http` implementation over WASI Preview 2 sockets | | `wasi/0.2.x/node/24.x.x/http/impl/wasi-http` | `node:http` implementation over WASI Preview 2 HTTP | | `wasi/0.2.x/node/24.x.x/http/host` | Deny-by-default host for `jco:node/http` | -| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` | +| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` and `node:https` | +| `wasi/0.2.x/node/24.x.x/https/core` | `node:https` core shared by the selectable implementations | | `wasi/0.2.x/node/24.x.x/os/host` | Deny-by-default host for `jco:node/os` | | `wasi/0.2.x/node/24.x.x/os/host/node` | Opt-in host over the runtime's real `node:os` | | `node/path` | Legacy unversioned alias for `wasi/0.2.x/node/24.x.x/path` | @@ -146,8 +148,8 @@ Jco can bundle the following Node.js APIs into JavaScript WebAssembly components `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` and the application-provided `jco:node/inspector@0.1.0` capability, with the host calling back into the component through a guest-exported callbacks interface; -- the `node:http` API, with selectable direct, `wasi:sockets`, and `wasi:http` - implementations; +- the `node:http` and `node:https` APIs, with selectable direct, + `wasi:sockets`, and `wasi:http` implementations; - `node:buffer`, with its modern core provided by Jco's audited unenv compatibility layer; - `node:querystring`, provided by Jco's audited unenv compatibility layer; @@ -577,7 +579,19 @@ const server = createServer((request, response) => { server.listen(8080, "127.0.0.1"); ``` -Bundle it and select how `node:http` reaches the host: +`node:https` is the same core with the `https:` profile, port 443, and a +TLS-aware `Agent`. Servers take Node's TLS options and clients take the +`tls.connect` subset (`ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, +`ALPNProtocols`, and so on), which cross the boundary as a typed record: + +```js +import { createServer, get } from "node:https"; + +createServer({ key, cert }, (request, response) => response.end("secure")).listen(8443); +get("https://localhost:8443/", { ca: cert }, (response) => response.resume()); +``` + +Bundle it and select how `node:http` and `node:https` reach the host: ```console jco componentize component.js --wit wit --bundle \ @@ -589,12 +603,32 @@ jco componentize component.js --wit wit --bundle \ - `direct` (the default), which adds `jco:node/http@0.1.0`; its default provider throws `ERR_JCO_HTTP_ADAPTER_REQUIRED`, and a Node application can explicitly map `wasi/0.2.x/node/24.x.x/http/host/node` when transpiling. It supports - clients and servers through real `node:http`; -- `wasi-sockets`, which implements HTTP/1.1 in the guest using only Preview 2 - socket and IO capabilities, including TCP servers; and + clients and servers through real `node:http`, and terminates TLS for + `node:https` through real `node:https`; +- `wasi-sockets`, which implements HTTP/1.1 in the guest over Preview 2 TCP. + TLS connections implicitly require the additional `wasi:tls` capability; + `node:https` adds its import automatically. Verified HTTPS clients work with + an explicit host provider; HTTPS servers remain unsupported by the pinned + client-only TLS interface; and - `wasi-http`, which translates requests to Preview 2 - `wasi:http/outgoing-handler`. It rejects `Server` construction immediately - because an outgoing-handler cannot listen for arbitrary inbound connections. + `wasi:http/outgoing-handler`, including `https` URLs, though per-request TLS + options are refused because the outgoing-handler owns certificate + validation. It rejects `Server` construction immediately because an + outgoing-handler cannot listen for arbitrary inbound connections. + +For HTTPS over sockets, explicitly map `wasi:tls/types@0.2.0-draft` to +`@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node`. TLS support is backend-independent. +The [local TLS contract](wit/tls-0.2.0-draft/README.md) shares `wasi:io@0.2.12` +resources with sockets directly. The default mapping denies TLS before connecting. The Node provider wraps the existing TCP streams, validates the +certificate chain and hostname, and offers HTTP/1.1 ALPN. The draft accepts only +`servername` (and `rejectUnauthorized: true`); other per-request TLS settings, +including `ca`, are rejected. Hosts can configure trust with `createTlsProvider`. +See the [provider example](../../docs/src/interop/nodejs-builtins.md#https). + +> [!NOTE] +> `componentize-qjs` 0.4.3 currently fails to link the TLS interface's shared IO +> resources during snapshot initialization. StarlingMonkey is a workaround for +> this build-time issue. When the selected world is missing a required import or callback export, Jco edits that world in place, adds generated comments and declarations, installs @@ -608,9 +642,10 @@ WIT package defines multiple worlds. The initial implementation buffers each request and response at the implementation boundary. Client and server objects retain Node-style callbacks -and events inside the guest. Connection pooling, upgrades, CONNECT tunnels, -HTTPS, and persistent HTTP/1.1 connections in the `wasi-sockets` implementation -are not implemented; unavailable operations throw explicit errors. +and events inside the guest. Connection pooling, upgrades, CONNECT proxy +tunnels, and persistent HTTP/1.1 connections in the `wasi-sockets` +implementation are not implemented; unavailable operations throw explicit +errors. ### HTTP/2 diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 45e59a185..75438f40c 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -24,7 +24,8 @@ }, "files": [ "dist", - "wit/node-0.1.0" + "wit/node-0.1.0", + "wit/tls-0.2.0-draft" ], "type": "module", "exports": { @@ -115,9 +116,9 @@ "default": "./dist/wasi/0.2.x/node/24.x.x/http/core.js" }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets": { - "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.d.ts", - "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js", - "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js" + "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js" }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-http": { "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.d.ts", @@ -167,6 +168,16 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/http2-host-node.d.ts", "node": "./dist/wasi/0.2.x/node/24.x.x/http2-host-node.js" }, + "./wasi/0.2.x/node/24.x.x/https": { + "types": "./dist/wasi/0.2.x/node/24.x.x/https.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/https.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/https.js" + }, + "./wasi/0.2.x/node/24.x.x/https/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/https/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/https/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/https/core.js" + }, "./wasi/0.2.x/node/24.x.x/path": { "types": "./dist/wasi/0.2.x/node/24.x.x/path.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/path.js", @@ -337,6 +348,14 @@ "types": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.d.ts", "browser": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.js", "default": "./dist/wasi/0.2.3/http/adapters/hono/middleware/env.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls-host.d.ts", + "default": "./dist/wasi/0.2.x/node/24.x.x/tls-host.js" + }, + "./wasi/0.2.x/node/24.x.x/tls/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/tls-host-node.d.ts", + "node": "./dist/wasi/0.2.x/node/24.x.x/tls-host-node.js" } }, "scripts": { @@ -348,11 +367,12 @@ "build:bindings:wasi:http:0.2.6": "WIT_PATH=wit/http-v0m2p6 OUTPUT_DIR_PATH=src/wasi/0.2.6/generated/types node scripts/generate-wasi-bindings.mjs", "build:bindings:wasi:http:0.2.12": "WIT_PATH=wit/http-v0m2p12 OUTPUT_DIR_PATH=src/wasi/0.2.12/generated/types node scripts/generate-wasi-bindings.mjs", "build:bindings:wasi:http:0.2.3": "WIT_PATH=wit/http-v0m2p3 OUTPUT_DIR_PATH=src/wasi/0.2.3/generated/types node scripts/generate-wasi-bindings.mjs", - "build:bindings": "pnpm run build:bindings:wasi:http:0.2.3 && pnpm run build:bindings:wasi:http:0.2.6 && pnpm run build:bindings:wasi:http:0.2.12", + "build:bindings": "pnpm run build:bindings:wasi:http:0.2.3 && pnpm run build:bindings:wasi:http:0.2.6 && pnpm run build:bindings:wasi:http:0.2.12 && pnpm run build:bindings:wasi:tls", "build:ts": "tsc", "build": "pnpm run setup:jco-transpile:build && pnpm run build:bindings && pnpm run build:ts", "test": "vitest run -c test/vitest.ts", - "prepack": "pnpm run build" + "prepack": "pnpm run build", + "build:bindings:wasi:tls": "node scripts/generate-tls-bindings.mjs" }, "dependencies": { "minimatch": "10.2.6" @@ -369,5 +389,13 @@ "typescript": "catalog:", "vitest": "^4.0.8", "which": "^5.0.0" + }, + "peerDependencies": { + "@bytecodealliance/preview2-shim": "^0.24.0" + }, + "peerDependenciesMeta": { + "@bytecodealliance/preview2-shim": { + "optional": true + } } } diff --git a/packages/jco-std/scripts/generate-tls-bindings.mjs b/packages/jco-std/scripts/generate-tls-bindings.mjs new file mode 100644 index 000000000..b86c6bc4f --- /dev/null +++ b/packages/jco-std/scripts/generate-tls-bindings.mjs @@ -0,0 +1,16 @@ +// Generate bindings for the documented local TLS contract. +import { generateGuestTypes, writeFiles } from "@bytecodealliance/jco-transpile"; +const files = await generateGuestTypes("wit/tls-0.2.0-draft", { + outDir: "src/wasi/0.2.12/generated/types/tls", +}); +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); +for (const [path, contents] of Object.entries(files)) { + files[path] = encoder.encode( + decoder + .decode(contents) + .replace(/[ \t]+$/gm, "") + .trimEnd() + "\n", + ); +} +await writeFiles(files); diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts index c800c96d2..b7bc48e03 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-cli-environment.d.ts @@ -1,10 +1,10 @@ declare module 'wasi:cli/environment@0.2.12' { /** * Get the POSIX-style environment variables. - * + * * Each environment variable is provided as a pair of string variable names * and string value. - * + * * Morally, these are a value import, but until value imports are available * in the component model, this import function should return the same * values each time it is called. diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts index 3e6b3ff4c..3744c8078 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-clocks-monotonic-clock.d.ts @@ -2,10 +2,10 @@ declare module 'wasi:clocks/monotonic-clock@0.2.12' { /** * Read the current value of the clock. - * + * * The clock is monotonic, therefore calling this function repeatedly will * produce a sequence of non-decreasing values. - * + * * For completeness, this function traps if it's not possible to represent * the value of the clock in an `instant`. Consequently, implementations * should ensure that the starting time is low enough to avoid the diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts index 0ac5fcc82..e6d9a5253 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-config-store.d.ts @@ -1,14 +1,14 @@ declare module 'wasi:config/store@0.2.0-rc.1' { /** * Gets a configuration value of type `string` associated with the `key`. - * + * * The value is returned as an `option`. If the key is not found, * `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. */ export function get(key: string): string | undefined; /** * Gets a list of configuration key-value pairs of type `string`. - * + * * If an error occurs, an `Err(error)` is returned. */ export function getAll(): Array<[string, string]>; diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts index a1e63d979..bcc6d9139 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-incoming-handler.d.ts @@ -7,7 +7,7 @@ declare module 'wasi:http/incoming-handler@0.2.12' { * method, which allows execution to continue after the response has been * sent. This enables both streaming to the response body, and performing other * work. - * + * * The implementor of this function must write a response to the * `response-outparam` before returning, or else the caller will respond * with an error on its behalf. diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts index 2ec86da20..fb43ce6cc 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-http-types.d.ts @@ -6,13 +6,13 @@ declare module 'wasi:http/types@0.2.12' { /** * Attempts to extract a http-related `error` from the wasi:io `error` * provided. - * + * * Stream operations which return * `wasi:io/stream.stream-error.last-operation-failed` have a payload of * type `wasi:io/error.error` with more information about the operation * that failed. This payload can be passed through to this function to see * if there's http-related information about the error to return. - * + * * Note that this function is fallible because not all io-errors are * http-related errors. */ @@ -265,18 +265,18 @@ declare module 'wasi:http/types@0.2.12' { } /** * Field keys are always strings. - * + * * Field keys should always be treated as case insensitive by the `fields` * resource for the purposes of equality checking. - * + * * # Deprecation - * + * * This type has been deprecated in favor of the `field-name` type. */ export type FieldKey = string; /** * Field names are always strings. - * + * * Field names should always be treated as case insensitive by the `fields` * resource for the purposes of equality checking. */ @@ -300,26 +300,26 @@ declare module 'wasi:http/types@0.2.12' { */ export type StatusCode = number; export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; - + export class Fields implements Disposable { /** * Construct an empty HTTP Fields. - * + * * The resulting `fields` is mutable. */ constructor() /** * Construct an HTTP Fields. - * + * * The resulting `fields` is mutable. - * + * * The list represents each name-value pair in the Fields. Names * which have multiple values are represented by multiple entries in this * list with the same name. - * + * * The tuple is a pair of the field name, represented as a string, and * Value, represented as a list of bytes. - * + * * An error result will be returned if any `field-name` or `field-value` is * syntactically invalid, or if a field is forbidden. */ @@ -339,9 +339,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Set all of the values for a name. Clears any existing values for that * name, if they have been set. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` or any of * the `field-value`s are syntactically invalid. */ @@ -349,9 +349,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Delete all values for a name. Does nothing if no values for the name * exist. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` is * syntactically invalid. */ @@ -359,9 +359,9 @@ declare module 'wasi:http/types@0.2.12' { /** * Append a value for a name. Does not change or delete any existing * values for that name. - * + * * Fails with `header-error.immutable` if the `fields` are immutable. - * + * * Fails with `header-error.invalid-syntax` if the `field-name` or * `field-value` are syntactically invalid. */ @@ -369,11 +369,11 @@ declare module 'wasi:http/types@0.2.12' { /** * Retrieve the full set of names and values in the Fields. Like the * constructor, the list represents each name-value pair. - * + * * The outer list represents each name-value pair in the Fields. Names * which have multiple values are represented by multiple entries in this * list with the same name. - * + * * The names and values are always returned in the original casing and in * the order in which they will be serialized for transport. */ @@ -386,7 +386,7 @@ declare module 'wasi:http/types@0.2.12' { clone(): Fields; [Symbol.dispose](): void; } - + export class FutureIncomingResponse implements Disposable { /** * This type does not have a public constructor. @@ -400,14 +400,14 @@ declare module 'wasi:http/types@0.2.12' { subscribe(): Pollable; /** * Returns the incoming HTTP Response, or an error, once one is ready. - * + * * The outer `option` represents future readiness. Users can wait on this * `option` to become `some` using the `subscribe` method. - * + * * The outer `result` is used to retrieve the response or error at most * once. It will be success on the first call in which the outer option * is `some`, and error on subsequent calls. - * + * * The inner `result` represents that either the incoming HTTP Response * status and headers have received successfully, or that an error * occurred. Errors may also occur while consuming the response body, @@ -417,7 +417,7 @@ declare module 'wasi:http/types@0.2.12' { get(): Result, void> | undefined; [Symbol.dispose](): void; } - + export class FutureTrailers implements Disposable { /** * This type does not have a public constructor. @@ -432,19 +432,19 @@ declare module 'wasi:http/types@0.2.12' { /** * Returns the contents of the trailers, or an error which occurred, * once the future is ready. - * + * * The outer `option` represents future readiness. Users can wait on this * `option` to become `some` using the `subscribe` method. - * + * * The outer `result` is used to retrieve the trailers or error at most * once. It will be success on the first call in which the outer option * is `some`, and error on subsequent calls. - * + * * The inner `result` represents that either the HTTP Request or Response * body, as well as any trailers, were received successfully, or that an * error occurred receiving them. The optional `trailers` indicates whether * or not trailers were present in the body. - * + * * When some `trailers` are returned by this method, the `trailers` * resource is immutable, and a child. Use of the `set`, `append`, or * `delete` methods will return an error, and the resource must be @@ -453,7 +453,7 @@ declare module 'wasi:http/types@0.2.12' { get(): Result, void> | undefined; [Symbol.dispose](): void; } - + export class IncomingBody implements Disposable { /** * This type does not have a public constructor. @@ -461,14 +461,14 @@ declare module 'wasi:http/types@0.2.12' { private constructor(); /** * Returns the contents of the body, as a stream of bytes. - * + * * Returns success on first call: the stream representing the contents * can be retrieved at most once. Subsequent calls will return error. - * + * * The returned `input-stream` resource is a child: it must be dropped * before the parent `incoming-body` is dropped, or consumed by * `incoming-body.finish`. - * + * * This invariant ensures that the implementation can determine whether * the user is consuming the contents of the body, waiting on the * `future-trailers` to be ready, or neither. This allows for network @@ -484,7 +484,7 @@ declare module 'wasi:http/types@0.2.12' { static finish(this_: IncomingBody): FutureTrailers; [Symbol.dispose](): void; } - + export class IncomingRequest implements Disposable { /** * This type does not have a public constructor. @@ -508,10 +508,10 @@ declare module 'wasi:http/types@0.2.12' { authority(): string | undefined; /** * Get the `headers` associated with the request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * The `headers` returned are a child resource: it must be dropped before * the parent `incoming-request` is dropped. Dropping this * `incoming-request` before all children are dropped will trap. @@ -524,7 +524,7 @@ declare module 'wasi:http/types@0.2.12' { consume(): IncomingBody; [Symbol.dispose](): void; } - + export class IncomingResponse implements Disposable { /** * This type does not have a public constructor. @@ -536,10 +536,10 @@ declare module 'wasi:http/types@0.2.12' { status(): StatusCode; /** * Returns the headers from the incoming response. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `incoming-response` is dropped. */ @@ -551,7 +551,7 @@ declare module 'wasi:http/types@0.2.12' { consume(): IncomingBody; [Symbol.dispose](): void; } - + export class OutgoingBody implements Disposable { /** * This type does not have a public constructor. @@ -559,11 +559,11 @@ declare module 'wasi:http/types@0.2.12' { private constructor(); /** * Returns a stream for writing the body contents. - * + * * The returned `output-stream` is a child resource: it must be dropped * before the parent `outgoing-body` resource is dropped (or finished), * otherwise the `outgoing-body` drop or `finish` will trap. - * + * * Returns success on the first call: the `output-stream` resource for * this `outgoing-body` may be retrieved at most once. Subsequent calls * will return error. @@ -574,7 +574,7 @@ declare module 'wasi:http/types@0.2.12' { * called to signal that the response is complete. If the `outgoing-body` * is dropped without calling `outgoing-body.finalize`, the implementation * should treat the body as corrupted. - * + * * Fails if the body's `outgoing-request` or `outgoing-response` was * constructed with a Content-Length header, and the contents written * to the body (via `write`) does not match the value given in the @@ -583,14 +583,14 @@ declare module 'wasi:http/types@0.2.12' { static finish(this_: OutgoingBody, trailers: Trailers | undefined): void; [Symbol.dispose](): void; } - + export class OutgoingRequest implements Disposable { /** * Construct a new `outgoing-request` with a default `method` of `GET`, and * `none` values for `path-with-query`, `scheme`, and `authority`. - * + * * * `headers` is the HTTP Headers for the Request. - * + * * It is possible to construct, or manipulate with the accessor functions * below, an `outgoing-request` with an invalid combination of `scheme` * and `authority`, or `headers` which are not permitted to be sent. @@ -601,7 +601,7 @@ declare module 'wasi:http/types@0.2.12' { /** * Returns the resource corresponding to the outgoing Body for this * Request. - * + * * Returns success on the first call: the `outgoing-body` resource for * this `outgoing-request` can be retrieved at most once. Subsequent * calls will return error. @@ -653,10 +653,10 @@ declare module 'wasi:http/types@0.2.12' { setAuthority(authority: string | undefined): void; /** * Get the headers associated with the Request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `outgoing-request` is dropped, or its ownership is transferred to * another component by e.g. `outgoing-handler.handle`. @@ -664,13 +664,13 @@ declare module 'wasi:http/types@0.2.12' { headers(): Headers; [Symbol.dispose](): void; } - + export class OutgoingResponse implements Disposable { /** * Construct an `outgoing-response`, with a default `status-code` of `200`. * If a different `status-code` is needed, it must be set via the * `set-status-code` method. - * + * * * `headers` is the HTTP Headers for the Response. */ constructor(headers: Headers) @@ -685,10 +685,10 @@ declare module 'wasi:http/types@0.2.12' { setStatusCode(statusCode: StatusCode): void; /** * Get the headers associated with the Request. - * + * * The returned `headers` resource is immutable: `set`, `append`, and * `delete` operations will fail with `header-error.immutable`. - * + * * This headers resource is a child: it must be dropped before the parent * `outgoing-request` is dropped, or its ownership is transferred to * another component by e.g. `outgoing-handler.handle`. @@ -696,7 +696,7 @@ declare module 'wasi:http/types@0.2.12' { headers(): Headers; /** * Returns the resource corresponding to the outgoing Body for this Response. - * + * * Returns success on the first call: the `outgoing-body` resource for * this `outgoing-response` can be retrieved at most once. Subsequent * calls will return error. @@ -704,7 +704,7 @@ declare module 'wasi:http/types@0.2.12' { body(): OutgoingBody; [Symbol.dispose](): void; } - + export class RequestOptions implements Disposable { /** * Construct a default `request-options` value. @@ -741,7 +741,7 @@ declare module 'wasi:http/types@0.2.12' { setBetweenBytesTimeout(duration: Duration | undefined): void; [Symbol.dispose](): void; } - + export class ResponseOutparam implements Disposable { /** * This type does not have a public constructor. @@ -750,11 +750,11 @@ declare module 'wasi:http/types@0.2.12' { /** * Set the value of the `response-outparam` to either send a response, * or indicate an error. - * + * * This method consumes the `response-outparam` to ensure that it is * called at most once. If it is never called, the implementation * will respond with an error. - * + * * The user may provide an `error` to `response` to allow the * implementation determine how to respond with an HTTP error response. */ diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts index af82b6319..c285e878b 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-error.d.ts @@ -1,5 +1,5 @@ declare module 'wasi:io/error@0.2.12' { - + export class Error implements Disposable { /** * This type does not have a public constructor. @@ -8,7 +8,7 @@ declare module 'wasi:io/error@0.2.12' { /** * Returns a string that is suitable to assist humans in debugging * this error. - * + * * WARNING: The returned string should not be consumed mechanically! * It may change across platforms, hosts, or other implementation * details. Parsing this string is a major platform-compatibility diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts index 7e40bdbe1..9a3022734 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-poll.d.ts @@ -1,27 +1,27 @@ declare module 'wasi:io/poll@0.2.12' { /** * Poll for completion on a set of pollables. - * + * * This function takes a list of pollables, which identify I/O sources of * interest, and waits until one or more of the events is ready for I/O. - * + * * The result `list` contains one or more indices of handles in the * argument list that is ready for I/O. - * + * * This function traps if either: * - the list is empty, or: * - the list contains more elements than can be indexed with a `u32` value. - * + * * A timeout can be implemented by adding a pollable from the * wasi-clocks API to the list. - * + * * This function does not return a `result`; polling in itself does not * do any I/O so it doesn't fail. If any of the I/O sources identified by * the pollables has an error, it is indicated by marking the source as * being ready for I/O. */ export function poll(in_: Array): Uint32Array; - + export class Pollable implements Disposable { /** * This type does not have a public constructor. @@ -29,14 +29,14 @@ declare module 'wasi:io/poll@0.2.12' { private constructor(); /** * Return the readiness of a pollable. This function never blocks. - * + * * Returns `true` when the pollable is ready, and `false` otherwise. */ ready(): boolean; /** * `block` returns immediately if the pollable is ready, and otherwise * blocks until ready. - * + * * This function is equivalent to calling `poll.poll` on a list * containing only this pollable. */ diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts index f7ba066c7..64403d4e2 100644 --- a/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/interfaces/wasi-io-streams.d.ts @@ -9,9 +9,9 @@ declare module 'wasi:io/streams@0.2.12' { export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; /** * The last operation (a write or flush) failed before completion. - * + * * More information is available in the `error` payload. - * + * * After this, the stream will be closed. All future operations return * `stream-error::closed`. */ @@ -27,7 +27,7 @@ declare module 'wasi:io/streams@0.2.12' { export interface StreamErrorClosed { tag: 'closed', } - + export class InputStream implements Disposable { /** * This type does not have a public constructor. @@ -35,27 +35,27 @@ declare module 'wasi:io/streams@0.2.12' { private constructor(); /** * Perform a non-blocking read from the stream. - * + * * When the source of a `read` is binary data, the bytes from the source * are returned verbatim. When the source of a `read` is known to the * implementation to be text, bytes containing the UTF-8 encoding of the * text are returned. - * + * * This function returns a list of bytes containing the read data, * when successful. The returned list will contain up to `len` bytes; * it may return fewer than requested, but not more. The list is * empty when no bytes are available for reading at this time. The * pollable given by `subscribe` will be ready when more bytes are * available. - * + * * This function fails with a `stream-error` when the operation * encounters an error, giving `last-operation-failed`, or when the * stream is closed, giving `closed`. - * + * * When the caller gives a `len` of 0, it represents a request to * read 0 bytes. If the stream is still open, this call should * succeed and return an empty list, or otherwise fail with `closed`. - * + * * The `len` parameter is a `u64`, which could represent a list of u8 which * is not possible to allocate in wasm32, or not desirable to allocate as * as a return value by the callee. The callee may return a list of bytes @@ -69,7 +69,7 @@ declare module 'wasi:io/streams@0.2.12' { blockingRead(len: bigint): Uint8Array; /** * Skip bytes from a stream. Returns number of bytes skipped. - * + * * Behaves identical to `read`, except instead of returning a list * of bytes, returns the number of bytes consumed from the stream. */ @@ -90,7 +90,7 @@ declare module 'wasi:io/streams@0.2.12' { subscribe(): Pollable; [Symbol.dispose](): void; } - + export class OutputStream implements Disposable { /** * This type does not have a public constructor. @@ -98,11 +98,11 @@ declare module 'wasi:io/streams@0.2.12' { private constructor(); /** * Check readiness for writing. This function never blocks. - * + * * Returns the number of bytes permitted for the next call to `write`, * or an error. Calling `write` with more bytes than this function has * permitted will trap. - * + * * When this function returns 0 bytes, the `subscribe` pollable will * become ready when this function will report at least 1 byte, or an * error. @@ -110,16 +110,16 @@ declare module 'wasi:io/streams@0.2.12' { checkWrite(): bigint; /** * Perform a write. This function never blocks. - * + * * When the destination of a `write` is binary data, the bytes from * `contents` are written verbatim. When the destination of a `write` is * known to the implementation to be text, the bytes of `contents` are * transcoded from UTF-8 into the encoding of the destination and then * written. - * + * * Precondition: check-write gave permit of Ok(n) and contents has a * length of less than or equal to n. Otherwise, this function will trap. - * + * * returns Err(closed) without writing if the stream has closed since * the last call to check-write provided a permit. */ @@ -127,7 +127,7 @@ declare module 'wasi:io/streams@0.2.12' { /** * Perform a write of up to 4096 bytes, and then flush the stream. Block * until all of these operations are complete, or an error occurs. - * + * * Returns success when all of the contents written are successfully * flushed to output. If an error occurs at any point before all * contents are successfully flushed, that error is returned as soon as @@ -139,11 +139,11 @@ declare module 'wasi:io/streams@0.2.12' { blockingWriteAndFlush(contents: Uint8Array): void; /** * Request to flush buffered output. This function never blocks. - * + * * This tells the output-stream that the caller intends any buffered * output to be flushed. the output which is expected to be flushed * is all that has been passed to `write` prior to this call. - * + * * Upon calling this function, the `output-stream` will not accept any * writes (`check-write` will return `ok(0)`) until the flush has * completed. The `subscribe` pollable will become ready when the @@ -160,9 +160,9 @@ declare module 'wasi:io/streams@0.2.12' { * is ready for more writing, or an error has occurred. When this * pollable is ready, `check-write` will return `ok(n)` with n>0, or an * error. - * + * * If the stream is closed, this pollable is always ready immediately. - * + * * The created `pollable` is a child resource of the `output-stream`. * Implementations may trap if the `output-stream` is dropped before * all derived `pollable`s created with this function are dropped. @@ -170,7 +170,7 @@ declare module 'wasi:io/streams@0.2.12' { subscribe(): Pollable; /** * Write zeroes to a stream. - * + * * This should be used precisely like `write` with the exact same * preconditions (must use check-write first), but instead of * passing a list of bytes, you simply pass the number of zero-bytes @@ -181,30 +181,30 @@ declare module 'wasi:io/streams@0.2.12' { * Perform a write of up to 4096 zeroes, and then flush the stream. * Block until all of these operations are complete, or an error * occurs. - * + * * Functionality is equivelant to `blocking-write-and-flush` with * contents given as a list of len containing only zeroes. */ blockingWriteZeroesAndFlush(len: bigint): void; /** * Read from one stream and write to another. - * + * * The behavior of splice is equivalent to: * 1. calling `check-write` on the `output-stream` * 2. calling `read` on the `input-stream` with the smaller of the * `check-write` permitted length and the `len` provided to `splice` * 3. calling `write` on the `output-stream` with that read data. - * + * * Any error reported by the call to `check-write`, `read`, or * `write` ends the splice and reports that error. - * + * * This function returns the number of bytes transferred; it may be less * than `len`. */ splice(src: InputStream, len: bigint): bigint; /** * Read from one stream and write to another, with blocking. - * + * * This is similar to `splice`, except that it blocks until the * `output-stream` is ready for writing, and the `input-stream` * is ready for reading, before performing the `splice`. diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts new file mode 100644 index 000000000..71514f1a4 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-error.d.ts @@ -0,0 +1,11 @@ +declare module 'wasi:io/error@0.2.12' { + + export class Error implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + toDebugString(): string; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts new file mode 100644 index 000000000..e8833929a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-poll.d.ts @@ -0,0 +1,13 @@ +declare module 'wasi:io/poll@0.2.12' { + export function poll(in_: Array): Uint32Array; + + export class Pollable implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + ready(): boolean; + block(): void; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts new file mode 100644 index 000000000..724a49a22 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-io-streams.d.ts @@ -0,0 +1,45 @@ +/// +/// +declare module 'wasi:io/streams@0.2.12' { + export type Error = import('wasi:io/error@0.2.12').Error; + export type Pollable = import('wasi:io/poll@0.2.12').Pollable; + export type StreamError = StreamErrorLastOperationFailed | StreamErrorClosed; + export interface StreamErrorLastOperationFailed { + tag: 'last-operation-failed', + val: Error, + } + export interface StreamErrorClosed { + tag: 'closed', + } + + export class InputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + read(len: bigint): Uint8Array; + blockingRead(len: bigint): Uint8Array; + skip(len: bigint): bigint; + blockingSkip(len: bigint): bigint; + subscribe(): Pollable; + [Symbol.dispose](): void; + } + + export class OutputStream implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + checkWrite(): bigint; + write(contents: Uint8Array): void; + blockingWriteAndFlush(contents: Uint8Array): void; + flush(): void; + blockingFlush(): void; + subscribe(): Pollable; + writeZeroes(len: bigint): void; + blockingWriteZeroesAndFlush(len: bigint): void; + splice(src: InputStream, len: bigint): bigint; + blockingSplice(src: InputStream, len: bigint): bigint; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts new file mode 100644 index 000000000..79829ea62 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/interfaces/wasi-tls-types.d.ts @@ -0,0 +1,39 @@ +/// +/// +/// +declare module 'wasi:tls/types@0.2.0-draft' { + /** + * Whether the host grants TLS connections. This query performs no IO. + */ + export function isAvailable(): boolean; + export type InputStream = import('wasi:io/streams@0.2.12').InputStream; + export type OutputStream = import('wasi:io/streams@0.2.12').OutputStream; + export type Pollable = import('wasi:io/poll@0.2.12').Pollable; + export type IoError = import('wasi:io/error@0.2.12').Error; + export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; + + export class ClientConnection implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + closeOutput(): void; + [Symbol.dispose](): void; + } + + export class ClientHandshake implements Disposable { + constructor(serverName: string, input: InputStream, output: OutputStream) + static finish(this_: ClientHandshake): FutureClientStreams; + [Symbol.dispose](): void; + } + + export class FutureClientStreams implements Disposable { + /** + * This type does not have a public constructor. + */ + private constructor(); + subscribe(): Pollable; + get(): Result, void> | undefined; + [Symbol.dispose](): void; + } +} diff --git a/packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts new file mode 100644 index 000000000..336a082fb --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.12/generated/types/tls/tls-0.2.d.ts @@ -0,0 +1,10 @@ +/// +/// +/// +/// +declare module 'wasi:tls/imports@0.2.0-draft' { + export type * as WasiIoError0212 from 'wasi:io/error@0.2.12'; // import wasi:io/error@0.2.12 + export type * as WasiIoPoll0212 from 'wasi:io/poll@0.2.12'; // import wasi:io/poll@0.2.12 + export type * as WasiIoStreams0212 from 'wasi:io/streams@0.2.12'; // import wasi:io/streams@0.2.12 + export type * as WasiTlsTypes020Draft from 'wasi:tls/types@0.2.0-draft'; // import wasi:tls/types@0.2.0-draft +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts index 1f57171b5..ab7d99b98 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts @@ -2,11 +2,16 @@ * Opt-in Node.js HTTP provider. * * The operation mapping follows nodejs/node v24.19.0, commit - * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js and + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js, lib/https.js, and * lib/_http_client.js (MIT license). The Node stream lifecycle is adapted to - * one buffered, typed WIT request/response exchange. + * one buffered, typed WIT request/response exchange. Requests with the `https` + * scheme and servers carrying a `tls` record go through real `node:https`, so + * TLS is terminated by the host's own stack. */ +import { Buffer } from "node:buffer"; import * as nodeHttp from "node:http"; +import * as nodeHttps from "node:https"; +import type * as nodeTls from "node:tls"; import { fieldsToRawHeaders, @@ -22,10 +27,56 @@ import type { DirectHttpServerAddress, DirectHttpServerConstructor, DirectHttpServerOptions, + DirectTlsOptions, } from "./http/types.js"; type AsyncResult = Promise>; type Timer = ReturnType; +type NodeTlsOptions = nodeTls.SecureContextOptions & + Pick & + Pick; + +function buffers(values: Uint8Array[]): Buffer[] { + return values.map((value) => Buffer.from(value)); +} + +/** + * Maps the WIT `tls-options` record onto the option names `node:tls` reads. + * + * Only present fields are copied, so Node applies its own defaults for the rest exactly as it + * would for a native caller. + */ +function nodeTlsOptions(tls: DirectTlsOptions): NodeTlsOptions { + const options: NodeTlsOptions = { + key: tls.key && buffers(tls.key), + cert: tls.cert && buffers(tls.cert), + pfx: tls.pfx && buffers(tls.pfx), + passphrase: tls.passphrase, + ca: tls.ca && buffers(tls.ca), + crl: tls.crl && buffers(tls.crl), + dhparam: tls.dhparam && Buffer.from(tls.dhparam), + ciphers: tls.ciphers, + ecdhCurve: tls.ecdhCurve, + sigalgs: tls.sigalgs, + minVersion: tls.minVersion as nodeTls.SecureVersion | undefined, + maxVersion: tls.maxVersion as nodeTls.SecureVersion | undefined, + secureProtocol: tls.secureProtocol, + secureOptions: tls.secureOptions, + sessionIdContext: tls.sessionIdContext, + honorCipherOrder: tls.honorCipherOrder, + ALPNProtocols: tls.alpnProtocols, + servername: tls.servername, + rejectUnauthorized: tls.rejectUnauthorized, + requestCert: tls.requestCert, + }; + for (const [name, value] of Object.entries(options)) { + if (value === undefined) { + delete options[name as keyof NodeTlsOptions]; + } + } + return options; +} + function timeoutError(syscall: string): Error & { code: string; syscall: string } { return Object.assign(new Error(`HTTP ${syscall} timed out`), { code: "ETIMEDOUT", @@ -42,12 +93,16 @@ export async function request(options: DirectHttpRequest): AsyncResult { clearTimeout(connectTimer); @@ -135,11 +190,22 @@ function serverAddress( class NodeHttpServer { readonly #listener: DirectHttpRequestListener; - readonly #server: nodeHttp.Server; + readonly #server: nodeHttp.Server | nodeHttps.Server; constructor(options: DirectHttpServerOptions, listener: DirectHttpRequestListener) { this.#listener = listener; - this.#server = nodeHttp.createServer(nodeServerOptions(options), async (request, response) => { + // A `tls` record, even an empty one, means the guest constructed an https.Server; Node's + // own https.Server accepts a missing certificate at construction and fails the handshake. + const create = + options.tls === undefined + ? (handler: nodeHttp.RequestListener) => + nodeHttp.createServer(nodeServerOptions(options), handler) + : (handler: nodeHttp.RequestListener) => + nodeHttps.createServer( + { ...nodeServerOptions(options), ...nodeTlsOptions(options.tls!) }, + handler, + ); + this.#server = create(async (request, response) => { try { const chunks: Uint8Array[] = []; for await (const chunk of request) { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts index ff5679136..ab228d8e4 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/agent.ts @@ -20,6 +20,7 @@ export interface AgentOptions { timeout?: number; defaultPort?: number; protocol?: string; + noDelay?: boolean; [name: string]: unknown; } @@ -50,7 +51,10 @@ export class Agent extends EventEmitter { constructor(options: AgentOptions = {}) { super(); - this.options = { ...options }; + // lib/_http_agent.js normalises two fields into `agent.options` itself: `noDelay` + // defaults to true, and `path` is forced to null so net does not read the bag as a + // pipe target. Both are observable through `agent.options`. + this.options = { ...options, noDelay: options.noDelay ?? true, path: null }; this.defaultPort = options.defaultPort ?? 80; this.protocol = options.protocol ?? "http:"; this.keepAlive = options.keepAlive ?? false; @@ -69,14 +73,25 @@ export class Agent extends EventEmitter { } getName(options: AgentNameOptions = {}): string { + // Field order and the conditional separators follow lib/_http_agent.js at the pinned + // commit: an absent port contributes an empty field rather than `defaultPort`, and + // `family` and `socketPath` are appended only when set, so the name is variable-length. + let name = options.host || "localhost"; + name += ":"; + if (options.port) { + name += options.port; + } + name += ":"; + if (options.localAddress) { + name += options.localAddress; + } + if (options.family === 4 || options.family === 6) { + name += `:${options.family}`; + } if (options.socketPath) { - return `${options.socketPath}:`; + name += `:${options.socketPath}`; } - const host = options.host ?? "localhost"; - const port = options.port ?? this.defaultPort; - const localAddress = options.localAddress ?? ""; - const family = options.family === 4 || options.family === 6 ? `:${options.family}` : ""; - return `${host}:${port}:${localAddress}${family}`; + return name; } keepSocketAlive(_socket: unknown): boolean { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts index bfdd71b6c..0e6ef352c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/client-request.ts @@ -9,17 +9,20 @@ * request/response exchange with the selected implementation. */ -import { Agent, globalAgent } from "./agent.js"; +import { Agent } from "./agent.js"; import { base64 } from "./body.js"; import { deprecated, invalidArgType, invalidArgValue, unsupported } from "./errors.js"; import { validateHeaderName } from "./headers.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import type { ProtocolProfile } from "./profile.js"; +import { tlsMaterial } from "./tls.js"; import type { HttpImplementation, HttpImplementationRequest, HttpImplementationResponse, HttpRequestOptions, + HttpTlsMaterial, } from "./types.js"; export type ResponseListener = (response: IncomingMessage) => void; @@ -35,6 +38,22 @@ interface NormalizedRequest { path: string; } +/** + * Resolves the port assumed when the options carry none. + * + * lib/_http_client.js reads `options.defaultPort || (this.agent && this.agent.defaultPort)`, + * and an `agent: false` request still gets a fresh instance of the module's own agent class, + * so the profile's port is the correct final fallback for both modules. + */ +function resolvedDefaultPort(options: HttpRequestOptions, profile: ProtocolProfile): number { + const agent = options.agent; + const agentDefaultPort = + typeof agent === "object" && agent !== null && typeof agent.defaultPort === "number" + ? agent.defaultPort + : undefined; + return Number(options.defaultPort || agentDefaultPort || profile.defaultPort); +} + function urlOptions(input: string | URL): HttpRequestOptions { const url = input instanceof URL ? input : new URL(input); return { @@ -60,17 +79,18 @@ function numericPort(value: number | string | null | undefined, fallback: number function normalizedRequest( input: RequestInput, extra: HttpRequestOptions | undefined, + profile: ProtocolProfile, ): NormalizedRequest { const base = typeof input === "string" || input instanceof URL ? urlOptions(input) : input; if (typeof base !== "object" || base === null) { throw invalidArgType("options", "object, string, or URL", input); } const options = { ...base, ...extra }; - const protocol = options.protocol ?? "http:"; - if (protocol !== "http:") { + const protocol = options.protocol ?? profile.protocol; + if (protocol !== profile.protocol) { const error = invalidArgValue("protocol", protocol); error.code = "ERR_INVALID_PROTOCOL"; - error.message = `Protocol \"${protocol}\" not supported. Expected \"http:\"`; + error.message = `Protocol \"${protocol}\" not supported. Expected \"${profile.protocol}\"`; throw error; } let hostname = options.hostname ?? options.host ?? "localhost"; @@ -85,7 +105,8 @@ function normalizedRequest( } else if (hostname.split(":").length === 2) { [hostname, hostPort] = hostname.split(":"); } - const port = numericPort(options.port ?? hostPort, Number(options.defaultPort ?? 80)); + const defaultPort = resolvedDefaultPort(options, profile); + const port = numericPort(options.port ?? hostPort, defaultPort); const method = (options.method ?? "GET").toUpperCase(); validateHeaderName(method, "Method"); const path = options.path ?? "/"; @@ -105,7 +126,7 @@ function normalizedRequest( protocol, hostname, port, - authority: port === 80 ? authorityHost : `${authorityHost}:${port}`, + authority: port === defaultPort ? authorityHost : `${authorityHost}:${port}`, path, }; } @@ -121,7 +142,7 @@ function abortError(reason: unknown): Error & { code: string } { } export class ClientRequestBase extends OutgoingMessage { - readonly agent: Agent | undefined; + readonly agent: Agent; readonly protocol: string; readonly host: string; readonly path: string; @@ -129,26 +150,38 @@ export class ClientRequestBase extends OutgoingMessage { readonly reusedSocket = false; maxHeadersCount: number | null = null; readonly #implementation: HttpImplementation; + readonly #profile: ProtocolProfile; readonly #hostname: string; readonly #port: number; + readonly #tls: HttpTlsMaterial | undefined; readonly #responseListener: ResponseListener | undefined; constructor( implementation: HttpImplementation, + profile: ProtocolProfile, input: RequestInput, options: HttpRequestOptions | undefined, responseListener: ResponseListener | undefined, ) { - const normalized = normalizedRequest(input, options); + const normalized = normalizedRequest(input, options, profile); super(normalized.options.headers); this.#implementation = implementation; + this.#profile = profile; this.#hostname = normalized.hostname; this.#port = normalized.port; + // lib/https.js hands the whole option bag to tls.connect; the shim carries the + // serializable subset and refuses the rest by name before anything is sent. + this.#tls = + profile.scheme === "https" + ? tlsMaterial(normalized.options, `${profile.module}.request option`) + : undefined; this.#responseListener = responseListener; + // lib/_http_client.js gives an `agent: false` request a fresh instance of the default + // agent's class rather than no agent at all, so the request never shares the global pool. this.agent = normalized.options.agent === false - ? undefined - : ((normalized.options.agent as Agent | undefined) ?? globalAgent); + ? new (profile.globalAgent.constructor as new () => Agent)() + : ((normalized.options.agent as Agent | null | undefined) ?? profile.globalAgent); this.protocol = normalized.protocol; this.host = normalized.authority; this.path = normalized.path; @@ -174,19 +207,19 @@ export class ClientRequestBase extends OutgoingMessage { } abort(): never { - return deprecated("http.ClientRequest.abort", "request.destroy()"); + return deprecated(`${this.#profile.module}.ClientRequest.abort`, "request.destroy()"); } setNoDelay(_noDelay = true): never { return unsupported( - "http.ClientRequest.setNoDelay", + `${this.#profile.module}.ClientRequest.setNoDelay`, "the selected implementation owns the socket", ); } setSocketKeepAlive(_enable = false, _initialDelay = 0): never { return unsupported( - "http.ClientRequest.setSocketKeepAlive", + `${this.#profile.module}.ClientRequest.setSocketKeepAlive`, "the selected implementation owns the socket", ); } @@ -204,7 +237,7 @@ export class ClientRequestBase extends OutgoingMessage { const timeout = this._timeout() || undefined; const request: HttpImplementationRequest = { method: this.method, - scheme: "http", + scheme: this.#profile.scheme, authority: this.host, pathWithQuery: this.path, headers: this._headers.fields(), @@ -213,6 +246,9 @@ export class ClientRequestBase extends OutgoingMessage { firstByteTimeoutMs: timeout, betweenBytesTimeoutMs: timeout, }; + if (this.#tls !== undefined) { + request.tls = this.#tls; + } const response = this.#implementation.request(request); return () => this.#deliver(response); } @@ -240,7 +276,10 @@ export interface ClientRequestConstructor { ): ClientRequestBase; } -export function createClientRequest(implementation: HttpImplementation): ClientRequestConstructor { +export function createClientRequest( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ClientRequestConstructor { return class ClientRequest extends ClientRequestBase { constructor( input: RequestInput, @@ -249,6 +288,7 @@ export function createClientRequest(implementation: HttpImplementation): ClientR ) { super( implementation, + profile, input, typeof options === "function" ? undefined : options, typeof options === "function" ? options : callback, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts index 1a194d1f9..0048c1178 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/core.ts @@ -1,6 +1,7 @@ import { Agent, globalAgent } from "./agent.js"; import { ClientRequestBase, + type ClientRequestConstructor, createClientRequest, type RequestInput, type ResponseListener, @@ -10,6 +11,7 @@ import { invalidArgType, outOfRange, unsupported } from "./errors.js"; import { validateHeaderName, validateHeaderValue } from "./headers.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import { HTTP_PROFILE, type ProtocolProfile } from "./profile.js"; import { connectionListener, createServerConstructor, @@ -36,21 +38,14 @@ function globalConstructor(name: "WebSocket" | "CloseEvent" | "MessageEvent"): R return typeof value === "function" ? (value as RuntimeConstructor) : unavailableConstructor(name); } -export interface NodeHttpModule { - Agent: typeof Agent; - ClientRequest: ReturnType; - CloseEvent: RuntimeConstructor; - IncomingMessage: typeof IncomingMessage; - METHODS: string[]; - MessageEvent: RuntimeConstructor; - OutgoingMessage: typeof OutgoingMessage; - STATUS_CODES: Record; +/** + * The protocol-dependent half of `node:http`, which `node:https` reuses wholesale. + */ +export interface ProtocolModule { + ClientRequest: ClientRequestConstructor; Server: ServerConstructor; - ServerResponse: typeof ServerResponse; - WebSocket: RuntimeConstructor; - _connectionListener: typeof connectionListener; createServer: ( - optionsOrListener?: ServerOptions | RequestListener, + optionsOrListener?: ServerOptions | RequestListener | null, listener?: RequestListener, ) => ServerBase; get: ( @@ -58,24 +53,25 @@ export interface NodeHttpModule { options?: HttpRequestOptions | ResponseListener, callback?: ResponseListener, ) => ClientRequestBase; - globalAgent: Agent; - maxHeaderSize: number; request: ( input: RequestInput, options?: HttpRequestOptions | ResponseListener, callback?: ResponseListener, ) => ClientRequestBase; - setGlobalProxyFromEnv: (environment?: Record) => never; - setMaxIdleHTTPParsers: (max: number) => void; - validateHeaderName: typeof validateHeaderName; - validateHeaderValue: typeof validateHeaderValue; } -let maxIdleHttpParsers = 1_000; - -export function createHttp(implementation: HttpImplementation): NodeHttpModule { - const ClientRequest = createClientRequest(implementation); - const Server = createServerConstructor(implementation); +/** + * Builds the request/server surface for one protocol. + * + * `lib/https.js` reuses `_http_client` and `_http_server` unchanged and only varies the + * protocol, default port, and default agent, so both modules share this builder. + */ +export function createProtocolModule( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ProtocolModule { + const ClientRequest = createClientRequest(implementation, profile); + const Server = createServerConstructor(implementation, profile); function request( input: RequestInput, @@ -96,12 +92,58 @@ export function createHttp(implementation: HttpImplementation): NodeHttpModule { } function createServer( - optionsOrListener: ServerOptions | RequestListener = {}, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ): ServerBase { return new Server(optionsOrListener, listener); } + return { ClientRequest, Server, createServer, get, request }; +} + +export interface NodeHttpModule { + Agent: typeof Agent; + ClientRequest: ClientRequestConstructor; + CloseEvent: RuntimeConstructor; + IncomingMessage: typeof IncomingMessage; + METHODS: string[]; + MessageEvent: RuntimeConstructor; + OutgoingMessage: typeof OutgoingMessage; + STATUS_CODES: Record; + Server: ServerConstructor; + ServerResponse: typeof ServerResponse; + WebSocket: RuntimeConstructor; + _connectionListener: typeof connectionListener; + createServer: ( + optionsOrListener?: ServerOptions | RequestListener | null, + listener?: RequestListener, + ) => ServerBase; + get: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + globalAgent: Agent; + maxHeaderSize: number; + request: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + setGlobalProxyFromEnv: (environment?: Record) => never; + setMaxIdleHTTPParsers: (max: number) => void; + validateHeaderName: typeof validateHeaderName; + validateHeaderValue: typeof validateHeaderValue; +} + +let maxIdleHttpParsers = 1_000; + +export function createHttp(implementation: HttpImplementation): NodeHttpModule { + const { ClientRequest, Server, createServer, get, request } = createProtocolModule( + implementation, + HTTP_PROFILE, + ); + function setMaxIdleHTTPParsers(max: number): void { if (typeof max !== "number") { throw invalidArgType("max", "number", max); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts index 32514db6e..6e91444b2 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts @@ -1,6 +1,6 @@ import { concatBytes } from "../body.js"; import { STATUS_CODES } from "../constants.js"; -import { fromImplementationError } from "../errors.js"; +import { fromImplementationError, unsupported } from "../errors.js"; import type { HttpHeaderField, HttpImplementation } from "../types.js"; export type WasiHttpMethod = @@ -160,6 +160,17 @@ function method(value: string): WasiHttpMethod { : { tag: "other", val: value }; } +function scheme(value: string): WasiHttpScheme { + switch (value) { + case "http": + return { tag: "HTTP" }; + case "https": + return { tag: "HTTPS" }; + default: + return { tag: "other", val: value }; + } +} + function duration(milliseconds: number | undefined): bigint | undefined { return milliseconds === undefined ? undefined : BigInt(milliseconds) * 1_000_000n; } @@ -240,15 +251,19 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm "wasi:http outgoing-handler cannot accept arbitrary inbound HTTP connections", request(request) { + if (request.tls !== undefined) { + unsupported( + `${request.scheme}.request TLS options with the wasi-http implementation`, + "wasi:http/outgoing-handler owns certificate validation and cannot take per-request TLS configuration", + ); + } try { const fields = provider.types.Fields.fromList( request.headers.map(({ name, value }): [string, Uint8Array] => [name, value]), ); const outgoing = new provider.types.OutgoingRequest(fields); outgoing.setMethod(method(request.method)); - outgoing.setScheme( - request.scheme === "http" ? { tag: "HTTP" } : { tag: "other", val: request.scheme }, - ); + outgoing.setScheme(scheme(request.scheme)); outgoing.setAuthority(request.authority); outgoing.setPathWithQuery(request.pathWithQuery); const body = outgoing.body(); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts similarity index 85% rename from packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts rename to packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts index 38d452eb9..d6e486ad3 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts @@ -1,11 +1,17 @@ -import { concatBytes } from "../body.js"; -import { fromImplementationError, invalidArgValue, unsupported } from "../errors.js"; +import { + handshake, + validateTlsOptions, + type WasiTlsProvider, + type WasiTlsConnection, +} from "./tls.js"; +import { concatBytes } from "../../body.js"; +import { fromImplementationError, invalidArgValue, unsupported } from "../../errors.js"; import { parseHttp1Request, parseHttp1Response, serializeHttp1Request, serializeHttp1Response, -} from "../http1.js"; +} from "../../http1.js"; import type { HttpImplementation, HttpImplementationRequest, @@ -16,7 +22,7 @@ import type { HttpServerAddress, HttpServerImplementation, HttpServerOptions, -} from "../types.js"; +} from "../../types.js"; export type WasiIpAddress = | { tag: "ipv4"; val: [number, number, number, number] } @@ -76,6 +82,7 @@ export interface WasiNetwork { } export interface WasiSocketsProvider { + tls?: WasiTlsProvider; instanceNetwork: { instanceNetwork(): WasiNetwork; }; @@ -99,6 +106,10 @@ export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): vo } export function errorCode(error: unknown): string | undefined { + // Component bindings wrap WIT result errors in ComponentError.payload. + if (typeof error === "object" && error !== null && "payload" in error) { + error = error.payload; + } if (typeof error === "string") { return error; } @@ -187,10 +198,13 @@ export function nodeAddress(address: WasiIpSocketAddress): Exclude void; export type GetConnectionsCallback = (error: Error | null, count: number) => void; -export interface ServerOptions { +/** + * `http.createServer` options plus the TLS material `https.createServer` accepts. + * + * Node routes the TLS half to `tls.Server` and the rest to `_http_server`; the shim keeps one + * option bag and lets the profile decide whether the TLS half is read at all. + */ +export interface ServerOptions extends HttpTlsOptions { requestTimeout?: number; headersTimeout?: number; keepAliveTimeout?: number; @@ -55,6 +64,22 @@ export interface ServerOptions { [name: string]: unknown; } +/** + * Reads the first `Server` argument the way lib/_http_server.js does. + * + * A listener or a nullish value means "no options"; any other non-object is + * `ERR_INVALID_ARG_TYPE` rather than being read as an option bag. + */ +function serverOptions(value: ServerOptions | RequestListener | null | undefined): ServerOptions { + if (typeof value === "function" || value === null || value === undefined) { + return {}; + } + if (typeof value !== "object") { + throw invalidArgType("options", "object", value); + } + return value; +} + export class ServerResponse extends OutgoingMessage { readonly req: IncomingMessage; statusCode = 200; @@ -176,20 +201,24 @@ export class ServerBase extends EventEmitter { requestTimeout: number; #server: HttpServerImplementation; + readonly #profile: ProtocolProfile; + constructor( implementation: HttpImplementation, - optionsOrListener: ServerOptions | RequestListener = {}, + profile: ProtocolProfile, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { super(); + this.#profile = profile; if (!implementation.createServer) { unsupported( - "http.Server", + `${profile.module}.Server`, implementation.serverUnsupportedReason ?? "the selected HTTP implementation cannot accept inbound connections", ); } - const options = typeof optionsOrListener === "function" ? {} : optionsOrListener; + const options = serverOptions(optionsOrListener); const requestListener = typeof optionsOrListener === "function" ? optionsOrListener : listener; for (const name of [ "IncomingMessage", @@ -201,7 +230,7 @@ export class ServerBase extends EventEmitter { ] as const) { if (options[name] !== undefined) { unsupported( - `http.Server option ${name}`, + `${profile.module}.Server option ${name}`, "this option cannot be represented by the current typed WIT boundary", ); } @@ -214,8 +243,17 @@ export class ServerBase extends EventEmitter { if (requestListener) { this.on("request", requestListener); } + // `tls.Server` terminates TLS below the HTTP layer, so the material is normalized once + // here and carried by the implementation rather than by any HTTP-level option. An https + // server always carries the record, even an empty one, so an implementation with no TLS + // stack refuses it rather than serving plaintext; Node itself constructs an https.Server + // without a certificate and fails each handshake instead. + const tls = + profile.scheme === "https" + ? (tlsMaterial(options, `${profile.module}.createServer option`) ?? {}) + : undefined; this.#server = implementation.createServer( - options, + { ...options, tls }, (request) => this.#handle(request), (error) => queueMicrotask(() => this.emit("error", error)), ); @@ -225,7 +263,7 @@ export class ServerBase extends EventEmitter { const { options, callback } = parseListenArguments(args); if (options.signal !== undefined) { unsupported( - "http.Server.listen signal", + `${this.#profile.module}.Server.listen signal`, "an AbortSignal cannot be retained across the current WIT server resource boundary", ); } @@ -284,7 +322,7 @@ export class ServerBase extends EventEmitter { setTimeout(milliseconds = 0, callback?: (...args: never[]) => unknown): this { if (milliseconds !== 0 || callback !== undefined) { unsupported( - "http.Server.setTimeout", + `${this.#profile.module}.Server.setTimeout`, "timeout events require an additional server callback across the WIT boundary", ); } @@ -316,7 +354,7 @@ export class ServerBase extends EventEmitter { const request = new IncomingMessage(data); const response = new ServerResponse(request); if (!this.emit("request", request, response)) { - unsupported("http.Server request", "the server has no request listener"); + unsupported(`${this.#profile.module}.Server request`, "the server has no request listener"); } request._start(); return response._completed(); @@ -324,16 +362,22 @@ export class ServerBase extends EventEmitter { } export interface ServerConstructor { - new (optionsOrListener?: ServerOptions | RequestListener, listener?: RequestListener): ServerBase; + new ( + optionsOrListener?: ServerOptions | RequestListener | null, + listener?: RequestListener, + ): ServerBase; } -export function createServerConstructor(implementation: HttpImplementation): ServerConstructor { +export function createServerConstructor( + implementation: HttpImplementation, + profile: ProtocolProfile, +): ServerConstructor { return class Server extends ServerBase { constructor( - optionsOrListener: ServerOptions | RequestListener = {}, + optionsOrListener: ServerOptions | RequestListener | null = {}, listener?: RequestListener, ) { - super(implementation, optionsOrListener, listener); + super(implementation, profile, optionsOrListener, listener); } }; } diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts new file mode 100644 index 000000000..9a5d43e5b --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/tls.ts @@ -0,0 +1,179 @@ +/** + * Normalizes Node's TLS options into the material the WIT boundary carries. + * + * The accepted option shapes follow nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/_tls_common.js `configSecureContext`, + * lib/_tls_wrap.js, and doc/api/tls.md (MIT license). Only the serializable subset crosses the + * boundary: PEM/DER/PFX blobs, strings, booleans, and ALPN protocol names. Options that install + * a callback, hand over an opaque host handle, or name an OpenSSL engine have no representation + * in a typed WIT record and are refused by name instead of being silently dropped. Options the + * record does not carry at all are ignored, exactly as `http.createServer` ignores keys it does + * not know. + * + * Every material field stays a list. Node accepts arrays for `key`, `cert`, `pfx`, `ca`, and + * `crl`, and OpenSSL reads only the first key from a concatenated PEM, so joining a `key` + * bundle into one blob would silently drop every key after the first. + */ + +import { invalidArgType, outOfRange, unsupported } from "./errors.js"; +import type { HttpTlsMaterial, HttpTlsOptions, TlsMaterial } from "./types.js"; + +const CALLBACK = "a callback cannot be retained across the WIT boundary"; +const ENGINE = "OpenSSL engines are not addressable from a component"; + +/** TLS options that cannot cross a typed WIT boundary, mapped to the reason they cannot. */ +const UNREPRESENTABLE_TLS_OPTIONS: Readonly> = { + ALPNCallback: CALLBACK, + SNICallback: CALLBACK, + checkServerIdentity: CALLBACK, + pskCallback: CALLBACK, + secureContext: "a prebuilt SecureContext is an opaque host handle", + session: "a TLS session is bound to the implementation's own connections", + ticketKeys: "TLS ticket keys are owned by the implementation", + clientCertEngine: ENGINE, + privateKeyEngine: ENGINE, + privateKeyIdentifier: ENGINE, + allowPartialTrustChain: "partial-chain trust policy is not carried by the TLS boundary", + enableTrace: "native TLS tracing is not exposed by the TLS boundary", + requestOCSP: "OCSP negotiation is not exposed by the TLS boundary", + minDHSize: "the TLS boundary cannot configure a minimum Diffie-Hellman key size", + handshakeTimeout: "handshake deadlines are host policy at the TLS boundary", + sessionTimeout: "session lifetime is host policy at the TLS boundary", +}; + +const encoder = new TextEncoder(); + +function isMaterial(value: unknown): value is TlsMaterial { + return typeof value === "string" || ArrayBuffer.isView(value) || value instanceof ArrayBuffer; +} + +function bytes(name: string, value: unknown): Uint8Array { + if (typeof value === "string") { + return encoder.encode(value); + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), + ); + } + throw invalidArgType(name, "string, Buffer, TypedArray, DataView, or ArrayBuffer", value); +} + +/** + * Reads a material option that Node accepts either as one blob or as an array of blobs. + * + * Object entries (`{ pem, passphrase }` for keys, `{ buf, passphrase }` for PFX bundles) carry + * a per-entry passphrase the WIT record has no field for, so they are refused rather than + * losing the passphrase. + */ +function materialList(api: string, name: string, value: unknown): Uint8Array[] { + if (Array.isArray(value)) { + return (value as unknown[]).map((entry, index) => { + if (typeof entry === "object" && entry !== null && !isMaterial(entry)) { + throw unsupported( + `${api} ${name}[${index}]`, + "only string, Buffer, TypedArray, DataView, and ArrayBuffer entries can cross the WIT boundary", + ); + } + return bytes(`${name}[${index}]`, entry); + }); + } + return [bytes(name, value)]; +} + +function string(name: string, value: unknown): string { + if (typeof value !== "string") { + throw invalidArgType(name, "string", value); + } + return value; +} + +function boolean(name: string, value: unknown): boolean { + if (typeof value !== "boolean") { + throw invalidArgType(name, "boolean", value); + } + return value; +} + +function uint32(name: string, value: unknown): number { + if (typeof value !== "number") { + throw invalidArgType(name, "number", value); + } + if (!Number.isInteger(value) || value < 0 || value > 0xff_ff_ff_ff) { + throw outOfRange(name, ">= 0 && <= 4294967295", value); + } + return value; +} + +function alpnProtocols(value: unknown): string[] { + if (Array.isArray(value)) { + return (value as unknown[]).map((entry, index) => string(`ALPNProtocols[${index}]`, entry)); + } + if (!isMaterial(value)) { + throw invalidArgType("ALPNProtocols", "Array, Buffer, TypedArray, or DataView", value); + } + // Node also accepts the wire encoding: length-prefixed protocol names. + const wire = bytes("ALPNProtocols", value); + const protocols: string[] = []; + const decoder = new TextDecoder(); + let offset = 0; + while (offset < wire.byteLength) { + const length = wire[offset]; + offset += 1; + if (length === 0 || offset + length > wire.byteLength) { + throw invalidArgType("ALPNProtocols", "a valid ALPN protocol list", value); + } + protocols.push(decoder.decode(wire.subarray(offset, offset + length))); + offset += length; + } + return protocols; +} + +/** + * Extracts the TLS material from `https.createServer` or `https.request` options. + * + * `api` labels refusals, e.g. `https.request option`. Returns `undefined` when no carried option + * is present at all so callers can tell "no TLS configuration" from an empty one. + */ +export function tlsMaterial(options: HttpTlsOptions, api: string): HttpTlsMaterial | undefined { + const bag = options as Record; + for (const [name, reason] of Object.entries(UNREPRESENTABLE_TLS_OPTIONS)) { + if (bag[name] !== undefined) { + unsupported(`${api} ${name}`, reason); + } + } + const material: HttpTlsMaterial = {}; + const read = ( + name: string, + key: K, + convert: (value: unknown) => HttpTlsMaterial[K], + ): void => { + if (bag[name] !== undefined) { + material[key] = convert(bag[name]); + } + }; + read("key", "key", (value) => materialList(api, "key", value)); + read("cert", "cert", (value) => materialList(api, "cert", value)); + read("pfx", "pfx", (value) => materialList(api, "pfx", value)); + read("passphrase", "passphrase", (value) => string("passphrase", value)); + read("ca", "ca", (value) => materialList(api, "ca", value)); + read("crl", "crl", (value) => materialList(api, "crl", value)); + read("dhparam", "dhparam", (value) => bytes("dhparam", value)); + read("ciphers", "ciphers", (value) => string("ciphers", value)); + read("ecdhCurve", "ecdhCurve", (value) => string("ecdhCurve", value)); + read("sigalgs", "sigalgs", (value) => string("sigalgs", value)); + read("minVersion", "minVersion", (value) => string("minVersion", value)); + read("maxVersion", "maxVersion", (value) => string("maxVersion", value)); + read("secureProtocol", "secureProtocol", (value) => string("secureProtocol", value)); + read("secureOptions", "secureOptions", (value) => uint32("secureOptions", value)); + read("sessionIdContext", "sessionIdContext", (value) => string("sessionIdContext", value)); + read("honorCipherOrder", "honorCipherOrder", (value) => boolean("honorCipherOrder", value)); + read("ALPNProtocols", "alpnProtocols", alpnProtocols); + read("servername", "servername", (value) => string("servername", value)); + read("rejectUnauthorized", "rejectUnauthorized", (value) => boolean("rejectUnauthorized", value)); + read("requestCert", "requestCert", (value) => boolean("requestCert", value)); + return Object.keys(material).length > 0 ? material : undefined; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts index 8c948ab77..663267b6a 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts @@ -3,7 +3,13 @@ import type { HostErrno, HostErrorBase } from "../internal/wit-types.js"; export type HttpHeaderValue = string | number | readonly string[]; export type HttpHeaders = Record; -export interface HttpRequestOptions { +/** + * `http.request` options plus the TLS options `https.request` accepts. + * + * The TLS members are read only by the `node:https` profile; `node:http` ignores them the way + * Node's `net.connect` does. + */ +export interface HttpRequestOptions extends HttpTlsOptions { protocol?: string; host?: string | null; hostname?: string | null; @@ -14,7 +20,7 @@ export interface HttpRequestOptions { auth?: string | null; timeout?: number; signal?: AbortSignal; - agent?: AgentLike | boolean; + agent?: AgentLike | boolean | null; defaultPort?: number | string; family?: number; hints?: number; @@ -31,6 +37,70 @@ export interface HttpRequestOptions { export interface AgentLike { readonly options: Readonly>; + readonly defaultPort?: number; +} + +/** PEM, DER, or PFX material as Node's TLS options accept it. */ +export type TlsMaterial = string | ArrayBufferView | ArrayBuffer; + +/** + * The subset of Node's `tls.createServer` / `tls.connect` options the typed WIT boundary + * carries. + * + * Follows nodejs/node v24.19.0, commit cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, + * lib/_tls_common.js `configSecureContext` and lib/_tls_wrap.js. Options with no serializable + * representation (`secureContext`, `SNICallback`, `ALPNCallback`, `checkServerIdentity`, + * engine identifiers, sessions, ticket keys) are refused by `tlsMaterial()` rather than + * silently dropped. + */ +export interface HttpTlsOptions { + key?: TlsMaterial | readonly TlsMaterial[]; + cert?: TlsMaterial | readonly TlsMaterial[]; + pfx?: TlsMaterial | readonly TlsMaterial[]; + passphrase?: string; + ca?: TlsMaterial | readonly TlsMaterial[]; + crl?: TlsMaterial | readonly TlsMaterial[]; + dhparam?: TlsMaterial; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + ALPNProtocols?: readonly string[] | TlsMaterial; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; +} + +/** + * Normalized TLS material handed to an implementation; mirrors the `tls-options` record of + * `jco:node/http@0.1.0` field for field. + */ +export interface HttpTlsMaterial { + key?: Uint8Array[]; + cert?: Uint8Array[]; + pfx?: Uint8Array[]; + passphrase?: string; + ca?: Uint8Array[]; + crl?: Uint8Array[]; + dhparam?: Uint8Array; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + alpnProtocols?: string[]; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; } export type HttpBodyChunk = string | ArrayBuffer | ArrayBufferView; @@ -50,6 +120,8 @@ export interface HttpImplementationRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + /** Client TLS configuration; only ever set by `node:https`, and only when options carry some. */ + tls?: HttpTlsMaterial; } export interface HttpImplementationResponse { @@ -93,6 +165,12 @@ export interface HttpServerOptions { highWaterMark?: number; insecureHTTPParser?: boolean; uniqueHeaders?: Array; + /** + * Present for every `node:https` server, even when no material was supplied, so that an + * implementation without a TLS stack refuses instead of serving plaintext. Absent for + * `node:http` servers. + */ + tls?: HttpTlsMaterial; [name: string]: unknown; } @@ -200,6 +278,7 @@ export interface DirectHttpRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + tls?: DirectTlsOptions; } export interface DirectHttpResponse { @@ -212,6 +291,9 @@ export interface DirectHttpResponse { export type DirectHttpResult = { tag: "ok"; val: T } | { tag: "err"; val: DirectHttpError }; +/** The `tls-options` record of `jco:node/http@0.1.0`. */ +export type DirectTlsOptions = HttpTlsMaterial; + export interface DirectHttpServerOptions { requestTimeout?: number; headersTimeout?: number; @@ -226,6 +308,7 @@ export interface DirectHttpServerOptions { keepAliveInitialDelay?: number; rejectNonStandardBodyWrites?: boolean; optimizeEmptyRequests?: boolean; + tls?: DirectTlsOptions; } export interface DirectHttpListenOptions { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts index 61aadbed3..caeedc88d 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts @@ -1,4 +1,4 @@ -import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets.js"; +import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets/index.js"; export const FRAME = { data: 0, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts index 1648be640..cf20bc481 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts @@ -5,7 +5,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts index cbd68735c..865cc729c 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts @@ -1,4 +1,4 @@ -import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets/index.js"; import type { Http2Implementation } from "../../types.js"; import { createWasiSocketsHttp2Client } from "./client.js"; import { createWasiSocketsHttp2Server } from "./server.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts index 00aa2f4c8..e24b74f9b 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts @@ -10,7 +10,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts index 0e8598f57..1524bbf34 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts @@ -3,7 +3,7 @@ import { type WasiInputStream, type WasiOutputStream, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../http/impl/wasi-sockets/index.js"; import type { Http2Settings, HttpHeaderField } from "../../types.js"; import { encodeFrame, FLAG, FRAME, type Http2Frame } from "../frames.js"; import { encodeHeaders } from "../hpack.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts new file mode 100644 index 000000000..dd280eb34 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https.ts @@ -0,0 +1,15 @@ +import * as host from "jco:node/http@0.1.0"; + +import { createDirectHttpImplementation } from "./http/impl/direct.js"; +import { createHttps } from "./https/core.js"; + +const https = createHttps(createDirectHttpImplementation(host)); + +export const Agent = https.Agent; +export const Server = https.Server; +export const createServer = https.createServer; +export const get = https.get; +export const globalAgent = https.globalAgent; +export const request = https.request; + +export default https; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts new file mode 100644 index 000000000..4aef994c6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/agent.ts @@ -0,0 +1,259 @@ +/** + * Agent for the portable node:https shim. + * + * The operation mapping follows nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js (MIT license). The option defaults, + * the TLS session cache, and the full `getName()` field order are kept verbatim; socket + * pooling, TLS handshakes, and CONNECT proxy tunnelling are owned by the selected + * implementation and refuse rather than pretend. + */ + +import { Agent as HttpAgent, type AgentNameOptions, type AgentOptions } from "../http/agent.js"; +import { unsupported } from "../http/errors.js"; +import type { ProtocolProfile } from "../http/profile.js"; + +export interface HttpsAgentOptions extends AgentOptions { + maxCachedSessions?: number; + servername?: string; +} + +/** `getName()` reads TLS options too, so its argument is wider than the http one. */ +export interface HttpsAgentNameOptions extends AgentNameOptions { + ca?: unknown; + cert?: unknown; + clientCertEngine?: unknown; + ciphers?: unknown; + key?: unknown; + pfx?: unknown; + passphrase?: unknown; + rejectUnauthorized?: unknown; + servername?: unknown; + minVersion?: unknown; + maxVersion?: unknown; + secureProtocol?: unknown; + crl?: unknown; + honorCipherOrder?: unknown; + ecdhCurve?: unknown; + dhparam?: unknown; + secureOptions?: unknown; + sessionIdContext?: unknown; + sigalgs?: unknown; + privateKeyIdentifier?: unknown; + privateKeyEngine?: unknown; +} + +interface SessionCache { + map: Record; + list: string[]; +} + +interface PfxEntry { + buf?: unknown; + passphrase?: unknown; +} + +/** + * Builds the `pfx` field of an agent key. + * + * Ported from `getPfxAgentKey` in lib/https.js: a plain value contributes itself, while an + * array contributes `:buf:passphrase` per entry so distinct bundles get distinct keys. The + * falsy (`||`) fallbacks and the literal `undefined` for a missing passphrase are Node's own. + */ +function pfxAgentKey(pfx: unknown, passphrase: unknown): string { + if (!Array.isArray(pfx)) { + return String(pfx); + } + let key = ""; + for (const value of pfx as Array) { + const raw = value?.buf || value; + const pass = value?.passphrase || passphrase; + key += `:${String(raw)}:${String(pass)}`; + } + return key; +} + +export class Agent extends HttpAgent { + maxCachedSessions: number; + readonly _sessionCache: SessionCache; + + constructor(options: HttpsAgentOptions = {}) { + super({ + ...options, + defaultPort: options.defaultPort ?? 443, + protocol: options.protocol ?? "https:", + }); + // lib/https.js: only an absent option falls back to 100; any supplied value is kept as-is. + const configured = this.options.maxCachedSessions; + this.maxCachedSessions = configured === undefined ? 100 : (configured as number); + this._sessionCache = { map: {}, list: [] }; + } + + override createConnection(..._args: unknown[]): never { + return unsupported( + "https.Agent.createConnection", + "TLS handshakes and CONNECT tunnels are owned by the selected implementation", + ); + } + + /** + * Own prototype member in Node so a per-request `checkServerIdentity` socket is never + * pooled; the shim has no sockets, so it only preserves the shape and defers to the base. + */ + override keepSocketAlive(socket: unknown): boolean { + return super.keepSocketAlive(socket); + } + + /** + * Appends the 19 TLS fields to the http agent key. + * + * The order, the `!== undefined` guards on `rejectUnauthorized`, `honorCipherOrder`, and + * `secureOptions`, the `servername !== host` guard, and the `JSON.stringify` of `sigalgs` + * are all load-bearing: they are what makes two option bags share or split a socket pool. + */ + override getName(options: HttpsAgentNameOptions = {}): string { + let name = super.getName(options); + + name += ":"; + if (options.ca) { + name += options.ca; + } + + name += ":"; + if (options.cert) { + name += options.cert; + } + + name += ":"; + if (options.clientCertEngine) { + name += options.clientCertEngine; + } + + name += ":"; + if (options.ciphers) { + name += options.ciphers; + } + + name += ":"; + if (options.key) { + name += options.key; + } + + name += ":"; + if (options.pfx) { + name += pfxAgentKey(options.pfx, options.passphrase); + } + + name += ":"; + if (options.rejectUnauthorized !== undefined) { + name += options.rejectUnauthorized; + } + + name += ":"; + if (options.servername && options.servername !== options.host) { + name += options.servername; + } + + name += ":"; + if (options.minVersion) { + name += options.minVersion; + } + + name += ":"; + if (options.maxVersion) { + name += options.maxVersion; + } + + name += ":"; + if (options.secureProtocol) { + name += options.secureProtocol; + } + + name += ":"; + if (options.crl) { + name += options.crl; + } + + name += ":"; + if (options.honorCipherOrder !== undefined) { + name += options.honorCipherOrder; + } + + name += ":"; + if (options.ecdhCurve) { + name += options.ecdhCurve; + } + + name += ":"; + if (options.dhparam) { + name += options.dhparam; + } + + name += ":"; + if (options.secureOptions !== undefined) { + name += options.secureOptions; + } + + name += ":"; + if (options.sessionIdContext) { + name += options.sessionIdContext; + } + + name += ":"; + if (options.sigalgs) { + name += JSON.stringify(options.sigalgs); + } + + name += ":"; + if (options.privateKeyIdentifier) { + name += options.privateKeyIdentifier; + } + + name += ":"; + if (options.privateKeyEngine) { + name += options.privateKeyEngine; + } + + return name; + } + + _getSession(key: string): unknown { + return this._sessionCache.map[key]; + } + + _cacheSession(key: string, session: unknown): void { + if (this.maxCachedSessions === 0) { + return; + } + if (this._sessionCache.map[key]) { + this._sessionCache.map[key] = session; + return; + } + if (this._sessionCache.list.length >= this.maxCachedSessions) { + const oldKey = this._sessionCache.list.shift(); + if (oldKey !== undefined) { + delete this._sessionCache.map[oldKey]; + } + } + this._sessionCache.list.push(key); + this._sessionCache.map[key] = session; + } + + _evictSession(key: string): void { + const index = this._sessionCache.list.indexOf(key); + if (index === -1) { + return; + } + this._sessionCache.list.splice(index, 1); + delete this._sessionCache.map[key]; + } +} + +export const globalAgent = new Agent({ keepAlive: true, scheduling: "lifo", timeout: 5_000 }); + +export const HTTPS_PROFILE: ProtocolProfile = { + module: "https", + protocol: "https:", + scheme: "https", + defaultPort: 443, + globalAgent, +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts new file mode 100644 index 000000000..74ff55f5c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/https/core.ts @@ -0,0 +1,58 @@ +/** + * Module factory for the portable node:https shim. + * + * The export set follows nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js (MIT license): `lib/https.js` reuses + * `_http_client` and `_http_server` unchanged and varies only the protocol, the default port, + * and the default agent, so this module reuses the node:http core through a profile rather + * than forking it. + * + * Deliberately absent, matching the upstream module's own export list: nothing here is + * deprecated at the pinned release, and the CONNECT proxy tunnelling added in Node 24 + * (`getTunnelConfigForProxiedHttps`, `establishTunnel`, `ERR_PROXY_TUNNEL`) needs a raw + * socket, so a proxied agent refuses through `Agent.createConnection` rather than silently + * making a direct connection. + */ + +import type { ClientRequestBase, RequestInput, ResponseListener } from "../http/client-request.js"; +import { createProtocolModule, type ProtocolModule } from "../http/core.js"; +import type { + RequestListener, + ServerBase, + ServerConstructor, + ServerOptions, +} from "../http/server.js"; +import type { HttpImplementation, HttpRequestOptions } from "../http/types.js"; +import { Agent, globalAgent, HTTPS_PROFILE } from "./agent.js"; + +export interface NodeHttpsModule { + Agent: typeof Agent; + Server: ServerConstructor; + createServer: ( + optionsOrListener?: ServerOptions | RequestListener, + listener?: RequestListener, + ) => ServerBase; + get: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; + globalAgent: Agent; + request: ( + input: RequestInput, + options?: HttpRequestOptions | ResponseListener, + callback?: ResponseListener, + ) => ClientRequestBase; +} + +export function createHttps(implementation: HttpImplementation): NodeHttpsModule { + const protocol: ProtocolModule = createProtocolModule(implementation, HTTPS_PROFILE); + return { + Agent, + Server: protocol.Server, + createServer: protocol.createServer, + get: protocol.get, + globalAgent, + request: protocol.request, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts new file mode 100644 index 000000000..134bb029f --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node-worker.ts @@ -0,0 +1,172 @@ +/** Native TLS over the streams already owned by the Preview 2 IO worker. */ +import { Duplex, Readable, Writable } from "node:stream"; +import { connect, checkServerIdentity, type TLSSocket } from "node:tls"; +import { isIP } from "node:net"; +import type { + WorkerExtension, + WorkerExtensionContext, +} from "@bytecodealliance/preview2-shim/io-worker"; + +export interface TlsStartOptions { + serverName: string; + input: number; + output: number; + ca?: string[]; + handshakeTimeoutMs: number; +} +export interface TlsWorkerOperations { + start: { args: [TlsStartOptions]; result: { connection: number; future: number } }; + streams: { args: [number]; result: [number, number] }; + "close-output": { args: [number]; result: void }; + dispose: { args: [number]; result: void }; + counts: { + args: []; + result: { tls: number; streams: number; futures: number; polls: number; sockets: number }; + }; +} +interface Connection { + socket: TLSSocket; + timer: ReturnType; +} +export default function createTlsWorker(context: WorkerExtensionContext): WorkerExtension { + const { createFuture, createReadableStream, createWritableStream } = context; + const connections = new Map(); + let nextId = 0; + + function tlsStart(options: TlsStartOptions): { connection: number; future: number } { + const readable: unknown = context.getStream(options.input); + const writable: unknown = context.getStream(options.output); + if (!(readable instanceof Readable) || !(writable instanceof Writable)) { + throw new Error("wasi:tls requires Node-backed readable and writable streams"); + } + // This Duplex consumes precisely the supplied streams. No DNS lookup or replacement + // TCP connection is possible: tls.connect receives an already-connected transport. + const transport = Duplex.from({ readable, writable }); + const socket = connect({ + socket: transport, + servername: isIP(options.serverName) ? undefined : options.serverName, + rejectUnauthorized: true, + checkServerIdentity: (_host, certificate) => + checkServerIdentity(options.serverName, certificate), + ALPNProtocols: ["http/1.1"], + ca: options.ca, + }); + // Duplex.from may emit AbortError when TLS destroys an incomplete transport. + // Keep an error listener for the whole transport lifetime, including shutdown. + transport.on("error", (error: Error): void => { + socket.destroy(error); + }); + const connection = ++nextId; + const timer = setTimeout( + () => socket.destroy(new Error("TLS handshake timed out")), + options.handshakeTimeoutMs, + ); + connections.set(connection, { socket, timer }); + const future = createFuture( + new Promise((resolve, reject) => { + const fail = (error: Error): void => { + clearTimeout(timer); + reject({ + message: error.message, + code: "code" in error ? String(error.code) : "ERR_TLS_HANDSHAKE", + }); + }; + socket.on("error", fail); + socket.once("close", () => fail(new Error("TLS connection closed during handshake"))); + socket.once("secureConnect", () => { + clearTimeout(timer); + if (socket.alpnProtocol && socket.alpnProtocol !== "http/1.1") { + socket.destroy(new Error("TLS peer negotiated a protocol other than HTTP/1.1")); + return; + } + resolve(); + }); + }), + ); + return { connection, future }; + } + + function tlsCloseOutput(id: number): Promise { + const connection = connections.get(id); + if (!connection) { + throw new Error("wasi:tls connection was disposed"); + } + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error); + connection.socket.once("error", onError); + connection.socket.end(() => { + connection.socket.off("error", onError); + resolve(); + }); + }); + } + + function tlsDispose(id: number): void { + const connection = connections.get(id); + if (!connection) { + return; + } + clearTimeout(connection.timer); + connection.socket.destroy(); + connections.delete(id); + } + + function tlsStreams(id: number): [number, number] { + const socket = connections.get(id)!.socket; + return [createReadableStream(socket), createWritableStream(socket)]; + } + + function tlsConnectionCount(): number { + return connections.size; + } + + return (operation: string, args: unknown[]): unknown => { + const value = args[0]; + if (operation === "start") { + if ( + typeof value !== "object" || + value === null || + !("serverName" in value) || + typeof value.serverName !== "string" || + !("input" in value) || + typeof value.input !== "number" || + !("output" in value) || + typeof value.output !== "number" || + !("handshakeTimeoutMs" in value) || + typeof value.handshakeTimeoutMs !== "number" + ) { + throw new TypeError("Invalid TLS worker request"); + } + const ca = "ca" in value ? value.ca : undefined; + if ( + ca !== undefined && + (!Array.isArray(ca) || !ca.every((item: unknown) => typeof item === "string")) + ) { + throw new TypeError("Invalid TLS trust roots"); + } + return tlsStart({ + serverName: value.serverName, + input: value.input, + output: value.output, + handshakeTimeoutMs: value.handshakeTimeoutMs, + ca, + }); + } + if (operation === "counts") { + return { tls: tlsConnectionCount(), ...context.resourceCounts() }; + } + if (typeof value !== "number") { + throw new TypeError("Invalid TLS resource identifier"); + } + switch (operation) { + case "streams": + return tlsStreams(value); + case "close-output": + return tlsCloseOutput(value); + case "dispose": + return tlsDispose(value); + default: + throw new Error(`Unknown TLS operation: ${operation}`); + } + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts new file mode 100644 index 000000000..b7a056e33 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host-node.ts @@ -0,0 +1,210 @@ +/** + * Opt-in host implementation of WebAssembly/wasi-tls wit/types.wit at + * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). + * Local contract: wasi:io@0.2.12 and an availability query. + * Host trust and deadlines remain host policy. + */ +import { + callExtension, + inputStreamId, + outputStreamId, + inputStreamCreate, + outputStreamCreate, + futureSubscribe, + futureTakeValue, + futureDispose, + createIoError, +} from "@bytecodealliance/preview2-shim/io-worker"; +import type { TlsWorkerOperations } from "./tls-host-node-worker.js"; +import type { + InputStream, + OutputStream, +} from "@bytecodealliance/preview2-shim/interfaces/wasi-io-streams"; +import type { Pollable } from "@bytecodealliance/preview2-shim/interfaces/wasi-io-poll"; + +function tlsCall( + operation: Operation, + ...args: TlsWorkerOperations[Operation]["args"] +): TlsWorkerOperations[Operation]["result"] { + // The companion worker implements this operation/result contract; only this host module selects it. + return callExtension( + new URL("./tls-host-node-worker.js", import.meta.url), + operation, + args, + ) as TlsWorkerOperations[Operation]["result"]; +} + +export interface TlsHostOptions { + ca?: string[]; + handshakeTimeoutMs?: number; +} +export interface IoError { + toDebugString(): string; + [Symbol.dispose]?(): void; +} +export type ClientStreamsResult = + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "err"; val: IoError } + | { tag: "ok"; val: [ClientConnection, InputStream, OutputStream] }; + }; +interface OwnedTransport { + input: InputStream; + output: OutputStream; +} +function disposeStream(stream: InputStream | OutputStream): void { + const drop: unknown = Symbol.dispose in stream ? stream[Symbol.dispose] : undefined; + if (typeof drop !== "function") { + throw new TypeError("wasi:tls requires disposable IO resources"); + } + drop.call(stream); +} + +export class ClientConnection { + readonly #id: number; + readonly #transport: OwnedTransport; + #disposed = false; + constructor(id: number, transport: OwnedTransport) { + this.#id = id; + this.#transport = transport; + } + closeOutput(): void { + tlsCall("close-output", this.#id); + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + tlsCall("dispose", this.#id); + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } +} + +export class FutureClientStreams { + readonly #id: number; + readonly #connectionId: number; + readonly #connection: ClientConnection; + #taken = false; + #disposed = false; + constructor(id: number, connectionId: number, transport: OwnedTransport) { + this.#id = id; + this.#connectionId = connectionId; + this.#connection = new ClientConnection(connectionId, transport); + } + subscribe(): Pollable { + return futureSubscribe(this.#id, this); + } + get(): ClientStreamsResult | undefined { + const value: + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: + | { tag: "ok"; val: undefined } + | { tag: "err"; val: { message: string; code: string } }; + } + | undefined = futureTakeValue(this.#id); + if (!value) { + return undefined; + } + if (value.tag === "err") { + return { tag: "err", val: undefined }; + } + if (value.val.tag === "err") { + return { tag: "ok", val: { tag: "err", val: createIoError(value.val.val.message) } }; + } + const [input, output]: [number, number] = tlsCall("streams", this.#connectionId); + this.#taken = true; + return { + tag: "ok", + val: { + tag: "ok", + val: [this.#connection, inputStreamCreate(input), outputStreamCreate(output)], + }, + }; + } + [Symbol.dispose](): void { + if (this.#disposed) { + return; + } + futureDispose(this.#id); + this.#disposed = true; + if (!this.#taken) { + this.#connection[Symbol.dispose](); + } + } +} + +export interface ClientHandshakeResource { + [Symbol.dispose](): void; +} +export interface TlsProvider { + isAvailable(): boolean; + ClientHandshake: { + new (serverName: string, input: InputStream, output: OutputStream): ClientHandshakeResource; + finish(handshake: ClientHandshakeResource): FutureClientStreams; + }; + ClientConnection: typeof ClientConnection; + FutureClientStreams: typeof FutureClientStreams; +} + +export function createTlsProvider(options: TlsHostOptions = {}): TlsProvider { + const ca = options.ca?.slice(); + const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; + if (!Number.isSafeInteger(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) { + throw new RangeError("handshakeTimeoutMs must be a positive safe integer"); + } + class ClientHandshake implements ClientHandshakeResource { + #transport: OwnedTransport | undefined; + readonly #serverName: string; + constructor(serverName: string, input: InputStream, output: OutputStream) { + this.#serverName = serverName; + this.#transport = { input, output }; + } + static finish(value: ClientHandshakeResource): FutureClientStreams { + if (!(value instanceof ClientHandshake) || !value.#transport) { + throw new Error("wasi:tls handshake already consumed or belongs to another provider"); + } + const transport = value.#transport; + const result: { connection: number; future: number } = tlsCall("start", { + serverName: value.#serverName, + input: inputStreamId(transport.input), + output: outputStreamId(transport.output), + ca, + handshakeTimeoutMs, + }); + value.#transport = undefined; + return new FutureClientStreams(result.future, result.connection, transport); + } + [Symbol.dispose](): void { + if (this.#transport) { + disposeStream(this.#transport.output); + disposeStream(this.#transport.input); + } + this.#transport = undefined; + } + } + return { ClientHandshake, ClientConnection, FutureClientStreams, isAvailable }; +} + +export const { ClientHandshake } = createTlsProvider(); + +/** Host diagnostics for detecting owned IO resource leaks; not a WIT operation. */ +export function _resourceCounts(): { + tls: number; + streams: number; + futures: number; + polls: number; + sockets: number; +} { + return tlsCall("counts"); +} + +/** Availability query in Jco's local TLS contract. */ +export function isAvailable(): boolean { + return true; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts new file mode 100644 index 000000000..da3d74e85 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/tls-host.ts @@ -0,0 +1,34 @@ +/** Deny-by-default TLS capability; importing this module performs no IO. */ +function denied(): never { + throw Object.assign( + new Error( + "HTTPS over wasi:sockets requires an explicitly configured wasi:tls/types@0.2.0-draft host provider", + ), + { code: "ERR_JCO_TLS_ADAPTER_REQUIRED" }, + ); +} +export class ClientHandshake { + constructor(_serverName: string, _input: unknown, _output: unknown) { + denied(); + } + static finish(_handshake: ClientHandshake): never { + return denied(); + } +} +export class ClientConnection { + closeOutput(): never { + return denied(); + } +} +export class FutureClientStreams { + get(): never { + return denied(); + } + subscribe(): never { + return denied(); + } +} +/** Availability query in Jco's local TLS contract. */ +export function isAvailable(): boolean { + return false; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts new file mode 100644 index 000000000..09013f547 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/agent.ts @@ -0,0 +1,111 @@ +import nodeHttp from "node:http"; + +import { describe, expect, test } from "vitest"; + +import { Agent, globalAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; + +interface NameCase { + label: string; + options?: Parameters[0]; +} + +const NAME_CASES: NameCase[] = [ + { label: "no arguments" }, + { label: "empty options", options: {} }, + { label: "host and port", options: { host: "example.com", port: 8080 } }, + { label: "string port", options: { host: "example.com", port: "8080" } }, + { label: "host only", options: { host: "example.com" } }, + { label: "port only", options: { port: 8080 } }, + { label: "empty host", options: { host: "" } }, + { label: "port zero", options: { host: "example.com", port: 0 } }, + { label: "local address", options: { host: "example.com", port: 80, localAddress: "1.2.3.4" } }, + { label: "family 4", options: { host: "example.com", port: 80, family: 4 } }, + { label: "family 6", options: { host: "example.com", port: 80, family: 6 } }, + { label: "family 0", options: { host: "example.com", port: 80, family: 0 } }, + { + label: "local address and family", + options: { host: "example.com", port: 80, localAddress: "1.2.3.4", family: 6 }, + }, + { label: "socket path", options: { host: "example.com", port: 80, socketPath: "/tmp/sock" } }, + { label: "socket path only", options: { socketPath: "/tmp/sock" } }, + { + label: "socket path with family", + options: { host: "example.com", port: 80, family: 4, socketPath: "/tmp/sock" }, + }, +]; + +describe("http.Agent", () => { + test.concurrent("keeps Node's option defaults", () => { + const agent = new Agent(); + expect(agent.defaultPort).toBe(80); + expect(agent.protocol).toBe("http:"); + expect(agent.keepAlive).toBe(false); + expect(agent.keepAliveMsecs).toBe(1_000); + expect(agent.maxSockets).toBe(Number.POSITIVE_INFINITY); + expect(agent.maxFreeSockets).toBe(256); + expect(agent.maxTotalSockets).toBe(Number.POSITIVE_INFINITY); + expect(agent.scheduling).toBe("lifo"); + }); + + test.concurrent("does not substitute defaultPort for an absent port", () => { + // lib/_http_agent.js appends `options.port` only when truthy, so the port field is + // empty rather than the agent's defaultPort. + expect(new Agent().getName({ host: "example.com" })).toBe("example.com::"); + expect(new Agent({ defaultPort: 8080 }).getName({ host: "example.com" })).toBe("example.com::"); + }); + + test.concurrent("appends socketPath last instead of returning early", () => { + expect(new Agent().getName({ host: "example.com", port: 80, socketPath: "/tmp/sock" })).toBe( + "example.com:80::/tmp/sock", + ); + }); + + test.concurrent("normalises noDelay and path into options like Node", () => { + expect(new Agent().options).toEqual({ noDelay: true, path: null }); + expect(new Agent({ noDelay: false, keepAlive: true }).options).toEqual({ + noDelay: false, + keepAlive: true, + path: null, + }); + }); + + test.concurrent("keeps the global agent's documented options", () => { + expect(globalAgent.keepAlive).toBe(true); + expect(globalAgent.scheduling).toBe("lifo"); + expect(globalAgent.options).toMatchObject({ + keepAlive: true, + scheduling: "lifo", + timeout: 5_000, + }); + }); + + test.concurrent("refuses to own connections", () => { + expect(() => new Agent().createConnection()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); + +describeDifferential("http.Agent differential", () => { + for (const { label, options } of NAME_CASES) { + test.concurrent(`getName matches Node for ${label}`, () => { + const portable = new Agent(); + const native = new nodeHttp.Agent(); + expect(portable.getName(options)).toBe(native.getName(options)); + }); + } + + test.concurrent("matches Node's normalised option bag", () => { + expect(new Agent().options).toEqual({ ...new nodeHttp.Agent().options }); + expect(new Agent({ keepAlive: true, maxSockets: 4 }).options).toEqual({ + ...new nodeHttp.Agent({ keepAlive: true, maxSockets: 4 }).options, + }); + }); + + test.concurrent("getName ignores the agent's own defaultPort like Node", () => { + const portable = new Agent({ defaultPort: 8080 }); + const native = new nodeHttp.Agent({ defaultPort: 8080 }); + expect(portable.getName({ host: "example.com" })).toBe(native.getName({ host: "example.com" })); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts index ce57df36e..2f20f4344 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/client.ts @@ -4,6 +4,16 @@ import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x import { nextTurn, recordingImplementation } from "./helpers/index.js"; describe("node:http client requests", () => { + test.concurrent("gives an agent: false request a fresh agent instead of none", () => { + const { http } = recordingImplementation(); + const request = http.request({ host: "example.com", agent: false }); + expect(request.agent).toBeInstanceOf(http.Agent); + expect(request.agent).not.toBe(http.globalAgent); + expect(request.agent.keepAlive).toBe(false); + expect(http.request({ host: "example.com", agent: null }).agent).toBe(http.globalAgent); + expect(http.request({ host: "example.com" }).agent).toBe(http.globalAgent); + }); + test.concurrent("normalizes URL options and buffers a request body", async () => { const { http, requests } = recordingImplementation(); const events: string[] = []; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts index df2ee2e80..e89ba84b3 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts @@ -9,7 +9,7 @@ import { createWasiSocketsHttpImplementation, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import { parseHttp1Response, serializeHttp1Request, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts index ff4be0d3c..30f7f11a7 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/server.ts @@ -1,3 +1,5 @@ +import nodeHttp from "node:http"; + import { describe, expect, test, vi } from "vitest"; import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; @@ -102,6 +104,28 @@ describe("node:http Server", () => { const backend = serverImplementation(); const http = createHttp(backend.implementation); expect(new http.Server()).toBeInstanceOf(http.Server); + expect(new http.Server(null)).toBeInstanceOf(http.Server); + expect(new http.Server(null, () => undefined).listenerCount("request")).toBe(1); + }); + + test("rejects a non-object options argument the way Node does", () => { + const backend = serverImplementation(); + const http = createHttp(backend.implementation); + for (const value of ["8080", 8080, true]) { + let native: unknown; + try { + nodeHttp.createServer(value as never); + } catch (error) { + native = error; + } + expect(native).toMatchObject({ code: "ERR_INVALID_ARG_TYPE" }); + expect(() => http.createServer(value as never)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: (native as Error).message, + }), + ); + } }); test("rejects server operations the buffered boundary cannot represent", async () => { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts index b53f877a2..983b6088a 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "vitest"; import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; -import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import type { WasiInputStream, WasiOutputStream, WasiSocketsProvider, WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts index ff5d62c0c..a8bc1b018 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts @@ -14,7 +14,7 @@ import { encodeHeaders, HpackDecoder, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/hpack.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; import type { DirectHttp2ServerErrorListener, DirectHttp2Settings, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts index 688b132ff..49bee918b 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts @@ -79,18 +79,8 @@ describe("Node HTTP/2 host provider", () => { test("uses a real TLS client session with h2 ALPN", async () => { const [key, cert] = await Promise.all([ - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.key", - import.meta.url, - ), - ), - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.crt", - import.meta.url, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]); const server = nodeHttp2.createSecureServer({ key, cert }); closeables.push(() => new Promise((resolve) => server.close(() => resolve()))); @@ -126,18 +116,8 @@ describe("Node HTTP/2 host provider", () => { test.each([false, true])("uses a real %s server callback round trip", async (secure) => { const [key, cert] = secure ? await Promise.all([ - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.key", - import.meta.url, - ), - ), - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.crt", - import.meta.url, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]) : [undefined, undefined]; const listener: DirectHttp2StreamListener = { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts index 15519394a..10e9000c3 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; import { createWasiHttpHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-http/index.js"; import { createWasiSocketsHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; describe("node:http2 via wasi-http", () => { const createImplementation = createWasiHttpHttp2Implementation; diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts new file mode 100644 index 000000000..92a7bc3cb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/agent.ts @@ -0,0 +1,226 @@ +import nodeHttps from "node:https"; + +import { describe, expect, test } from "vitest"; + +import { Agent as HttpAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { Agent, globalAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; + +type NameOptions = Parameters[0]; + +/** + * One case per `getName` field, plus the guards that decide whether a field contributes. + * The order of the fields is what makes two option bags share or split a socket pool, so a + * mismatch anywhere here is a real behavioural difference, not a cosmetic one. + */ +const NAME_CASES: Array<{ label: string; options?: NameOptions }> = [ + { label: "no arguments" }, + { label: "empty options", options: {} }, + { label: "host and port", options: { host: "example.com", port: 443 } }, + { label: "host only", options: { host: "example.com" } }, + { label: "local address", options: { host: "h", port: 443, localAddress: "1.2.3.4" } }, + { label: "family 4", options: { host: "h", port: 443, family: 4 } }, + { label: "family 6", options: { host: "h", port: 443, family: 6 } }, + { label: "socket path", options: { host: "h", port: 443, socketPath: "/tmp/s" } }, + { label: "family before the TLS fields", options: { host: "h", port: 443, family: 4, ca: "C" } }, + { + label: "socket path before the TLS fields", + options: { host: "h", port: 443, socketPath: "/tmp/s", ca: "C" }, + }, + { label: "ca", options: { host: "h", port: 443, ca: "CA" } }, + { label: "cert", options: { host: "h", port: 443, cert: "CERT" } }, + { label: "clientCertEngine", options: { host: "h", port: 443, clientCertEngine: "ENGINE" } }, + { label: "ciphers", options: { host: "h", port: 443, ciphers: "AES" } }, + { label: "key", options: { host: "h", port: 443, key: "KEY" } }, + { label: "pfx string", options: { host: "h", port: 443, pfx: "PFX" } }, + { + label: "pfx array with passphrases", + options: { + host: "h", + port: 443, + pfx: [{ buf: "b1", passphrase: "p1" }, { buf: "b2" }], + passphrase: "outer", + } as NameOptions, + }, + { label: "pfx array of plain values", options: { host: "h", port: 443, pfx: ["a", "b"] } }, + { + label: "pfx array without any passphrase", + options: { host: "h", port: 443, pfx: [{ buf: "b1" }, "b2"] } as NameOptions, + }, + { + label: "pfx array with empty buf and passphrase strings", + options: { + host: "h", + port: 443, + pfx: [{ buf: "", passphrase: "" }], + passphrase: "outer", + } as NameOptions, + }, + { + label: "pfx array holding null", + options: { host: "h", port: 443, pfx: [null] } as NameOptions, + }, + { + label: "rejectUnauthorized false", + options: { host: "h", port: 443, rejectUnauthorized: false }, + }, + { label: "rejectUnauthorized true", options: { host: "h", port: 443, rejectUnauthorized: true } }, + { label: "servername equal to host", options: { host: "h", port: 443, servername: "h" } }, + { + label: "servername different from host", + options: { host: "h", port: 443, servername: "other" }, + }, + { label: "minVersion", options: { host: "h", port: 443, minVersion: "TLSv1.2" } }, + { label: "maxVersion", options: { host: "h", port: 443, maxVersion: "TLSv1.3" } }, + { label: "secureProtocol", options: { host: "h", port: 443, secureProtocol: "TLS_method" } }, + { label: "crl", options: { host: "h", port: 443, crl: "CRL" } }, + { label: "honorCipherOrder false", options: { host: "h", port: 443, honorCipherOrder: false } }, + { label: "ecdhCurve", options: { host: "h", port: 443, ecdhCurve: "auto" } }, + { label: "dhparam", options: { host: "h", port: 443, dhparam: "DH" } }, + { label: "secureOptions zero", options: { host: "h", port: 443, secureOptions: 0 } }, + { label: "sessionIdContext", options: { host: "h", port: 443, sessionIdContext: "ctx" } }, + { label: "sigalgs string", options: { host: "h", port: 443, sigalgs: "ecdsa" } }, + { label: "sigalgs object", options: { host: "h", port: 443, sigalgs: { a: 1 } } }, + { label: "privateKeyIdentifier", options: { host: "h", port: 443, privateKeyIdentifier: "id" } }, + { label: "privateKeyEngine", options: { host: "h", port: 443, privateKeyEngine: "eng" } }, + { + label: "every field at once", + options: { + host: "h", + port: 8443, + localAddress: "1.2.3.4", + family: 6, + ca: "CA", + cert: "CERT", + clientCertEngine: "ENGINE", + ciphers: "AES", + key: "KEY", + pfx: "PFX", + rejectUnauthorized: false, + servername: "other", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + crl: "CRL", + honorCipherOrder: true, + ecdhCurve: "auto", + dhparam: "DH", + secureOptions: 1, + sessionIdContext: "ctx", + sigalgs: ["a"], + privateKeyIdentifier: "id", + privateKeyEngine: "eng", + }, + }, +]; + +describe("https.Agent", () => { + test.concurrent("keeps Node's option defaults", () => { + const agent = new Agent(); + expect(agent.defaultPort).toBe(443); + expect(agent.protocol).toBe("https:"); + expect(agent.maxCachedSessions).toBe(100); + expect(agent.keepAlive).toBe(false); + }); + + test.concurrent("honours explicit defaultPort, protocol, and maxCachedSessions", () => { + const agent = new Agent({ defaultPort: 8443, protocol: "http:", maxCachedSessions: 0 }); + expect(agent.defaultPort).toBe(8443); + expect(agent.protocol).toBe("http:"); + expect(agent.maxCachedSessions).toBe(0); + }); + + test.concurrent("keeps the global agent's documented options", () => { + expect(globalAgent.defaultPort).toBe(443); + expect(globalAgent.protocol).toBe("https:"); + expect(globalAgent.keepAlive).toBe(true); + expect(globalAgent.scheduling).toBe("lifo"); + }); + + test.concurrent("refuses to own TLS connections", () => { + expect(() => new Agent().createConnection()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Agent.createConnection"), + }), + ); + }); + + test.concurrent("stores and evicts TLS sessions", () => { + const agent = new Agent({ maxCachedSessions: 2 }); + agent._cacheSession("a", 1); + agent._cacheSession("b", 2); + expect(agent._getSession("a")).toBe(1); + agent._cacheSession("c", 3); + expect(agent._getSession("a")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual(["b", "c"]); + agent._cacheSession("b", 20); + expect(agent._getSession("b")).toBe(20); + expect(agent._sessionCache.list).toEqual(["b", "c"]); + agent._evictSession("b"); + expect(agent._getSession("b")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual(["c"]); + agent._evictSession("missing"); + expect(agent._sessionCache.list).toEqual(["c"]); + }); + + test.concurrent("caches nothing when the cache is disabled", () => { + const agent = new Agent({ maxCachedSessions: 0 }); + agent._cacheSession("a", 1); + expect(agent._getSession("a")).toBeUndefined(); + expect(agent._sessionCache.list).toEqual([]); + }); + + test.concurrent("produces a 23-field key for a plain host and port", () => { + expect(new Agent().getName({ host: "h", port: 443 }).split(":")).toHaveLength(23); + }); + + test.concurrent("keeps a supplied maxCachedSessions value as-is", () => { + // Only an absent option falls back to 100 in lib/https.js. + expect(new Agent({ maxCachedSessions: null as never }).maxCachedSessions).toBeNull(); + expect(new Agent({ maxCachedSessions: 7 }).maxCachedSessions).toBe(7); + }); +}); + +describeDifferential("https.Agent differential", () => { + for (const { label, options } of NAME_CASES) { + test.concurrent(`getName matches Node for ${label}`, () => { + expect(new Agent().getName(options)).toBe( + new nodeHttps.Agent().getName(options as Parameters[0]), + ); + }); + } + + test.concurrent("declares the same own prototype members as Node", () => { + expect(Object.getOwnPropertyNames(Agent.prototype).sort()).toEqual( + Object.getOwnPropertyNames(nodeHttps.Agent.prototype).sort(), + ); + expect(Object.getPrototypeOf(Agent)).toBe(HttpAgent); + }); + + test.concurrent("matches Node's option defaults", () => { + for (const options of [ + undefined, + {}, + { maxCachedSessions: 5 }, + { maxCachedSessions: null as never }, + { defaultPort: 8443 }, + { protocol: "http:" }, + { keepAlive: true, scheduling: "lifo" as const }, + ]) { + const portable = new Agent(options); + const native = new nodeHttps.Agent(options); + expect(portable.defaultPort).toBe(native.defaultPort); + expect(portable.protocol).toBe(native.protocol); + expect(portable.maxCachedSessions).toBe(native.maxCachedSessions); + expect({ ...portable.options }).toEqual({ ...native.options }); + } + }); + + test.concurrent("matches Node's global agent defaults", () => { + expect(globalAgent.defaultPort).toBe(nodeHttps.globalAgent.defaultPort); + expect(globalAgent.protocol).toBe(nodeHttps.globalAgent.protocol); + expect(globalAgent.maxCachedSessions).toBe(nodeHttps.globalAgent.maxCachedSessions); + expect({ ...globalAgent.options }).toEqual({ ...nodeHttps.globalAgent.options }); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts new file mode 100644 index 000000000..0260ca600 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/get.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; + +import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/incoming-message.js"; +import { nextTurn, recordingImplementation, response } from "./helpers/index.js"; + +describe("node:https get", () => { + test.concurrent("ends the request itself and returns the same request shape", () => { + const { https, requests } = recordingImplementation(); + const request = https.get("https://example.com/"); + expect(request).toBeInstanceOf(https.request("https://example.com/").constructor); + expect(request.writableEnded).toBe(true); + expect(requests[0]).toMatchObject({ method: "GET", scheme: "https", authority: "example.com" }); + expect(requests[0].body.byteLength).toBe(0); + }); + + test.concurrent("delivers response, data, and end asynchronously", async () => { + const { https } = recordingImplementation(response("secure body")); + const events: string[] = []; + const message = new Promise((resolve) => { + https.get("https://example.com/", (incoming) => { + events.push("callback"); + resolve(incoming); + }); + }); + // The implementation returned synchronously, but nothing is observable until a later turn. + expect(events).toEqual([]); + const incoming = await message; + incoming.setEncoding("utf8"); + const chunks: string[] = []; + incoming.on("data", (chunk: string) => chunks.push(chunk)); + await new Promise((resolve) => incoming.once("end", resolve)); + expect(events).toEqual(["callback"]); + expect(chunks.join("")).toBe("secure body"); + expect(incoming.statusCode).toBe(200); + expect(incoming.headers["content-type"]).toBe("text/plain"); + }); + + test.concurrent("accepts options and a callback after a URL", async () => { + const { https, requests } = recordingImplementation(); + const message = new Promise((resolve) => { + https.get( + "https://example.com/base", + { path: "/override", headers: { "X-A": "1" } }, + resolve, + ); + }); + await message; + expect(requests[0].pathWithQuery).toBe("/override"); + expect(requests[0].headers.some(({ name }) => name.toLowerCase() === "x-a")).toBe(true); + }); + + test.concurrent("reports implementation failures on the request", async () => { + const https = recordingImplementation().https; + const failing = recordingImplementation(); + failing.implementation.request = () => { + throw Object.assign(new Error("boom"), { code: "ECONNREFUSED" }); + }; + void https; + const request = failing.https.get("https://example.com/"); + const error = await new Promise((resolve) => request.once("error", resolve)); + expect(error).toMatchObject({ code: "ECONNREFUSED" }); + await nextTurn(); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts new file mode 100644 index 000000000..3e5578091 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/index.ts @@ -0,0 +1,81 @@ +import { createHttps } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; +import type { + HttpImplementation, + HttpImplementationRequest, + HttpImplementationResponse, + HttpListenOptions, + HttpRequestHandler, + HttpServerImplementation, + HttpServerOptions, +} from "../../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; + +const encoder = new TextEncoder(); + +export function response(body = "response body"): HttpImplementationResponse { + return { + statusCode: 200, + statusMessage: "OK", + httpVersion: "1.1", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode(body), + }; +} + +export function recordingImplementation(result = response()): { + https: ReturnType; + requests: HttpImplementationRequest[]; + implementation: HttpImplementation; +} { + const requests: HttpImplementationRequest[] = []; + const implementation: HttpImplementation = { + request(request) { + requests.push(request); + return result; + }, + }; + return { https: createHttps(implementation), requests, implementation }; +} + +export function servingImplementation(): { + https: ReturnType; + implementation: HttpImplementation; + options: HttpServerOptions[]; + request: (data: Parameters[0]) => ReturnType; +} { + const options: HttpServerOptions[] = []; + let handler: HttpRequestHandler | undefined; + const backend: HttpServerImplementation = { + listen: (_listenOptions: HttpListenOptions) => ({ + address: "127.0.0.1", + family: "IPv4" as const, + port: 8443, + }), + close: () => true, + closeAllConnections: () => undefined, + closeIdleConnections: () => undefined, + getConnections: () => 0, + address: () => ({ address: "127.0.0.1", family: "IPv4", port: 8443 }), + ref: () => undefined, + unref: () => undefined, + }; + const implementation: HttpImplementation = { + request: () => { + throw new Error("not used"); + }, + createServer(serverOptions, requestHandler) { + options.push(serverOptions); + handler = requestHandler; + return backend; + }, + }; + return { + https: createHttps(implementation), + implementation, + options, + request: (data) => handler!(data), + }; +} + +export function nextTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts new file mode 100644 index 000000000..71644b787 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/lifecycle.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { tcpCreateSocket, instanceNetwork } from "@bytecodealliance/preview2-shim/sockets"; +import { + createTlsProvider, + _resourceCounts, +} from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host/node"; + +function dispose(resource: object): void { + assert(Symbol.dispose in resource); + const drop: unknown = resource[Symbol.dispose]; + assert(typeof drop === "function"); + drop.call(resource); +} +const mode = process.argv[3]; +const ca = await readFile(new URL("./localhost.crt", import.meta.url), "utf8"); +const provider = createTlsProvider({ ca: [ca], handshakeTimeoutMs: 1000 }); +const before = _resourceCounts(); +const socket = tcpCreateSocket.createTcpSocket("ipv4"); +socket.startConnect(instanceNetwork.instanceNetwork(), { + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: Number(process.argv[2]) }, +}); +const poll = socket.subscribe(); +poll.block(); +dispose(poll); +const [input, output] = socket.finishConnect(); +const handshake = new provider.ClientHandshake("localhost", input, output); +assert.throws(() => createTlsProvider().ClientHandshake.finish(handshake), /another provider/); +if (mode === "unstarted") { + handshake[Symbol.dispose](); +} else { + const future = provider.ClientHandshake.finish(handshake); + assert.throws(() => provider.ClientHandshake.finish(handshake), /consumed/); + handshake[Symbol.dispose](); // consuming finish transfers ownership out of it + const ready = future.subscribe(); + assert.throws(() => future[Symbol.dispose](), /child poll/); + if (mode === "pending") { + assert.equal(future.get(), undefined); + dispose(ready); + future[Symbol.dispose](); + } else { + ready.block(); + dispose(ready); + const result = future.get(); + assert.equal(result?.tag, "ok"); + assert(result?.tag === "ok" && result.val.tag === "ok"); + assert.deepEqual(future.get(), { tag: "err", val: undefined }); + const [connection, plaintextInput, plaintextOutput] = result.val.val; + future[Symbol.dispose](); + connection.closeOutput(); + dispose(plaintextOutput); + dispose(plaintextInput); + connection[Symbol.dispose](); + connection[Symbol.dispose](); + } +} +dispose(socket); +assert.deepEqual(_resourceCounts(), before); +console.log("clean"); diff --git a/packages/preview2-shim/test/fixtures/tls/localhost.crt b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt similarity index 100% rename from packages/preview2-shim/test/fixtures/tls/localhost.crt rename to packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt diff --git a/packages/preview2-shim/test/fixtures/tls/localhost.key b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key similarity index 100% rename from packages/preview2-shim/test/fixtures/tls/localhost.key rename to packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts new file mode 100644 index 000000000..df1678741 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host-node.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createServer as tcpServer, type Socket } from "node:net"; +import { createServer as tlsServer } from "node:tls"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); +const fixture = new URL("./helpers/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", fixture)); +const key = await readFile(new URL("localhost.key", fixture)); +test.concurrent.each(["unstarted", "pending", "completed"])( + "TLS resource ownership: %s", + async (mode: string): Promise => { + const peers = new Set(); + const server = mode === "completed" ? tlsServer({ cert, key }) : tcpServer(); + server.on("connection", (socket: Socket): void => { + peers.add(socket); + socket.once("close", (): void => { + peers.delete(socket); + }); + }); + server.on("tlsClientError", (): void => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP address"); + } + const result = await exec( + process.execPath, + [fileURLToPath(new URL("lifecycle.ts", fixture)), String(address.port), mode], + { timeout: 5000 }, + ); + expect(result.stdout.trim()).toBe("clean"); + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts new file mode 100644 index 000000000..cb3515be5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts @@ -0,0 +1,125 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { Server, request } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; +import type { + DirectHttpRequestListener, + DirectHttpServer, + DirectHttpServerOptions, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const FIXTURES = new URL("./helpers/tls/", import.meta.url); +const cert = new Uint8Array(readFileSync(new URL("localhost.crt", FIXTURES))); +const key = new Uint8Array(readFileSync(new URL("localhost.key", FIXTURES))); + +const servers = new Set(); + +afterEach(async () => { + await Promise.all([...servers].map((server) => server.close())); + servers.clear(); +}); + +const echo: DirectHttpRequestListener = { + handle: async (incoming) => ({ + tag: "ok", + val: { + statusCode: 200, + statusMessage: "OK", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode(`${incoming.method} ${incoming.url} ${decoder.decode(incoming.body)}`), + }, + }), + [Symbol.dispose]: () => undefined, +}; + +async function listen( + options: DirectHttpServerOptions, +): Promise<{ server: DirectHttpServer; port: number }> { + const server = new Server(options, echo); + servers.add(server); + const started = await server.listen({ port: 0, host: "127.0.0.1" }); + if (started.tag === "err" || started.val.tag !== "tcp") { + throw new Error(`expected a TCP listener, got ${JSON.stringify(started)}`); + } + return { server, port: started.val.val.port }; +} + +describe("node:https direct Node host", () => { + test("terminates TLS for a server carrying a tls record", async () => { + const { port } = await listen({ tls: { key: [key], cert: [cert] } }); + const result = await request({ + method: "POST", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/secure", + headers: [ + { name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }, + { name: "Content-Length", value: encoder.encode("5") }, + ], + body: encoder.encode("hello"), + // The fixture certificate names `localhost`, so SNI carries that name while the + // connection itself goes to the loopback address. + tls: { ca: [cert], servername: "localhost" }, + }); + expect(result.tag).toBe("ok"); + if (result.tag === "ok") { + expect(result.val.statusCode).toBe(200); + expect(decoder.decode(result.val.body)).toBe("POST /secure hello"); + } + }); + + test("verifies the server certificate unless told not to", async () => { + const { port } = await listen({ tls: { key: [key], cert: [cert] } }); + const base = { + method: "GET", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + }; + const untrusted = await request({ ...base, tls: { servername: "localhost" } }); + expect(untrusted.tag).toBe("err"); + if (untrusted.tag === "err") { + expect(untrusted.val.code).toMatch(/SELF_SIGNED|DEPTH_ZERO/); + } + const unverified = await request({ + ...base, + tls: { servername: "localhost", rejectUnauthorized: false }, + }); + expect(unverified).toMatchObject({ tag: "ok", val: { statusCode: 200 } }); + }); + + test("builds an https server from an empty tls record and fails the handshake like Node", async () => { + const { port } = await listen({ tls: {} }); + const result = await request({ + method: "GET", + scheme: "https", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + tls: { rejectUnauthorized: false }, + }); + expect(result.tag).toBe("err"); + }); + + test("keeps serving plaintext when no tls record is present", async () => { + const { port } = await listen({}); + const result = await request({ + method: "GET", + scheme: "http", + authority: `127.0.0.1:${port}`, + pathWithQuery: "/plain", + headers: [{ name: "Host", value: encoder.encode(`127.0.0.1:${port}`) }], + body: new Uint8Array(), + }); + expect(result).toMatchObject({ tag: "ok", val: { statusCode: 200 } }); + if (result.tag === "ok") { + expect(decoder.decode(result.val.body)).toBe("GET /plain "); + } + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts new file mode 100644 index 000000000..692e8eb8b --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/module.ts @@ -0,0 +1,127 @@ +import nodeHttp from "node:http"; +import nodeHttps from "node:https"; +import * as nodeHttpsNamespace from "node:https"; + +import { describe, expect, test } from "vitest"; + +import { Agent as HttpAgent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/agent.js"; +import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; +import { createDirectHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/direct.js"; +import * as denyHost from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; +import { recordingImplementation } from "./helpers/index.js"; + +describe("node:https module", () => { + test.concurrent("exposes the Node 24 module surface", () => { + const { https } = recordingImplementation(); + expect(Object.keys(https).sort()).toEqual(Object.keys(nodeHttps).sort()); + }); + + test.concurrent("matches Node's default-versus-namespace split", () => { + // node:https has no `default` key of its own; the namespace adds one, so the two objects + // are never the same value and a `default` import sees only the six real exports. + expect(nodeHttpsNamespace.default).not.toBe(nodeHttpsNamespace); + expect(Object.keys(nodeHttpsNamespace).sort()).toEqual( + [...Object.keys(nodeHttps), "default"].sort(), + ); + }); + + test.concurrent("exposes a strictly smaller surface than node:http", () => { + const { https } = recordingImplementation(); + const { http } = { http: createHttp({ request: () => response() }) }; + expect(Object.keys(https).every((name) => name in http)).toBe(true); + expect(Object.keys(http).length).toBeGreaterThan(Object.keys(https).length); + function response(): never { + throw new Error("not used"); + } + }); + + test.concurrent("subclasses the node:http agent on both chains", () => { + const { https } = recordingImplementation(); + expect(Object.getPrototypeOf(https.Agent)).toBe(HttpAgent); + expect(Object.getPrototypeOf(https.Agent.prototype)).toBe(HttpAgent.prototype); + expect(new https.Agent()).toBeInstanceOf(HttpAgent); + }); + + test.concurrent("gives each protocol its own classes and global agent", () => { + const { https } = recordingImplementation(); + const http = createHttp({ + request: () => { + throw new Error("not used"); + }, + }); + expect(https.Agent).not.toBe(http.Agent); + expect(https.globalAgent).not.toBe(http.globalAgent); + expect(https.Server).not.toBe(http.Server); + expect(https.request).not.toBe(http.request); + }); + + test.concurrent("keeps one Agent class across module instances", () => { + expect(recordingImplementation().https.Agent).toBe(recordingImplementation().https.Agent); + expect(recordingImplementation().https.globalAgent).toBe( + recordingImplementation().https.globalAgent, + ); + }); + + test.concurrent("denies the direct capability by default", async () => { + const https = createHttps(createDirectHttpImplementation(denyHost)); + expect(() => https.createServer()).toThrow( + expect.objectContaining({ code: "ERR_JCO_HTTP_ADAPTER_REQUIRED" }), + ); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ code: "ERR_JCO_HTTP_ADAPTER_REQUIRED" }); + }); + + test.concurrent("rejects server construction when an implementation cannot listen", () => { + const { https } = recordingImplementation(); + expect(() => https.createServer(() => undefined)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server"), + }), + ); + }); + + test.concurrent("matches Node's callable shapes", () => { + const { https } = recordingImplementation(); + for (const name of ["createServer", "get", "request"] as const) { + expect(typeof https[name]).toBe("function"); + expect(typeof nodeHttps[name]).toBe("function"); + } + for (const name of ["Agent", "Server"] as const) { + expect(typeof https[name]).toBe("function"); + expect(https[name].prototype).toBeTypeOf("object"); + } + }); + + test.concurrent("omits the node:http-only exports Node also omits", () => { + const { https } = recordingImplementation(); + for (const name of [ + "METHODS", + "STATUS_CODES", + "maxHeaderSize", + "IncomingMessage", + "OutgoingMessage", + "ServerResponse", + "ClientRequest", + "validateHeaderName", + "validateHeaderValue", + "setMaxIdleHTTPParsers", + "setGlobalProxyFromEnv", + "_connectionListener", + "WebSocket", + ]) { + expect(name in https).toBe(false); + expect(name in nodeHttps).toBe(false); + expect(name in nodeHttp).toBe(true); + } + }); + + test.concurrent("does not touch globals on import", () => { + const before = Object.keys(globalThis).length; + recordingImplementation(); + expect(Object.keys(globalThis).length).toBe(before); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts new file mode 100644 index 000000000..76ea1e39c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/request.ts @@ -0,0 +1,224 @@ +import nodeHttps from "node:https"; + +import { describe, expect, test } from "vitest"; + +import type { IncomingMessage } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/incoming-message.js"; +import { Agent } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/agent.js"; +import { describeDifferential } from "../helpers/assert.js"; +import { recordingImplementation } from "./helpers/index.js"; + +const decoder = new TextDecoder(); + +function header( + request: { headers: Array<{ name: string; value: Uint8Array }> }, + name: string, +): string | undefined { + const field = request.headers.find((entry) => entry.name.toLowerCase() === name); + return field === undefined ? undefined : decoder.decode(field.value); +} + +describe("node:https request", () => { + test.concurrent("sends the https scheme to the implementation", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com/a?b=1").end(); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + method: "GET", + scheme: "https", + authority: "example.com", + pathWithQuery: "/a?b=1", + }); + }); + + test.concurrent("elides the default port 443 from the authority and Host header", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com:443/").end(); + https.request("https://example.com:8443/").end(); + expect(requests[0].authority).toBe("example.com"); + expect(header(requests[0], "host")).toBe("example.com"); + expect(requests[1].authority).toBe("example.com:8443"); + expect(header(requests[1], "host")).toBe("example.com:8443"); + }); + + test.concurrent("keeps port 80 in the authority, unlike node:http", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", port: 80 }).end(); + expect(requests[0].authority).toBe("example.com:80"); + }); + + test.concurrent("defaults to port 443 when the options carry none", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com" }).end(); + expect(requests[0].authority).toBe("example.com"); + expect(header(requests[0], "host")).toBe("example.com"); + }); + + test.concurrent("honours an explicit defaultPort for elision", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", defaultPort: 8443 }).end(); + expect(requests[0].authority).toBe("example.com"); + }); + + test.concurrent("takes the default port from an explicitly supplied agent", () => { + const { https, requests } = recordingImplementation(); + https.request({ host: "example.com", agent: new Agent({ defaultPort: 8443 }) }).end(); + expect(requests[0].authority).toBe("example.com"); + }); + + test.concurrent("defaults the agent to the https global agent", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(request.agent).toBe(https.globalAgent); + expect(request.agent?.protocol).toBe("https:"); + request.end(); + }); + + test.concurrent("gives agent: false a fresh https agent", () => { + const { https } = recordingImplementation(); + const request = https.request({ host: "example.com", agent: false }); + expect(request.agent).toBeInstanceOf(https.Agent); + expect(request.agent).not.toBe(https.globalAgent); + expect(request.agent.protocol).toBe("https:"); + expect(request.agent.keepAlive).toBe(false); + request.end(); + }); + + test.concurrent("reports the https protocol on the request", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(request.protocol).toBe("https:"); + request.end(); + }); + + test.concurrent("carries basic auth from the URL", () => { + const { https, requests } = recordingImplementation(); + https.request("https://user:pass@example.com/").end(); + expect(header(requests[0], "authorization")).toBe(`Basic ${btoa("user:pass")}`); + }); + + test.concurrent("buffers a request body and frames it", async () => { + const { https, requests } = recordingImplementation(); + const response = new Promise((resolve) => { + const request = https.request("https://example.com/", { method: "post" }, resolve); + request.write("hello "); + request.end("world"); + }); + const message = await response; + expect(decoder.decode(requests[0].body)).toBe("hello world"); + expect(header(requests[0], "content-length")).toBe("11"); + expect(requests[0].method).toBe("POST"); + expect(message.statusCode).toBe(200); + }); + + test.concurrent("carries client TLS options to the implementation", () => { + const { https, requests } = recordingImplementation(); + https + .request({ + host: "example.com", + ca: ["A", "B"], + cert: "C", + key: "K", + rejectUnauthorized: false, + servername: "sni.example.com", + minVersion: "TLSv1.3", + ALPNProtocols: ["http/1.1"], + }) + .end(); + const tls = requests[0].tls!; + expect(tls.ca!.map((entry) => decoder.decode(entry))).toEqual(["A", "B"]); + expect(tls.cert!.map((entry) => decoder.decode(entry))).toEqual(["C"]); + expect(tls.key!.map((entry) => decoder.decode(entry))).toEqual(["K"]); + expect(tls).toMatchObject({ + rejectUnauthorized: false, + servername: "sni.example.com", + minVersion: "TLSv1.3", + alpnProtocols: ["http/1.1"], + }); + }); + + test.concurrent("omits the TLS record when no TLS option is given", () => { + const { https, requests } = recordingImplementation(); + https.request("https://example.com/").end(); + https.request({ host: "example.com", timeout: 5, headers: { a: "b" } }).end(); + expect("tls" in requests[0]).toBe(false); + expect("tls" in requests[1]).toBe(false); + }); + + test.concurrent("refuses unrepresentable client TLS options before sending anything", () => { + const { https, requests } = recordingImplementation(); + for (const [name, value] of [ + ["checkServerIdentity", () => undefined], + ["secureContext", {}], + ["session", new Uint8Array(8)], + ["pskCallback", () => undefined], + ] as const) { + expect(() => https.request({ host: "example.com", [name]: value })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`https.request option ${name}`), + }), + ); + } + expect(requests).toHaveLength(0); + }); + + test.concurrent("labels its refusals as https", () => { + const { https } = recordingImplementation(); + const request = https.request("https://example.com/"); + expect(() => request.setNoDelay()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.ClientRequest.setNoDelay"), + }), + ); + expect(() => request.abort()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", + message: expect.stringContaining("https.ClientRequest.abort"), + }), + ); + request.end(); + }); +}); + +describeDifferential("node:https request differential", () => { + for (const protocol of ["http:", "ftp:", "wss:"]) { + test.concurrent(`rejects the ${protocol} protocol the way Node does`, () => { + const { https } = recordingImplementation(); + const options = { host: "example.com", protocol }; + let native: unknown; + try { + nodeHttps.request(options).destroy(); + } catch (error) { + native = error; + } + expect(native).toMatchObject({ + code: "ERR_INVALID_PROTOCOL", + message: `Protocol "${protocol}" not supported. Expected "https:"`, + }); + expect(() => https.request(options)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_PROTOCOL", + message: (native as Error).message, + }), + ); + }); + } + + test.concurrent("rejects an http URL the way Node does", () => { + const { https } = recordingImplementation(); + expect(() => https.request("http://example.com/")).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_PROTOCOL", + message: 'Protocol "http:" not supported. Expected "https:"', + }), + ); + }); + + test.concurrent("rejects unescaped characters in the path", () => { + const { https } = recordingImplementation(); + expect(() => https.request({ host: "example.com", path: "/a b" })).toThrow( + expect.objectContaining({ code: "ERR_UNESCAPED_CHARACTERS" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts new file mode 100644 index 000000000..fca4271ea --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/server.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, test } from "vitest"; + +import { createHttp } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/core.js"; +import { servingImplementation } from "./helpers/index.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const FIXTURES = new URL("./helpers/tls/", import.meta.url); +const cert = readFileSync(new URL("localhost.crt", FIXTURES)); +const key = readFileSync(new URL("localhost.key", FIXTURES)); + +describe("node:https Server", () => { + test("hands normalized TLS material to the implementation", () => { + const { https, options } = servingImplementation(); + https.createServer({ key, cert, passphrase: "pw", ALPNProtocols: ["http/1.1"] }); + expect(options).toHaveLength(1); + expect(options[0].tls).toEqual({ + key: [new Uint8Array(key)], + cert: [new Uint8Array(cert)], + passphrase: "pw", + alpnProtocols: ["http/1.1"], + }); + // The HTTP-level half of the bag still reaches the implementation untouched. + expect(options[0].passphrase).toBe("pw"); + }); + + test("keeps key, cert, and ca arrays as lists", () => { + const { https, options } = servingImplementation(); + https.createServer({ key: [key, "second"], cert: [cert], ca: ["a", "b"] }); + expect(options[0].tls!.key!.map((entry) => decoder.decode(entry))).toEqual([ + key.toString(), + "second", + ]); + expect(options[0].tls!.ca!.map((entry) => decoder.decode(entry))).toEqual(["a", "b"]); + }); + + test("always carries a TLS record, even when no TLS option was supplied", () => { + // Node constructs an https.Server without a certificate and fails each handshake; the + // record's presence is what tells an implementation without a TLS stack to refuse. + const { https, options } = servingImplementation(); + https.createServer(); + https.createServer(() => undefined); + https.createServer({ requestTimeout: 1_000 }); + expect(options.map(({ tls }) => tls)).toEqual([{}, {}, {}]); + expect(options[2].requestTimeout).toBe(1_000); + }); + + test("passes no TLS record for a node:http server, whatever the bag contains", () => { + const { implementation, options } = servingImplementation(); + createHttp(implementation).createServer({ key, cert }); + expect(options[0].tls).toBeUndefined(); + expect(options[0].key).toBe(key); + }); + + test("refuses unrepresentable TLS options by name before creating the server", () => { + const { https, options } = servingImplementation(); + for (const [name, value] of [ + ["SNICallback", () => undefined], + ["ALPNCallback", () => undefined], + ["secureContext", {}], + ["ticketKeys", new Uint8Array(48)], + ] as const) { + expect(() => https.createServer({ key, cert, [name]: value })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`https.createServer option ${name}`), + }), + ); + } + expect(options).toHaveLength(0); + }); + + test("labels HTTP-level refusals as https", () => { + const { https } = servingImplementation(); + expect(() => https.createServer({ insecureHTTPParser: true })).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server option insecureHTTPParser"), + }), + ); + const server = https.createServer({ key, cert }); + expect(() => server.listen({ port: 0, signal: new AbortController().signal })).toThrow( + expect.objectContaining({ message: expect.stringContaining("https.Server.listen signal") }), + ); + }); + + test("rejects a non-object options argument the way Node does", () => { + const { https } = servingImplementation(); + expect(() => https.createServer("8443" as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); + + test("dispatches inbound requests through the implementation", async () => { + const { https, request } = servingImplementation(); + const server = https.createServer({ key, cert }, async (incoming, outgoing) => { + let body = ""; + incoming.setEncoding("utf8"); + for await (const chunk of incoming) { + body += chunk; + } + outgoing.writeHead(201, "Created", { "X-Method": incoming.method! }); + outgoing.end(`${incoming.url}:${body}`); + }); + server.listen(8443, "127.0.0.1"); + await Promise.resolve(); + expect(server.listening).toBe(true); + expect(server.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 8443 }); + const response = await request({ + method: "POST", + url: "/items", + httpVersion: "1.1", + headers: [{ name: "Content-Type", value: encoder.encode("text/plain") }], + body: encoder.encode("hello"), + }); + expect(response).toMatchObject({ statusCode: 201, statusMessage: "Created" }); + expect(decoder.decode(response.body)).toBe("/items:hello"); + server.close(); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts new file mode 100644 index 000000000..b0de3ffdf --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/tls.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "vitest"; + +import { tlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/tls.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const API = "https.createServer option"; + +function text(value: Uint8Array | undefined): string | undefined { + return value === undefined ? undefined : decoder.decode(value); +} + +describe("node:https TLS option normalization", () => { + test.concurrent("returns undefined when no carried option is present", () => { + expect(tlsMaterial({}, API)).toBeUndefined(); + expect(tlsMaterial({ requestTimeout: 5 } as never, API)).toBeUndefined(); + }); + + test.concurrent("encodes string material as UTF-8 and keeps every field a list", () => { + const material = tlsMaterial({ key: "K", cert: "C", pfx: "P", ca: "A", crl: "R" }, API)!; + expect(material.key!.map(text)).toEqual(["K"]); + expect(material.cert!.map(text)).toEqual(["C"]); + expect(material.pfx!.map(text)).toEqual(["P"]); + expect(material.ca!.map(text)).toEqual(["A"]); + expect(material.crl!.map(text)).toEqual(["R"]); + }); + + test.concurrent("preserves arrays entry by entry instead of joining them", () => { + // OpenSSL reads only the first key out of a concatenated PEM, so a joined bundle would + // silently lose the second key. + const material = tlsMaterial({ key: ["rsa", "ecdsa"], cert: ["leaf", "chain"] }, API)!; + expect(material.key!.map(text)).toEqual(["rsa", "ecdsa"]); + expect(material.cert!.map(text)).toEqual(["leaf", "chain"]); + }); + + test.concurrent("copies binary material out of its source buffer", () => { + const source = new Uint8Array([1, 2, 3, 4]); + const view = new DataView(source.buffer, 1, 2); + const material = tlsMaterial( + { key: source, cert: view, pfx: source.buffer, dhparam: source.subarray(2) }, + API, + )!; + expect([...material.key![0]]).toEqual([1, 2, 3, 4]); + expect([...material.cert![0]]).toEqual([2, 3]); + expect([...material.pfx![0]]).toEqual([1, 2, 3, 4]); + expect([...material.dhparam!]).toEqual([3, 4]); + source.fill(0); + expect([...material.key![0]]).toEqual([1, 2, 3, 4]); + }); + + test.concurrent("carries every scalar the record has a field for", () => { + expect( + tlsMaterial( + { + passphrase: "pw", + ciphers: "AES", + ecdhCurve: "auto", + sigalgs: "ecdsa_secp256r1_sha256", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + secureOptions: 4, + sessionIdContext: "ctx", + honorCipherOrder: true, + servername: "example.com", + rejectUnauthorized: false, + requestCert: true, + }, + API, + ), + ).toEqual({ + passphrase: "pw", + ciphers: "AES", + ecdhCurve: "auto", + sigalgs: "ecdsa_secp256r1_sha256", + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + secureProtocol: "TLS_method", + secureOptions: 4, + sessionIdContext: "ctx", + honorCipherOrder: true, + servername: "example.com", + rejectUnauthorized: false, + requestCert: true, + }); + }); + + test.concurrent("accepts ALPN protocols as an array or in wire form", () => { + expect(tlsMaterial({ ALPNProtocols: ["h2", "http/1.1"] }, API)).toEqual({ + alpnProtocols: ["h2", "http/1.1"], + }); + const wire = new Uint8Array([2, ...encoder.encode("h2"), 8, ...encoder.encode("http/1.1")]); + expect(tlsMaterial({ ALPNProtocols: wire }, API)).toEqual({ + alpnProtocols: ["h2", "http/1.1"], + }); + }); + + test.concurrent("rejects malformed ALPN input", () => { + for (const value of [ + new Uint8Array([0]), + new Uint8Array([5, 104]), + [1], + "h2", + 42, + ] as unknown[]) { + expect(() => tlsMaterial({ ALPNProtocols: value as never }, API)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + }); + + test.concurrent("validates scalar types the way Node's validators do", () => { + for (const options of [ + { passphrase: 1 }, + { ciphers: ["AES"] }, + { minVersion: 1.2 }, + { honorCipherOrder: "yes" }, + { rejectUnauthorized: 0 }, + { requestCert: "true" }, + { secureOptions: "4" }, + { key: 42 }, + { ca: [null] }, + { dhparam: {} }, + ]) { + expect(() => tlsMaterial(options as never, API)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + for (const secureOptions of [-1, 1.5, 2 ** 32]) { + expect(() => tlsMaterial({ secureOptions }, API)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + } + }); + + test.concurrent("refuses per-entry passphrases rather than dropping them", () => { + expect(() => tlsMaterial({ key: ["a", { pem: "b", passphrase: "p" }] } as never, API)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`${API} key[1]`), + }), + ); + expect(() => tlsMaterial({ pfx: [{ buf: "b", passphrase: "p" }] } as never, API)).toThrow( + expect.objectContaining({ message: expect.stringContaining(`${API} pfx[0]`) }), + ); + }); + + test.concurrent("refuses every option that cannot cross the boundary, by name", () => { + const refused = [ + "ALPNCallback", + "SNICallback", + "checkServerIdentity", + "pskCallback", + "secureContext", + "session", + "ticketKeys", + "clientCertEngine", + "privateKeyEngine", + "privateKeyIdentifier", + ]; + for (const name of refused) { + expect(() => tlsMaterial({ key: "K", [name]: () => undefined } as never, API)).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining(`${API} ${name}`), + }), + ); + } + // The label is the caller's, so a client refusal names the request API. + expect(() => + tlsMaterial({ checkServerIdentity: () => undefined } as never, "https.request option"), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining("https.request option checkServerIdentity"), + }), + ); + }); + + test.concurrent("checks for refused options before touching any material", () => { + let read = false; + const options = { + SNICallback: () => undefined, + get key(): string { + read = true; + return "K"; + }, + }; + expect(() => tlsMaterial(options as never, API)).toThrow(); + expect(read).toBe(false); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts new file mode 100644 index 000000000..0c445c89c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-http.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; + +import { + createWasiHttpImplementation, + type WasiHttpProvider, + type WasiHttpScheme, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; + +/** A provider that records the scheme and then refuses the connection. */ +function refusingProvider(): { provider: WasiHttpProvider; schemes: WasiHttpScheme[] } { + const schemes: WasiHttpScheme[] = []; + const provider = { + outgoingHandler: { + handle() { + return { + subscribe: () => ({ block: () => undefined }), + get: () => ({ + tag: "ok" as const, + val: { tag: "err" as const, val: { tag: "connection-refused" } }, + }), + }; + }, + }, + types: { + Fields: { fromList: () => ({ entries: () => [] }) }, + IncomingBody: { finish: () => undefined }, + OutgoingBody: { finish: () => undefined }, + OutgoingRequest: class { + body() { + return { write: () => ({ blockingWriteAndFlush: () => undefined }) }; + } + + setMethod(): void {} + + setScheme(scheme: WasiHttpScheme | undefined): void { + if (scheme) { + schemes.push(scheme); + } + } + + setAuthority(): void {} + + setPathWithQuery(): void {} + }, + RequestOptions: class { + setConnectTimeout(): void {} + + setFirstByteTimeout(): void {} + + setBetweenBytesTimeout(): void {} + }, + }, + } satisfies WasiHttpProvider; + return { provider, schemes }; +} + +describe("node:https wasi:http implementation", () => { + test.concurrent("sets the HTTPS scheme variant rather than an `other` string", async () => { + const { provider, schemes } = refusingProvider(); + const https = createHttps(createWasiHttpImplementation(provider)); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ code: "ECONNREFUSED" }); + expect(schemes).toEqual([{ tag: "HTTPS" }]); + }); + + test.concurrent("refuses per-request TLS options, which outgoing-handler cannot honour", async () => { + const { provider, schemes } = refusingProvider(); + const https = createHttps(createWasiHttpImplementation(provider)); + const request = https.request({ host: "example.com", rejectUnauthorized: false }); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining( + "https.request TLS options with the wasi-http implementation", + ), + }); + expect(schemes).toEqual([]); + }); + + test.concurrent("rejects server construction immediately", () => { + const https = createHttps(createWasiHttpImplementation({} as never)); + expect(() => https.createServer()).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("https.Server"), + }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts new file mode 100644 index 000000000..6bc3f4d72 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-sockets.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { + createWasiSocketsHttpImplementation, + type WasiSocketsProvider, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import { createHttps } from "../../../../../../src/wasi/0.2.x/node/24.x.x/https/core.js"; + +/** A provider that fails loudly if the implementation ever reaches the network. */ +function untouchedProvider(): WasiSocketsProvider { + return { + instanceNetwork: { + instanceNetwork: () => { + throw new Error("network touched"); + }, + }, + ipNameLookup: { + resolveAddresses: () => { + throw new Error("resolver touched"); + }, + }, + tcpCreateSocket: { + createTcpSocket: () => { + throw new Error("socket created"); + }, + }, + }; +} + +describe("node:https wasi:sockets implementation", () => { + test.concurrent("reports missing TLS capability before touching the network", async () => { + const https = createHttps(createWasiSocketsHttpImplementation(untouchedProvider())); + const request = https.request("https://example.com/"); + const error = new Promise((resolve) => request.once("error", resolve)); + request.end(); + await expect(error).resolves.toMatchObject({ + code: "ERR_JCO_TLS_ADAPTER_REQUIRED", + message: expect.stringMatching(/https: requests with the wasi-sockets implementation.*TLS/), + }); + }); + + test.concurrent("refuses https servers instead of serving plaintext", () => { + const https = createHttps(createWasiSocketsHttpImplementation(untouchedProvider())); + for (const create of [ + () => https.createServer(), + () => https.createServer({ key: "K", cert: "C" }, () => undefined), + () => new https.Server(), + ]) { + expect(create).toThrow( + expect.objectContaining({ + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringMatching(/https\.Server with the wasi-sockets implementation.*TLS/), + }), + ); + } + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts new file mode 100644 index 000000000..71bf3ea14 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/wasi-tls.ts @@ -0,0 +1,183 @@ +import { tlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/tls.js"; +import { expect, test } from "vitest"; +import { + authority, + errorCode, + createWasiSocketsHttpImplementation, + type WasiInputStream, + type WasiOutputStream, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import { + handshake, + validateTlsOptions, + type WasiTlsProvider, + type WasiTlsResult, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.js"; +import type { HttpTlsMaterial } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/types.js"; +import * as denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/tls-host.js"; + +test.concurrent("HTTPS authority defaults to 443 and preserves explicit ports", () => { + expect(authority("example.com", "https")).toEqual({ hostname: "example.com", port: 443 }); + expect(authority("example.com:8443", "https")).toEqual({ hostname: "example.com", port: 8443 }); + expect(authority("example.com")).toEqual({ hostname: "example.com", port: 80 }); +}); + +test.concurrent("recognizes ComponentError payloads for socket polling and stream closure", () => { + expect(errorCode(Object.assign(new Error("would-block"), { payload: "would-block" }))).toBe( + "would-block", + ); + expect(errorCode(Object.assign(new Error("closed"), { payload: { tag: "closed" } }))).toBe( + "closed", + ); + expect(errorCode(new Error("would-block"))).toBeUndefined(); +}); + +const options: HttpTlsMaterial[] = [ + { ca: [] }, + { key: [] }, + { cert: [] }, + { pfx: [] }, + { passphrase: "x" }, + { crl: [] }, + { dhparam: new Uint8Array() }, + { ciphers: "x" }, + { ecdhCurve: "x" }, + { sigalgs: "x" }, + { minVersion: "TLSv1.2" }, + { maxVersion: "TLSv1.3" }, + { secureProtocol: "x" }, + { secureOptions: 0 }, + { sessionIdContext: "x" }, + { honorCipherOrder: true }, + { alpnProtocols: ["http/1.1"] }, + { rejectUnauthorized: false }, + { requestCert: false }, + { servername: "" }, +]; +test.concurrent.each(options)( + "rejects an unexpressible TLS option %j", + (option: HttpTlsMaterial) => { + expect(() => validateTlsOptions(option)).toThrow(/wasi:tls/); + }, +); +test.concurrent("accepts servername and explicitly enabled verification", () => { + expect(() => + validateTlsOptions({ servername: "localhost", rejectUnauthorized: true }), + ).not.toThrow(); +}); + +test.concurrent("polls a pending handshake and drops the poll before its future", () => { + const events: string[] = []; + let ready = false; + const input = { blockingRead: (): Uint8Array => new Uint8Array() }; + const output = { blockingWriteAndFlush: (): void => {} }; + const connection = { closeOutput: (): void => {} }; + const provider: WasiTlsProvider = { + 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("future"); + }, + }; + } + }, + }; + expect(handshake(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", + val: { + toDebugString: (): string => "untrusted certificate", + [Symbol.dispose]: (): void => { + events.push("error"); + }, + }, + }, + }), + subscribe: (): never => { + throw new Error("unexpected poll"); + }, + [Symbol.dispose]: (): void => { + events.push("future"); + }, + }; + } + }, + }; + expect(() => handshake(provider, "localhost", input, output)).toThrow(/untrusted certificate/); + expect(events).toEqual(["error", "future"]); +}); + +test.concurrent("default denial is lazy and refuses before acquiring TCP resources", () => { + const implementation = createWasiSocketsHttpImplementation({ + tls: denied, + instanceNetwork: { + instanceNetwork: (): never => { + throw new Error("network touched"); + }, + }, + ipNameLookup: { + resolveAddresses: (): never => { + throw new Error("DNS touched"); + }, + }, + tcpCreateSocket: { + createTcpSocket: (): never => { + throw new Error("TCP touched"); + }, + }, + }); + expect(() => + implementation.request({ + scheme: "https", + method: "GET", + authority: "example.com", + pathWithQuery: "/", + headers: [], + body: new Uint8Array(), + }), + ).toThrow(expect.objectContaining({ code: "ERR_JCO_TLS_ADAPTER_REQUIRED" })); +}); + +test.concurrent.each([ + "allowPartialTrustChain", + "enableTrace", + "requestOCSP", + "minDHSize", + "handshakeTimeout", + "sessionTimeout", +])("rejects uncarried TLS setting %s instead of dropping it", (name: string): void => { + expect(() => + tlsMaterial({ servername: "localhost", ...{ [name]: true } }, "https.request option"), + ).toThrow(name); +}); diff --git a/packages/jco-std/wit/node-0.1.0/http.wit b/packages/jco-std/wit/node-0.1.0/http.wit index 484a06af3..f019e80d4 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -78,6 +78,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -88,6 +90,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -102,6 +132,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { diff --git a/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md b/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md new file mode 100644 index 000000000..475309577 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/LICENSE.md @@ -0,0 +1,8 @@ +Copyright © 2019-2023 the Contributors to the WASI Specification, published +by the [WebAssembly Community Group][cg] under the +[W3C Community Contributor License Agreement (CLA)][cla]. A human-readable +[summary][summary] is available. + +[cg]: https://www.w3.org/community/webassembly/ +[cla]: https://www.w3.org/community/about/agreements/cla/ +[summary]: https://www.w3.org/community/about/agreements/cla-deed/ diff --git a/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md new file mode 100644 index 000000000..7f1ec7bd7 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/PROVENANCE.md @@ -0,0 +1,5 @@ +Adapted from WebAssembly/wasi-tls `wit/`, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5` (W3C Community CLA; see LICENSE.md). +Local changes: `wasi:io@0.2.12`, `is-available`, and no unstable-feature gate; see README.md. +IO WIT is copied from Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +Upstream wit-deps metadata is omitted; dependencies are vendored. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/README.md b/packages/jco-std/wit/tls-0.2.0-draft/README.md new file mode 100644 index 000000000..bce5a7393 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/README.md @@ -0,0 +1,25 @@ +# Local WASI TLS contract + +This is a slightly modified copy of [WebAssembly/wasi-tls](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit) +at revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, under the W3C Community +Contributor License Agreement (see LICENSE.md). + +It provides a provisional, shared interface for TLS implementations on Node.js, +the web, and other host platforms. It is not an unmodified upstream standard or +a claim that every host platform already has an implementation. + +Local changes: + +- Use `wasi:io@0.2.12` instead of `0.2.6`, sharing the sockets implementation's + stream resources directly without version bridging. IO WIT is copied from + Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +- Add `is-available`, a side-effect-free capability query so denied TLS requests + fail before acquiring TCP resources. +- Omit upstream unstable-feature annotations so normal WIT tooling can consume + this explicitly imported local contract without TLS-specific feature handling. + +The package retains `wasi:tls@0.2.0-draft`. +Hosts must implement this local contract. The upstream client handshake, +future polling, stream ownership, and output shutdown operations are retained. +Server TLS and guest trust/ALPN configuration remain outside the contract; +certificate verification and trust are host policy. No wit-deps tooling is used. diff --git a/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit new file mode 100644 index 000000000..8006d6d2e --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/deps/io/package.wit @@ -0,0 +1,66 @@ +package wasi:io@0.2.12; +interface error { + resource error { + to-debug-string: func() -> string; + } +} +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + poll: func(in: list>) -> list; +} +interface streams { + use error.{error}; + use poll.{pollable}; + variant stream-error { + last-operation-failed(error), + closed + } + resource input-stream { + read: func( + len: u64 + ) -> result, stream-error>; + blocking-read: func( + len: u64 + ) -> result, stream-error>; + skip: func( + len: u64, + ) -> result; + blocking-skip: func( + len: u64, + ) -> result; + subscribe: func() -> pollable; + } + resource output-stream { + check-write: func() -> result; + write: func( + contents: list + ) -> result<_, stream-error>; + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func( + len: u64 + ) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func( + len: u64 + ) -> result<_, stream-error>; + splice: func( + src: borrow, + len: u64, + ) -> result; + blocking-splice: func( + src: borrow, + len: u64, + ) -> result; + } +} +world imports { + import streams; + import poll; +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/types.wit b/packages/jco-std/wit/tls-0.2.0-draft/types.wit new file mode 100644 index 000000000..a2a664948 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/types.wit @@ -0,0 +1,27 @@ +// Adapted from WebAssembly/wasi-tls wit/types.wit, +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local changes: wasi:io@0.2.12 and an availability query. See README.md. +interface types { + /// Whether the host grants TLS connections. This query performs no IO. + is-available: func() -> bool; + + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + use wasi:io/error@0.2.12.{error as io-error}; + + resource client-handshake { + constructor(server-name: string, input: input-stream, output: output-stream); + + finish: static func(this: client-handshake) -> future-client-streams; + } + + resource client-connection { + close-output: func(); + } + + resource future-client-streams { + subscribe: func() -> pollable; + + get: func() -> option, io-error>>>; + } +} diff --git a/packages/jco-std/wit/tls-0.2.0-draft/world.wit b/packages/jco-std/wit/tls-0.2.0-draft/world.wit new file mode 100644 index 000000000..2c7093d93 --- /dev/null +++ b/packages/jco-std/wit/tls-0.2.0-draft/world.wit @@ -0,0 +1,8 @@ +// Adapted from WebAssembly/wasi-tls wit/world.wit at +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local change: omit unstable-feature gates; see README.md. +package wasi:tls@0.2.0-draft; + +world imports { + import types; +} diff --git a/packages/jco-transpile/test/browser/index.ts b/packages/jco-transpile/test/browser/index.ts index b0addbff3..af781bcb0 100644 --- a/packages/jco-transpile/test/browser/index.ts +++ b/packages/jco-transpile/test/browser/index.ts @@ -104,7 +104,7 @@ suite('Browser', () => { for (const fixture of ['dom', 'console']) { test(`runs the ${fixture} Web IDL component`, async () => { const { component } = await componentize({ - sourcePath: join(WEBIDL_FIXTURES_DIR, `${fixture}.test.js`), + sourcePath: join(WEBIDL_FIXTURES_DIR, `${fixture}.js`), disableFeatures: ['clocks', 'random', 'stdio'], witPath: join(WEBIDL_FIXTURES_DIR, `${fixture}.wit`), worldName: `${fixture === 'dom' ? 'window' : fixture}-test`, diff --git a/packages/jco-transpile/test/fixtures/webidl/console.test.js b/packages/jco-transpile/test/fixtures/webidl/console.js similarity index 100% rename from packages/jco-transpile/test/fixtures/webidl/console.test.js rename to packages/jco-transpile/test/fixtures/webidl/console.js diff --git a/packages/jco-transpile/test/fixtures/webidl/dom.test.js b/packages/jco-transpile/test/fixtures/webidl/dom.js similarity index 100% rename from packages/jco-transpile/test/fixtures/webidl/dom.test.js rename to packages/jco-transpile/test/fixtures/webidl/dom.js diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit index 32a1fce00..39fe8d24a 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit @@ -80,6 +80,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -90,6 +92,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -104,6 +134,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md new file mode 100644 index 000000000..475309577 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/LICENSE.md @@ -0,0 +1,8 @@ +Copyright © 2019-2023 the Contributors to the WASI Specification, published +by the [WebAssembly Community Group][cg] under the +[W3C Community Contributor License Agreement (CLA)][cla]. A human-readable +[summary][summary] is available. + +[cg]: https://www.w3.org/community/webassembly/ +[cla]: https://www.w3.org/community/about/agreements/cla/ +[summary]: https://www.w3.org/community/about/agreements/cla-deed/ diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md new file mode 100644 index 000000000..7f1ec7bd7 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/PROVENANCE.md @@ -0,0 +1,5 @@ +Adapted from WebAssembly/wasi-tls `wit/`, revision +`6781ae26084100c0628ef72cc44e4517c6c48ae5` (W3C Community CLA; see LICENSE.md). +Local changes: `wasi:io@0.2.12`, `is-available`, and no unstable-feature gate; see README.md. +IO WIT is copied from Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +Upstream wit-deps metadata is omitted; dependencies are vendored. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md new file mode 100644 index 000000000..bce5a7393 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/README.md @@ -0,0 +1,25 @@ +# Local WASI TLS contract + +This is a slightly modified copy of [WebAssembly/wasi-tls](https://github.com/WebAssembly/wasi-tls/tree/6781ae26084100c0628ef72cc44e4517c6c48ae5/wit) +at revision `6781ae26084100c0628ef72cc44e4517c6c48ae5`, under the W3C Community +Contributor License Agreement (see LICENSE.md). + +It provides a provisional, shared interface for TLS implementations on Node.js, +the web, and other host platforms. It is not an unmodified upstream standard or +a claim that every host platform already has an implementation. + +Local changes: + +- Use `wasi:io@0.2.12` instead of `0.2.6`, sharing the sockets implementation's + stream resources directly without version bridging. IO WIT is copied from + Jco's existing `builtin/0.2.12/wasi-io/package.wit`. +- Add `is-available`, a side-effect-free capability query so denied TLS requests + fail before acquiring TCP resources. +- Omit upstream unstable-feature annotations so normal WIT tooling can consume + this explicitly imported local contract without TLS-specific feature handling. + +The package retains `wasi:tls@0.2.0-draft`. +Hosts must implement this local contract. The upstream client handshake, +future polling, stream ownership, and output shutdown operations are retained. +Server TLS and guest trust/ALPN configuration remain outside the contract; +certificate verification and trust are host policy. No wit-deps tooling is used. diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit new file mode 100644 index 000000000..8006d6d2e --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/deps/io/package.wit @@ -0,0 +1,66 @@ +package wasi:io@0.2.12; +interface error { + resource error { + to-debug-string: func() -> string; + } +} +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + poll: func(in: list>) -> list; +} +interface streams { + use error.{error}; + use poll.{pollable}; + variant stream-error { + last-operation-failed(error), + closed + } + resource input-stream { + read: func( + len: u64 + ) -> result, stream-error>; + blocking-read: func( + len: u64 + ) -> result, stream-error>; + skip: func( + len: u64, + ) -> result; + blocking-skip: func( + len: u64, + ) -> result; + subscribe: func() -> pollable; + } + resource output-stream { + check-write: func() -> result; + write: func( + contents: list + ) -> result<_, stream-error>; + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func( + len: u64 + ) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func( + len: u64 + ) -> result<_, stream-error>; + splice: func( + src: borrow, + len: u64, + ) -> result; + blocking-splice: func( + src: borrow, + len: u64, + ) -> result; + } +} +world imports { + import streams; + import poll; +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit new file mode 100644 index 000000000..a2a664948 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/types.wit @@ -0,0 +1,27 @@ +// Adapted from WebAssembly/wasi-tls wit/types.wit, +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local changes: wasi:io@0.2.12 and an availability query. See README.md. +interface types { + /// Whether the host grants TLS connections. This query performs no IO. + is-available: func() -> bool; + + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + use wasi:io/error@0.2.12.{error as io-error}; + + resource client-handshake { + constructor(server-name: string, input: input-stream, output: output-stream); + + finish: static func(this: client-handshake) -> future-client-streams; + } + + resource client-connection { + close-output: func(); + } + + resource future-client-streams { + subscribe: func() -> pollable; + + get: func() -> option, io-error>>>; + } +} diff --git a/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit new file mode 100644 index 000000000..2c7093d93 --- /dev/null +++ b/packages/jco/lib/wit/builtin/wasi-tls-0.2.0-draft/world.wit @@ -0,0 +1,8 @@ +// Adapted from WebAssembly/wasi-tls wit/world.wit at +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local change: omit unstable-feature gates; see README.md. +package wasi:tls@0.2.0-draft; + +world imports { + import types; +} diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index f385ce00a..f4ef25cd8 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -139,6 +139,7 @@ async function usesOlderWasiHTTP(witPath: string, worldName?: string) { const exportsOldIncomingHandler = worldMetadata.exports.some((iface) => { return ( iface.namespace === "wasi" && + iface.package === "http" && iface.version != null && iface.version.major === 0n && iface.version.minor < 3n && @@ -149,6 +150,7 @@ async function usesOlderWasiHTTP(witPath: string, worldName?: string) { const importsOldFetch = worldMetadata.imports.some((iface) => { return ( iface.namespace === "wasi" && + iface.package === "http" && iface.version != null && iface.version.major === 0n && iface.version.minor < 3n && @@ -201,8 +203,7 @@ export async function componentize(jsSource: string, opts: ComponentizeOptions): nodeBuiltinPlugin(await worldMetadataFor(witPath, opts.worldName), { nodejsHttpVia: opts.nodejsHttpVia ?? opts.withNodejsHttpVia, nodejsHttp2Via: opts.nodejsHttp2Via ?? opts.withNodejsHttp2Via, - // StarlingMonkey's built-in socket modules are currently WASI 0.2.10. - // Using that exact version preserves its cross-interface resource identities. + // Match the socket bindings supplied by the selected component engine. wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", onWitRequirement(requirement: NodeWitRequirement) { witRequirements.set(requirement.witImport, requirement); diff --git a/packages/jco/src/cmd/new.ts b/packages/jco/src/cmd/new.ts index 1b8f1b876..031250478 100644 --- a/packages/jco/src/cmd/new.ts +++ b/packages/jco/src/cmd/new.ts @@ -115,7 +115,9 @@ async function scaffoldFiles(args: ScaffoldFilesArgs): Promise = { ".gitignore": "node_modules/\ndist/\n", [`src/${args.host ? "plugin" : "component"}.${extension}`]: args.source, - [`test/${args.host ? "plugin" : "component"}.test.${extension}`]: args.testSource, + [`test/${args.host ? "plugin" : "component"}.${extension}`]: args.testSource, + [`vitest.config.${extension}`]: + 'import { defineConfig } from "vitest/config";\n\nexport default defineConfig({\n test: { include: ["test/**/*.{ts,js}"] },\n});\n', }; for (const [name, contents] of Object.entries(args.generatedTypes)) { files[`types/generated/${name}`] = contents; diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index abebaf915..991824f7b 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -27,6 +27,7 @@ const HTTP2_ASYNC_IMPORTS = [ `${HTTP2_CAPABILITY}#[method]server.close`, ]; const DEFAULT_NODE_CAPABILITY_MAP = { + "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/console@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host", diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index 6f9eadfbd..efd39dd46 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -78,7 +78,10 @@ program .option("--bundle", "bundle source and its dependencies before componentization (automatic for TypeScript)") .option("--bundle-config ", "merge a Rolldown configuration module into the component bundle") .addOption( - new Option("--with-nodejs-http-via ", "implementation used by bundled node:http code") + new Option( + "--with-nodejs-http-via ", + "implementation used by bundled node:http and node:https code", + ) .choices(["direct", "wasi-sockets", "wasi-http"]) .default("direct"), ) diff --git a/packages/jco/src/node-builtins.ts b/packages/jco/src/node-builtins.ts index 0cd59ff0d..2f9b7f0e2 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -15,6 +15,10 @@ import { HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, HTTP_WIT_REQUIREMENT, + HTTPS_WASI_HTTP_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, + HTTPS_WIT_REQUIREMENT, HTTP2_WIT_REQUIREMENT, INSPECTOR_PROMISES_WIT_REQUIREMENT, INSPECTOR_WIT_REQUIREMENT, @@ -56,6 +60,7 @@ const STREAM_CONSUMERS_SPECIFIER = "node:stream/consumers"; const STREAM_ITER_SPECIFIER = "node:stream/iter"; const DNS_SPECIFIERS = new Set(["node:dns", "node:dns/promises"]); const HTTP_SPECIFIER = "node:http"; +const HTTPS_SPECIFIER = "node:https"; export const HTTP_CALLBACKS_SPECIFIER = "jco:node-http-callbacks"; const HTTP2_SPECIFIER = "node:http2"; export const HTTP2_CALLBACKS_SPECIFIER = "jco:node-http2-callbacks"; @@ -294,6 +299,9 @@ export interface NodeBuiltinOptions { httpCoreModule?: string; httpWasiSocketsImplementationModule?: string; httpWasiHttpImplementationModule?: string; + /** Paths to jco-std's HTTPS modules (overridable for tests). */ + httpsModule?: string; + httpsCoreModule?: string; /** Implementation used for `node:http2` host operations. */ nodejsHttp2Via?: NodejsHttp2Via; /** WASI socket module version supplied by the selected component engine. */ @@ -688,18 +696,32 @@ const HTTP_EXPORTS = [ "validateHeaderValue", ] as const; -function httpExports(moduleExpression: string): string { +/** `node:https` at the pinned release: six exports, no deprecated members. */ +const HTTPS_EXPORTS = ["Agent", "Server", "createServer", "get", "globalAgent", "request"] as const; + +/** The two protocol modules share one core, one implementation set, and one host interface. */ +type HttpProtocol = "http" | "https"; + +const PROTOCOL_EXPORTS: Record = { + http: HTTP_EXPORTS, + https: HTTPS_EXPORTS, +}; + +/** Factory exported by the protocol's core module (`createHttp` / `createHttps`). */ +const PROTOCOL_FACTORY: Record = { http: "createHttp", https: "createHttps" }; + +function protocolExports(protocol: HttpProtocol, moduleExpression: string): string { return ` -const http = ${moduleExpression}; -export default http; -export const { ${HTTP_EXPORTS.join(", ")} } = http; +const ${protocol} = ${moduleExpression}; +export default ${protocol}; +export const { ${PROTOCOL_EXPORTS[protocol].join(", ")} } = ${protocol}; `; } -function httpDirectAdapter(httpModule: string): string { +function protocolDirectAdapter(protocol: HttpProtocol, entryModule: string): string { return ` -import directHttp from ${JSON.stringify(httpModule)}; -${httpExports("directHttp")} +import direct from ${JSON.stringify(entryModule)}; +${protocolExports(protocol, "direct")} `; } @@ -708,28 +730,56 @@ function httpCallbacksAdapter(httpModule: string): string { return `export { httpCallbacks } from ${JSON.stringify(httpModule)};`; } -function httpWasiSocketsAdapter(coreModule: string, implementationModule: string, version: string): string { +function protocolWasiSocketsAdapter( + protocol: HttpProtocol, + coreModule: string, + implementationModule: string, + version: string, +): string { + const factory = PROTOCOL_FACTORY[protocol]; const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; + const tlsImports = protocol === "https" ? 'import * as tls from "wasi:tls/types@0.2.0-draft";' : ""; return ` import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; -import { createHttp } from ${JSON.stringify(coreModule)}; +${tlsImports} +import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiSocketsHttpImplementation } from ${JSON.stringify(implementationModule)}; -${httpExports(`createHttp(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule} }))`)} +${protocolExports(protocol, `${factory}(createWasiSocketsHttpImplementation({ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${schedule}${protocol === "https" ? ", tls" : ""} }))`)} `; } -function httpWasiHttpAdapter(coreModule: string, implementationModule: string): string { +function protocolWasiHttpAdapter(protocol: HttpProtocol, coreModule: string, implementationModule: string): string { + const factory = PROTOCOL_FACTORY[protocol]; return ` import * as outgoingHandler from "wasi:http/outgoing-handler@0.2.12"; import * as types from "wasi:http/types@0.2.12"; -import { createHttp } from ${JSON.stringify(coreModule)}; +import { ${factory} } from ${JSON.stringify(coreModule)}; import { createWasiHttpImplementation } from ${JSON.stringify(implementationModule)}; -${httpExports("createHttp(createWasiHttpImplementation({ outgoingHandler, types }))")} +${protocolExports(protocol, `${factory}(createWasiHttpImplementation({ outgoingHandler, types }))`)} `; } +/** WIT requirements for one protocol module under one `--with-nodejs-http-via` selection. */ +function protocolWitRequirements( + protocol: HttpProtocol, + via: NodejsHttpVia, + wasiSocketsVersion: string, +): readonly NodeWitRequirement[] { + const https = protocol === "https"; + if (via === "direct") { + return [https ? HTTPS_WIT_REQUIREMENT : HTTP_WIT_REQUIREMENT]; + } + if (via === "wasi-sockets") { + if (wasiSocketsVersion === "0.2.12") { + return https ? HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_WIT_REQUIREMENTS; + } + return https ? HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS; + } + return https ? HTTPS_WASI_HTTP_WIT_REQUIREMENTS : HTTP_WASI_HTTP_WIT_REQUIREMENTS; +} + const HTTP2_EXPORTS = [ "Http2ServerRequest", "Http2ServerResponse", @@ -794,6 +844,7 @@ ${http2Exports(`createHttp2(${factory}(${factoryArguments}))`)} function requireWasiHttpVersion( worldMetadata: WorldMetadata, + specifier: string, via: Exclude, version = "0.2.12", ): void { @@ -809,7 +860,7 @@ function requireWasiHttpVersion( if (incompatible) { const { major, minor, patch } = incompatible.version!; throw new Error( - `node:http via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, + `${specifier} via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, ); } } @@ -1016,8 +1067,31 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui stdModule(options.httpWasiSocketsImplementationModule, "http/impl/wasi-sockets"); const httpWasiHttpImplementationModule = () => stdModule(options.httpWasiHttpImplementationModule, "http/impl/wasi-http"); + const httpsModule = () => stdModule(options.httpsModule, "https"); + const httpsCoreModule = () => stdModule(options.httpsCoreModule, "https/core"); const httpVia = options.nodejsHttpVia ?? "direct"; const wasiSocketsVersion = options.wasiSocketsVersion ?? "0.2.12"; + const protocolOf = (specifier: string): HttpProtocol | undefined => + specifier === HTTP_SPECIFIER ? "http" : specifier === HTTPS_SPECIFIER ? "https" : undefined; + /** + * Facade for `node:http` or `node:https` under the selected implementation. Each jco-std + * path is resolved only on the branch that emits it, so a build never touches an entry point + * it does not use. + */ + const protocolAdapter = (protocol: HttpProtocol): string => { + if (httpVia === "direct") { + return protocolDirectAdapter(protocol, protocol === "http" ? httpModule() : httpsModule()); + } + const coreModule = protocol === "http" ? httpCoreModule() : httpsCoreModule(); + return httpVia === "wasi-sockets" + ? protocolWasiSocketsAdapter( + protocol, + coreModule, + httpWasiSocketsImplementationModule(), + wasiSocketsVersion, + ) + : protocolWasiHttpAdapter(protocol, coreModule, httpWasiHttpImplementationModule()); + }; const http2Module = () => options.http2Module ?? fileURLToPath(import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2")); @@ -1124,24 +1198,18 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui options.onWitRequirement?.(FS_WIT_REQUIREMENT); return `${VIRTUAL_PREFIX}${id}`; } - if (id === HTTP_SPECIFIER) { - if (httpVia === "direct") { - options.onWitRequirement?.(HTTP_WIT_REQUIREMENT); - } else { + const protocol = protocolOf(id); + if (protocol !== undefined) { + if (httpVia !== "direct") { requireWasiHttpVersion( worldMetadata, + id, httpVia, httpVia === "wasi-sockets" ? wasiSocketsVersion : "0.2.12", ); - const requirements = - httpVia === "wasi-sockets" - ? wasiSocketsVersion === "0.2.12" - ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS - : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS - : HTTP_WASI_HTTP_WIT_REQUIREMENTS; - for (const requirement of requirements) { - options.onWitRequirement?.(requirement); - } + } + for (const requirement of protocolWitRequirements(protocol, httpVia, wasiSocketsVersion)) { + options.onWitRequirement?.(requirement); } return `${VIRTUAL_PREFIX}${id}`; } @@ -1149,7 +1217,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (http2Via === "direct") { options.onWitRequirement?.(HTTP2_WIT_REQUIREMENT); } else if (http2Via === "wasi-sockets") { - requireWasiHttpVersion(worldMetadata, http2Via, wasiSocketsVersion); + requireWasiHttpVersion(worldMetadata, HTTP2_SPECIFIER, http2Via, wasiSocketsVersion); for (const requirement of wasiSocketsVersion === "0.2.12" ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS) { @@ -1238,17 +1306,9 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (FS_SPECIFIERS.has(value)) { return fsAdapter(value, fsModule(), fsPromisesModule()); } - if (value === HTTP_SPECIFIER) { - if (httpVia === "direct") { - return httpDirectAdapter(httpModule()); - } - return httpVia === "wasi-sockets" - ? httpWasiSocketsAdapter( - httpCoreModule(), - httpWasiSocketsImplementationModule(), - wasiSocketsVersion, - ) - : httpWasiHttpAdapter(httpCoreModule(), httpWasiHttpImplementationModule()); + const protocol = protocolOf(value); + if (protocol !== undefined) { + return protocolAdapter(protocol); } if (value === HTTP2_SPECIFIER) { if (http2Via === "direct") { diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 446a342be..37d0bd8f8 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -114,6 +114,16 @@ export const HTTP_WIT_REQUIREMENT = nodeRequirement("node:http", "http", { ], }); +/** + * `node:https` is `node:http` with TLS terminated by the same host interface, so it shares the + * import and the callback export; only the comment naming the importing builtin differs, and + * injection dedupes by `witImport` when a guest uses both. + */ +export const HTTPS_WIT_REQUIREMENT: NodeWitRequirement = { + ...HTTP_WIT_REQUIREMENT, + nodeSpecifier: "node:https", +}; + export const HTTP2_WIT_REQUIREMENT: NodeWitRequirement = { nodeSpecifier: "node:http2", witImport: "jco:node/http2@0.1.0", @@ -197,6 +207,34 @@ export const HTTP_WASI_HTTP_WIT_REQUIREMENTS = [ wasiRequirement("wasi:http/types@0.2.12", WASI_HTTP_DEPENDENCIES), ] as const; +function forHttps(requirements: readonly NodeWitRequirement[]): NodeWitRequirement[] { + return requirements.map((requirement) => ({ ...requirement, nodeSpecifier: "node:https" })); +} + +function tlsRequirements(): NodeWitRequirement[] { + const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); + return forHttps([ + wasiRequirement("wasi:tls/types@0.2.0-draft", [ + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), + }, + WASI_IO_DEPENDENCY, + ]), + ]); +} +export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ + ...forHttps(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS), + ...tlsRequirements(), +] as const; + +export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ + ...forHttps(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS), + ...tlsRequirements(), +]; + +export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forHttps(HTTP_WASI_HTTP_WIT_REQUIREMENTS); + export interface WitInjectionResult { witPath: string; worldFile: string; diff --git a/packages/jco/test/fixtures/componentize/node-https-server/component.js b/packages/jco/test/fixtures/componentize/node-https-server/component.js new file mode 100644 index 000000000..f962ec0e9 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/component.js @@ -0,0 +1,22 @@ +import { createServer } from "node:https"; + +let server; + +export function start(key, cert) { + server = createServer({ key, cert }, async (request, response) => { + request.setEncoding("utf8"); + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + response.setHeader("Content-Type", "text/plain"); + response.end(`${request.method} ${request.url}: ${chunks.join("")}`); + }); + server.listen(0, "127.0.0.1"); + return server.address().port; +} + +export function stop() { + server.closeAllConnections(); + server.close(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https-server/run.js b/packages/jco/test/fixtures/componentize/node-https-server/run.js new file mode 100644 index 000000000..ec746bf01 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/run.js @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +const { instantiate } = await import(pathToFileURL(argv[2])); +const imports = new WASIShim().getImportObject(); +imports[argv[3]] = await import(argv[3]); +const instance = await instantiate(undefined, imports); +const port = await instance.start(key, cert); + +try { + const body = await new Promise((resolve, reject) => { + const request = https.request( + `https://127.0.0.1:${port}/items`, + { method: "POST", ca: cert, servername: "localhost" }, + (response) => { + response.setEncoding("utf8"); + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.once("end", () => resolve(chunks.join(""))); + }, + ); + request.once("error", reject); + request.end("hello"); + }); + stdout.write(`${body}\n`); +} finally { + await instance.stop(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit new file mode 100644 index 000000000..38533bf7c --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-server/wit/component.wit @@ -0,0 +1,6 @@ +package jco-fixtures:node-https-server; + +world component { + export start: func(key: string, cert: string) -> u16; + export stop: func(); +} diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts new file mode 100644 index 000000000..cea57bb22 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/build.ts @@ -0,0 +1,67 @@ +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundleComponentSource } from "../../../../dist/bundle.js"; +import { componentize } from "../../../../dist/cmd/componentize.js"; +import { nodeBuiltinPlugin } from "../../../../dist/node-builtins.js"; +import { injectNodeWitImports, type NodeWitRequirement } from "../../../../dist/node-wit.js"; +import { transpileBytes, writeFiles } from "../../../../../jco-transpile/dist/index.js"; +import { componentWit } from "../../../../../jco-transpile/dist/wasm-tools.js"; + +const root = resolve(process.argv[2]); +const backend = process.argv[3]; +if (backend !== "starlingmonkey" && backend !== "quickjs") { + throw new Error("unknown backend"); +} +const fixture = fileURLToPath(new URL("./", import.meta.url)); +const std = fileURLToPath(new URL("../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/", import.meta.url)); +await mkdir(root, { recursive: true }); +await cp(join(fixture, "wit"), join(root, "wit"), { recursive: true }); +const requirements: NodeWitRequirement[] = []; +const source = await bundleComponentSource(join(fixture, "component.js"), { + external: [/^jco:/], + plugins: [ + nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + nodejsHttpVia: "wasi-sockets", + wasiSocketsVersion: backend === "starlingmonkey" ? "0.2.10" : "0.2.12", + httpsCoreModule: join(std, "https/core.js"), + httpCoreModule: join(std, "http/core.js"), + httpWasiSocketsImplementationModule: join(std, "http/impl/wasi-sockets/index.js"), + onWitRequirement: (requirement: NodeWitRequirement): void => { + requirements.push(requirement); + }, + }, + ), + ], +}); +await injectNodeWitImports(join(root, "wit"), "component", requirements); +await writeFile(join(root, "bundle.js"), source); +await componentize(join(root, "bundle.js"), { + wit: join(root, "wit"), + worldName: "component", + backend, + backendQjsDisableAysnc: false, + ...(backend === "starlingmonkey" ? { disable: ["http"] } : {}), + out: join(root, "component.wasm"), +}); +const bytes = await readFile(join(root, "component.wasm")); +await writeFile(join(root, "imports.wit"), await componentWit(bytes)); +const { files } = await transpileBytes(bytes, { + name: "guest", + instantiation: "async", + base64Cutoff: 0, + map: { + ...Object.fromEntries( + ["cli", "clocks", "filesystem", "http", "io", "random", "sockets"].map((name) => [ + `wasi:${name}/*`, + `${name}#*`, + ]), + ), + "wasi:tls/types@0.2.0-draft": "tls", + }, +}); +await writeFiles(Object.fromEntries(Object.entries(files).map(([name, bytes]) => [join(root, name), bytes]))); +await writeFile(join(root, "package.json"), '{"type":"module"}\n'); +console.log("built", backend); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt new file mode 100644 index 000000000..2a9340bd8 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIUeGV1KXtVGjlGttolUiclm/uDZicwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPSmNvIFRMUyBUZXN0IENBMCAXDTI2MDkwNzEyMDAyMloY +DzIxMjYwODE0MTIwMDIyWjAaMRgwFgYDVQQDDA9KY28gVExTIFRlc3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCuJT/1jsFlflBFPHHfRMkjq8iB +riWb1X0+6kDjTMNd8fYMveOKM+eMbEn8fQt5+Lgyb/b7iJHcdCtnu2fmxo7JFi0j +2T3Gw57FGcYlpQVNxtTGy9r+Qj1fm4NPE8e8W7kmy+NaLiOXNujFMS1ytXGi6JKa +f2aI9KBPxVbvG76zkLl4nDK+Pv7HQI2A6fPjOnGykFeJuYOmqhoNox5dERfzqvuc +C2PYgimcP3N5NFrkjphQn+CID3hfnT1OlwCyYRMs8K/sOsW9fMwu59Ej7KnEOeqx +wysVmKa/1bjcNPz9h04LBFZk5qQHfPVbOdrrJhyvdKijtWanBVaW8ED1546pAgMB +AAGjMjAwMB0GA1UdDgQWBBTORQpiaFBkvTOKHjTCSJkC4r2jTDAPBgNVHRMBAf8E +BTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQArCsL4CcNO0Tdlhkdf/a2fc7fF0LE8 +5entoI+BkB5wNssaWFqUgUX6SGTDIwWAuasYddNpbLEQloOCK1T3ypEraL14JGl6 +kITTrQ4eXViALUkA0F91FIheabbMzcFoK5KRA9HP3ZFKpTXOGuQZk3gKSbOIH1vz +0wb6EQbWSLnQRWVWZUQWScrpztqVGneGDZI8VHboeeF5r1PDDoa08n643bkUwl0z +KBvLJwVXUr+v16dsDU2KS1Bo7WOuZwtuCQqUAJCDqUAAqZUl4xJHSiZe+Z25L5eq +WOpn8zgbGpzcDMCIob7X4YDwwmwOMgA4PgJXcMFwcg+Uzi1D5Tl3kfqw +-----END CERTIFICATE----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt new file mode 100644 index 000000000..2570fbb8c --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIBATANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9KY28g +VExTIFRlc3QgQ0EwIBcNMjYwOTA3MTIwMDIyWhgPMjEyNjA4MTQxMjAwMjJaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAMUhqhklK7PXjE+GSpTybgZGkWKPUwDnVw/DR0GeBIAz5WaI9gztuUiWOR2W +zchnRRlKY/gaIM3rNE/kT4xJrujDa4/gbH55RreFQ7rsSp6mDaWZDiYGmRtZOfv1 +gWJHH2+865lhikYy6oM8XFdWm27UpgC8wPT9X6HzAOH7c8uBS2p9LninE5gN3MdU +Ifep40zkfjRsNEFIbRiixgDnRtz/rruh58kLfoGUEoZ5VEMDI60ggnjTkjYCcw2m +IVy0mZLWJMArw4XHLjJp1xBWpQTGKwrU+UCJzr6jwEIOp/eYwzuFeDFCV+fY8/z6 +DiWYZaB+sZpEUScX1isSWo9Frb8CAwEAAaNLMEkwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsG +AQUFBwMBMA0GCSqGSIb3DQEBCwUAA4IBAQCD/b+erxEvbzTtSA2j+9fwhP+HGEVn +cx1fP9x37Iy/oZH0NTrp/813vFIL6YSPEUHBQNirRGwMrCO7ZVXFLDMtskAak1A8 +iZ8tbyrzTn4E5dwUEVIPdVl5uityDqm0y3lLDzfn6tO3XhYZLzNm1OYY2qyiX2n+ +n/qGexMc8sua1IaMD2U61RqHIj5jQt1W+SvyFsc8rsWfjH/cg3hPjGTzbqObs3Tf +DEWzWhl58lxYGzN4LBSpRZD6GjkY53plf1VOHSWJCkIC1f2AL3ApmivI0FzPQd7j +EV35wknUjQ2x2cx9i4Ey8mX5KhR6VarOG+Fe3s7I84zHE6p0xbOAs+sl +-----END CERTIFICATE----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key new file mode 100644 index 000000000..2ab9a5350 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/certs/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDFIaoZJSuz14xP +hkqU8m4GRpFij1MA51cPw0dBngSAM+VmiPYM7blIljkdls3IZ0UZSmP4GiDN6zRP +5E+MSa7ow2uP4Gx+eUa3hUO67Eqepg2lmQ4mBpkbWTn79YFiRx9vvOuZYYpGMuqD +PFxXVptu1KYAvMD0/V+h8wDh+3PLgUtqfS54pxOYDdzHVCH3qeNM5H40bDRBSG0Y +osYA50bc/667oefJC36BlBKGeVRDAyOtIIJ405I2AnMNpiFctJmS1iTAK8OFxy4y +adcQVqUExisK1PlAic6+o8BCDqf3mMM7hXgxQlfn2PP8+g4lmGWgfrGaRFEnF9Yr +ElqPRa2/AgMBAAECggEAAkHo70HINtaEklKQ3xTJosPDHXRTuIJtsk4DrmIvXgJ6 +IYr2+l3sjcK+o7Ka560bEveRnoE6F/GWF0YfjRU47gxy2mJxC5+66hYaGPVkw11W +cauHiHLx5OjIK7T7htMWrpJkxkxiJ3ykx9z0l8FzpTjFL+P5d7TBGBsuyue0w0NS +GK4rJ5z5vjhlTK0vXiJbax6JYVv70XZnlQPU9CgRlq3V1fPj3gVnsAoYLWWN5A+4 +7tzvqw5FfhHGzX7gtEqEj5WIprpJcgdrweBgmP4NqQmo3jFmQpT4Zj+2Soct23rY +Sk1gJIFscMRKMnYbBISNcz0o6lTEOwGXWrWXsIY9gQKBgQD+wKHr+udTeQmxmhyj +a3fUYy+M9QZtmw6i5klDgiUNT7uLYQYrbMHkBdbinQptveTrkITAICxwuJuThn4h +6iKBjtA7Xb/QyRi1wfcy1CMXFj8/ofHTOw4kne0+nRP736WcoLsYT8ZWIgrbsoQ7 +GwA7Na+EHSpoKkCeGtObvhT0gQKBgQDGGMvLsLMtuEvLTR2OIS6hFbmMJS3GJ/DH +J3avWsOtGis7eWbBVjmrumgB3ZYHkVv2FCAY/W13rGH4gaMRo5068b3bpqn4bZct +dw2u2pf1QXopB5S1zc3goUWWMw9ZJoxiwnkKIhn1Fjo838Lr5mnJRe6taA+n6fha +lUtmmfSCPwKBgHtZ4M13lsznPZdebOGAJuyS/jI9blhiDQs5gF4MxU4VvlS1rRwX +tCZp4Wum6KbMnOym9HBm473M1Z/wLmDTktOyyAcG1NsOlEVl3wEgkMEcB5ITIxnJ +bYazZW289zEtUG5vsUgLUJjiMOnCHZ7U6x7AVvUcfi0j0Ff921p9Bn6BAoGASLLC +366qIwY2cpaLWSSeSymA3YirYsQ3na7C5JmHpBgtc3cbGaq+IWKYVs7uBzr2J7m9 +Cc6/hKKzlZJluMx1oDMlPN3OFMiLKXk+gUPhbnUoErSgg5PSkTQ+KF/2qv31mSzL +ZMedBQ+yMbLggtgdTGsoq2S8EiBQL1YIxM+NJtsCgYAU2uSY9UmXvTanvzK9vKin +hqkwYtJ049nkm9PPbpiICscWX/4QTRICzxOju2/mHaG8a+Ss1s8Ez6vd0UHOEsUd +I0wUxI5gAW66vMRcHJeB87YRPz6AgXmGD8ia43OmmN4vt94Jgd9ZRkXuHMFGnH8O +SRYiBvPGTofzItiRgGFzGQ== +-----END PRIVATE KEY----- diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js new file mode 100644 index 000000000..fc6c6d77f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/component.js @@ -0,0 +1,30 @@ +import https from "node:https"; +import http from "node:http"; + +export async function run(url, body, servername) { + try { + return await new Promise((resolve, reject) => { + const protocol = url.startsWith("http:") ? http : https; + const request = protocol.request( + url, + { + method: body ? "POST" : "GET", + ...(servername ? { servername } : {}), + }, + (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + text += chunk; + }); + response.once("error", reject); + response.once("end", () => resolve({ status: response.statusCode, body: text, error: "" })); + }, + ); + request.once("error", reject); + request.end(body); + }); + } catch (error) { + return { status: 0, body: "", error: `${error.code ?? "Error"}: ${error.message}` }; + } +} diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts new file mode 100644 index 000000000..7a6343876 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/run.ts @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as sockets from "../../../../../preview2-shim/dist/nodejs/sockets.js"; +import * as tls from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host-node.js"; +import * as denied from "../../../../../jco-std/dist/wasi/0.2.x/node/24.x.x/tls-host.js"; + +const root = resolve(process.argv[2]); +const url = process.argv[3]; +const policy = process.argv[4] ?? "trusted"; +const body = process.argv[5] === "large" ? "a".repeat(150_000) : ""; +const servername = process.argv[6] ?? ""; +const ca = policy === "trusted" ? [await readFile(new URL("./certs/ca.crt", import.meta.url), "utf8")] : undefined; +let handshakes = 0; +let connections = 0; +const provider = tls.createTlsProvider({ ca, handshakeTimeoutMs: 1500 }); +class CountedHandshake extends provider.ClientHandshake { + constructor(...args: ConstructorParameters) { + super(...args); + handshakes++; + } +} +const imports: Record = {}; +for (const name of ["cli", "clocks", "filesystem", "http", "io", "random"]) { + imports[name] = await import(new URL(`../../../../../preview2-shim/dist/nodejs/${name}.js`, import.meta.url).href); +} +imports.sockets = { + ...sockets, + tcpCreateSocket: { + createTcpSocket: (family: "ipv4" | "ipv6"): ReturnType => { + connections++; + return sockets.tcpCreateSocket.createTcpSocket(family); + }, + }, +}; +imports.tls = policy === "denied" ? denied : { ...provider, ClientHandshake: CountedHandshake }; +interface Report { + status: number; + body: string; + error: string; +} +interface Guest { + run(url: string, body: string, servername: string): Report | Promise; +} +const { + instantiate, +}: { + instantiate: ( + load: (path: string) => Promise, + imports: Record, + ) => Promise; +} = await import(pathToFileURL(join(root, "guest.js")).href); +const guest = await instantiate( + async (path: string): Promise => + WebAssembly.compile(new Uint8Array(await readFile(join(root, path)))), + imports, +); +const before = tls._resourceCounts(); +const report = await guest.run(url, body, servername); +const after = tls._resourceCounts(); +console.log(JSON.stringify({ report, handshakes, connections, before, after })); diff --git a/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit new file mode 100644 index 000000000..be4b991f2 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https-wasi-tls/wit/component.wit @@ -0,0 +1,5 @@ +package jco-fixtures:https-wasi-tls; +world component { + record report { status: u16, body: string, error: string } + export run: func(url: string, body: string, servername: string) -> report; +} diff --git a/packages/jco/test/fixtures/componentize/node-https/component.js b/packages/jco/test/fixtures/componentize/node-https/component.js new file mode 100644 index 000000000..62dbc67b4 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/component.js @@ -0,0 +1,26 @@ +import { get } from "node:https"; + +function fetchText(url, ca) { + return new Promise((resolve, reject) => { + // The fixture certificate names `localhost`; the connection goes to the loopback + // address, so SNI and identity checks are pinned to the certificate's name. + const request = get(url, { ca, servername: "localhost" }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => { + resolve({ + statusCode: response.statusCode, + contentType: response.headers["content-type"], + body: chunks.join(""), + }); + }); + }); + request.once("error", reject); + }); +} + +export async function run(url, ca) { + return fetchText(url, ca); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs b/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs new file mode 100644 index 000000000..a9f2cbc5b --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/run-probe.mjs @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../preview2-shim/test/fixtures/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +const server = https.createServer({ key, cert }, (_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("hello from node:https"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + +try { + const address = server.address(); + const { instantiate } = await import(pathToFileURL(argv[2])); + const imports = new WASIShim().getImportObject(); + imports[argv[3]] = await import(argv[3]); + imports["jco:node/http-callbacks"] = { RequestListener: class {} }; + const instance = await instantiate(undefined, imports); + stdout.write(`${JSON.stringify(await instance.run(`https://127.0.0.1:${address.port}/`, cert))}\n`); +} finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/run.js b/packages/jco/test/fixtures/componentize/node-https/run.js new file mode 100644 index 000000000..f815b9692 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/run.js @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import https from "node:https"; +import { argv, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const tls = new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/", import.meta.url); +const cert = await readFile(new URL("localhost.crt", tls), "utf8"); +const key = await readFile(new URL("localhost.key", tls), "utf8"); + +const server = https.createServer({ key, cert }, (_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("hello from node:https"); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + +try { + const address = server.address(); + const { instantiate } = await import(pathToFileURL(argv[2])); + const imports = new WASIShim().getImportObject(); + imports[argv[3]] = await import(argv[3]); + const instance = await instantiate(undefined, imports); + stdout.write(`${JSON.stringify(await instance.run(`https://127.0.0.1:${address.port}/`, cert))}\n`); +} finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} diff --git a/packages/jco/test/fixtures/componentize/node-https/wit/component.wit b/packages/jco/test/fixtures/componentize/node-https/wit/component.wit new file mode 100644 index 000000000..29b72428f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-https/wit/component.wit @@ -0,0 +1,11 @@ +package jco-fixtures:node-https; + +world component { + record report { + status-code: u16, + content-type: string, + body: string, + } + + export run: func(url: string, ca: string) -> report; +} diff --git a/packages/jco/test/fixtures/wit/idl/console.test.js b/packages/jco/test/fixtures/wit/idl/console.js similarity index 100% rename from packages/jco/test/fixtures/wit/idl/console.test.js rename to packages/jco/test/fixtures/wit/idl/console.js diff --git a/packages/jco/test/fixtures/wit/idl/dom.test.js b/packages/jco/test/fixtures/wit/idl/dom.js similarity index 100% rename from packages/jco/test/fixtures/wit/idl/dom.test.js rename to packages/jco/test/fixtures/wit/idl/dom.js diff --git a/packages/jco/test/new.js b/packages/jco/test/new.js index bb3a3ff1b..748f3eb1d 100644 --- a/packages/jco/test/new.js +++ b/packages/jco/test/new.js @@ -160,15 +160,17 @@ suite("jco scaffold", () => { "test", "tsconfig.json", "types", + "vitest.config.ts", "wit", ]); assert.include( await readFile(join(project, "src/component.ts"), "utf8"), "export const foo1: typeof World.foo1", ); - const generatedTest = await readFile(join(project, "test/component.test.ts"), "utf8"); + const generatedTest = await readFile(join(project, "test/component.ts"), "utf8"); assert.include(generatedTest, 'component["foo1"]'); assert.include(generatedTest, '["foo"]'); + assert.include(await readFile(join(project, "vitest.config.ts"), "utf8"), '"test/**/*.{ts,js}"'); const packageJson = JSON.parse(await readFile(join(project, "package.json"), "utf8")); assert.equal(packageJson.packageManager, `pnpm@${DEFAULT_PNPM_VERSION}`); assert.equal(packageJson.scripts.check, "pnpm run check:types"); diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index fa4f96908..f360fce95 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -34,6 +34,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({ + "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", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", @@ -52,6 +53,7 @@ describe("Node builtin adapters", () => { "jco:node/os@0.1.0": "/application/os-host.js", }), ).toEqual({ + "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", diff --git a/packages/jco/test/node/http.js b/packages/jco/test/node/http.js index 661fc8c51..c81717fbc 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -235,7 +235,15 @@ describe("node:http in a component", () => { copy: true, extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", implementation], }); + // `--with-nodejs-http-via` selects which capability is injected; a wrong mode still + // injects *something*, so the assertion names the interface the mode must add. + const injected = { + direct: "jco:node/http@0.1.0", + "wasi-sockets": "wasi:sockets/instance-network@0.2.12", + "wasi-http": "wasi:http/outgoing-handler@0.2.12", + }[implementation]; expect(stderr).toContain("Jco added generated WIT import"); + expect(stderr).toContain(injected); const map = implementation === "direct" ? { "jco:node/http@0.1.0": NODE_HOST } : undefined; const { esModuleOutputPath, cleanup } = await setupAsyncTest({ component: { name: `node-http-${implementation}`, path: componentPath, skipInstantiation: true }, diff --git a/packages/jco/test/node/https-wasi-tls.ts b/packages/jco/test/node/https-wasi-tls.ts new file mode 100644 index 000000000..00a87cc2d --- /dev/null +++ b/packages/jco/test/node/https-wasi-tls.ts @@ -0,0 +1,282 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer as createTlsServer } from "node:tls"; +import { createServer as createTcpServer, type Socket } from "node:net"; +import { once } from "node:events"; +import { createServer as createHttpServer } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const exec = promisify(execFile); +const fixture = new URL("../fixtures/componentize/node-https-wasi-tls/", import.meta.url); +const build = fileURLToPath(new URL("build.ts", fixture)); +const runner = fileURLToPath(new URL("run.ts", fixture)); +const cert = await readFile(new URL("certs/localhost.crt", fixture)); +const key = await readFile(new URL("certs/localhost.key", fixture)); + +interface Resources { + tls: number; + streams: number; + futures: number; + polls: number; + sockets: number; +} +interface RunResult { + report: { status: number; body: string; error: string }; + handshakes: number; + connections: number; + before: Resources; + after: Resources; +} +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} +function resources(value: unknown): value is Resources { + return ( + record(value) && + ["tls", "streams", "futures", "polls", "sockets"].every((key) => typeof value[key] === "number") + ); +} +function parseResult(source: string): RunResult { + const value: unknown = JSON.parse(source); + if ( + !record(value) || + !record(value.report) || + typeof value.report.status !== "number" || + typeof value.report.body !== "string" || + typeof value.report.error !== "string" || + typeof value.handshakes !== "number" || + typeof value.connections !== "number" || + !resources(value.before) || + !resources(value.after) + ) { + throw new Error("Invalid guest report"); + } + return { + report: { status: value.report.status, body: value.report.body, error: value.report.error }, + handshakes: value.handshakes, + connections: value.connections, + before: value.before, + after: value.after, + }; +} +function portOf(server: { address(): string | { port: number } | null }): number { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected listening TCP server"); + } + return address.port; +} +async function run(root: string, url: string, policy = "trusted", body = "", servername = ""): Promise { + try { + const result = await exec(process.execPath, [runner, root, url, policy, body, servername], { + timeout: 20_000, + maxBuffer: 1_000_000, + }); + return parseResult(result.stdout.trim()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stderr = record(error) && "stderr" in error ? String(error.stderr) : ""; + throw new Error( + `HTTPS component execution failed for ${url}; check DNS/TCP access and TLS trust. ${message}\n${stderr}`, + { cause: error }, + ); + } +} + +for (const backend of ["starlingmonkey"]) { + describe(`node:https over wasi:sockets + wasi:tls (${backend})`, () => { + let root: string; + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "jco-https-tls-")); + await exec(process.execPath, [build, root, backend], { timeout: 180_000, maxBuffer: 2_000_000 }); + const imports = await readFile(join(root, "imports.wit"), "utf8"); + expect(imports).toContain("import wasi:tls/types@0.2.0-draft"); + expect(imports).toContain("import wasi:sockets/tcp@"); + expect(imports).toContain("import wasi:io/streams@0.2.12"); + expect(imports).not.toContain("wasi:io/streams@0.2.6"); + expect(imports).not.toContain("jco:tls-streams"); + expect(imports).not.toContain("import jco:node/http@"); + expect(imports).not.toContain("import wasi:http/outgoing-handler@"); + }, 190_000); + afterAll(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + } + }); + + test.concurrent("public network: verified GET https://example.com/", async () => { + // Intentionally enabled: this case needs public DNS and outbound TCP/443. + const result = await run(root, "https://example.com/", "public"); + expect(result.report.error, "Public endpoint requires working DNS/TCP/443 and system trust").toBe(""); + expect(result.report.status).toBe(200); + expect(result.report.body).toContain("Example Domain"); + expect(result.handshakes).toBe(1); + expect(result.connections).toBeGreaterThanOrEqual(1); + expect(result.after).toEqual(result.before); + }, 25_000); + + test.concurrent.each([ + ["verified custom host CA, SNI, ALPN, and fragmented response", "trusted", "localhost", "", true], + ["fragmented large request writes", "trusted", "localhost", "large", true], + ["untrusted certificate", "public", "localhost", "", false], + ["hostname mismatch", "trusted", "wrong.example", "", false], + ["missing TLS capability without plaintext fallback", "denied", "localhost", "", false], + ])( + "%s", + async (_name, policy, servername, body, success) => { + const peers = new Set(); + let secureConnections = 0; + let negotiated: { servername: string | false | null; alpn: string | false | null } | undefined; + let received = 0; + const server = createTlsServer({ cert, key, ALPNProtocols: ["http/1.1"] }, (socket) => { + secureConnections++; + negotiated = { servername: socket.servername, alpn: socket.alpnProtocol }; + let bytes = Buffer.alloc(0); + let responded = false; + socket.on("data", (chunk) => { + bytes = Buffer.concat([bytes, chunk]); + const end = bytes.indexOf("\r\n\r\n"); + if (end < 0) { + return; + } + received = bytes.length - end - 4; + if (responded || (body === "large" && received < 150_000)) { + return; + } + responded = true; + const response = Buffer.from( + "HTTP/1.1 200 OK\r\nContent-Length: 18\r\nConnection: close\r\n\r\nhello verified TLS", + ); + let offset = 0; + function fragment(): void { + if (socket.destroyed) { + return; + } + if (offset === response.length) { + socket.end(); + return; + } + const next = Math.min(offset + 3, response.length); + socket.write(response.subarray(offset, next)); + offset = next; + setImmediate(fragment); + } + fragment(); + }); + }); + server.on("connection", (peer) => { + peers.add(peer); + peer.on("close", () => peers.delete(peer)); + }); + server.on("tlsClientError", () => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `https://127.0.0.1:${portOf(server)}/`, policy, body, servername); + expect(result.after).toEqual(result.before); + if (success) { + expect(result.report).toEqual({ status: 200, body: "hello verified TLS", error: "" }); + expect(negotiated).toEqual({ servername: "localhost", alpn: "http/1.1" }); + expect(result.handshakes).toBe(1); + expect(secureConnections).toBe(1); + if (body) { + expect(received).toBe(150_000); + } + } else { + expect(result.report.status).toBe(0); + expect(result.report.error).toMatch( + policy === "denied" ? /wasi:tls.*TLS capability/ : /TLS handshake failed/, + ); + if (policy === "public") { + expect(result.report.error).toMatch(/certificate/i); + } + if (servername === "wrong.example") { + expect(result.report.error).toMatch(/Hostname\/IP does not match|not in the cert/); + } + expect(secureConnections).toBe(0); + expect(result.handshakes).toBe(policy === "denied" ? 0 : 1); + if (policy === "denied") { + expect(result.connections).toBe(0); + } + } + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 25_000, + ); + + test.concurrent("plain HTTP keeps using TCP without a TLS handshake", async () => { + const server = createHttpServer((_request, response) => response.end("plain HTTP")); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `http://127.0.0.1:${portOf(server)}/`, "denied"); + expect(result.report).toEqual({ status: 200, body: "plain HTTP", error: "" }); + expect(result.handshakes).toBe(0); + expect(result.after).toEqual(result.before); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test.concurrent("connection refusal releases socket resources", async () => { + const server = createTcpServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = portOf(server); + await new Promise((resolve) => server.close(() => resolve())); + const result = await run(root, `https://127.0.0.1:${port}/`); + expect(result.report.status).toBe(0); + expect(result.report.error).toContain("ECONNREFUSED"); + expect(result.handshakes).toBe(0); + expect(result.after).toEqual(result.before); + }); + + test.concurrent.each(["reset", "stalled handshake"])( + "cleans up after %s", + async (kind) => { + const peers = new Set(); + const server = createTcpServer((socket) => { + peers.add(socket); + socket.on("close", () => peers.delete(socket)); + if (kind === "reset") { + socket.destroy(); + } + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const result = await run(root, `https://127.0.0.1:${portOf(server)}/`, "trusted", "", "localhost"); + expect(result.report.status).toBe(0); + expect(result.report.error).toMatch(/TLS handshake failed/); + expect(result.after).toEqual(result.before); + } finally { + for (const peer of peers) { + peer.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 25_000, + ); + }); +} + +test.concurrent("QuickJS reports its snapshot linker TLS resource incompatibility", async () => { + const root = await mkdtemp(join(tmpdir(), "jco-https-tls-qjs-")); + try { + await expect( + exec(process.execPath, [build, root, "quickjs"], { timeout: 180_000, maxBuffer: 2_000_000 }), + ).rejects.toThrow(/wasi:tls.*mismatched resource types/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 190_000); diff --git a/packages/jco/test/node/https.js b/packages/jco/test/node/https.js new file mode 100644 index 000000000..108331eef --- /dev/null +++ b/packages/jco/test/node/https.js @@ -0,0 +1,209 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { describe, expect, test, vi } from "vitest"; + +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins.js"; +import { + HTTP_WIT_REQUIREMENT, + HTTPS_WASI_HTTP_WIT_REQUIREMENTS, + HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, + HTTPS_WIT_REQUIREMENT, + injectNodeWitImports, +} from "../../src/node-wit.js"; +import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; + +const modulePaths = { + httpModule: "/jco/http.js", + httpCoreModule: "/jco/http/core.js", + httpWasiSocketsImplementationModule: "/jco/http/wasi-sockets.js", + httpWasiHttpImplementationModule: "/jco/http/wasi-http.js", + httpsModule: "/jco/https.js", + httpsCoreModule: "/jco/https/core.js", +}; + +const HTTPS_EXPORTS = ["Agent", "Server", "createServer", "get", "globalAgent", "request"]; + +const NODE_HOST = pathToFileURL( + fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/http-host-node.js", import.meta.url)), +).href; + +describe("node:https builtin adapter", () => { + test.each([ + ["direct", "jco:node/http@0.1.0", "/jco/https.js"], + ["wasi-sockets", "wasi:sockets/instance-network@0.2.12", "/jco/https/core.js"], + ["wasi-http", "wasi:http/outgoing-handler@0.2.12", "/jco/https/core.js"], + ])("generates the %s implementation facade", (nodejsHttpVia, capability, implementationModule) => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { ...modulePaths, nodejsHttpVia, onWitRequirement }, + ); + const id = plugin.resolveId("node:https"); + expect(id).toBe("\0jco-node-builtin:node:https"); + const source = plugin.load(id); + expect(source).toContain(implementationModule); + expect(source).toContain("export default https"); + for (const name of HTTPS_EXPORTS) { + expect(source).toMatch(new RegExp(`\\b${name}\\b`)); + } + // The six-export surface must not leak node:http-only names. + expect(source).not.toContain("validateHeaderValue"); + expect(source).not.toContain("STATUS_CODES"); + if (nodejsHttpVia !== "direct") { + expect(source).toContain("createHttps("); + } + expect(onWitRequirement).toHaveBeenCalledWith( + expect.objectContaining({ witImport: capability, nodeSpecifier: "node:https" }), + ); + }); + + test.concurrent("shares the direct host interface and callback export with node:http", () => { + expect(HTTPS_WIT_REQUIREMENT.witImport).toBe(HTTP_WIT_REQUIREMENT.witImport); + expect(HTTPS_WIT_REQUIREMENT.guestExports).toEqual(HTTP_WIT_REQUIREMENT.guestExports); + expect(HTTPS_WIT_REQUIREMENT.dependencySources).toEqual(HTTP_WIT_REQUIREMENT.dependencySources); + for (const [https, http] of [ + [HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS, "wasi:sockets/instance-network@0.2.12"], + [HTTPS_WASI_HTTP_WIT_REQUIREMENTS, "wasi:http/outgoing-handler@0.2.12"], + ]) { + expect(https.map(({ witImport }) => witImport)).toContain(http); + expect(https.every(({ nodeSpecifier }) => nodeSpecifier === "node:https")).toBe(true); + } + }); + + test.concurrent("resolves node:https without touching the node:http entry points", () => { + // Only the https path is configured; resolving the real package for node:http would fail. + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { httpsModule: "/jco/https.js" }); + const id = plugin.resolveId("node:https"); + expect(plugin.load(id)).toContain('from "/jco/https.js"'); + }); + + test.concurrent("does not intercept the bare https specifier", () => { + expect(nodeBuiltinPlugin({ imports: [], exports: [] }, modulePaths).resolveId("https")).toBeNull(); + }); + + test.each([ + ["wasi-sockets", "sockets"], + ["wasi-http", "http"], + ])("rejects an incompatible Preview 2 package for %s, naming node:https", (nodejsHttpVia, packageName) => { + const plugin = nodeBuiltinPlugin( + { + imports: [ + { + namespace: "wasi", + package: packageName, + interface: "types", + version: { major: 0n, minor: 2n, patch: 10n }, + }, + ], + exports: [], + }, + { ...modulePaths, nodejsHttpVia }, + ); + expect(() => plugin.resolveId("node:https")).toThrow(/node:https via .* requires wasi:.*@0\.2\.12/); + }); +}); + +describe("node:https WIT installation", () => { + test.concurrent("injects one shared import when a guest uses both protocol modules", async () => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:https;\nworld component {}\n"); + const result = await injectNodeWitImports(root, undefined, [HTTP_WIT_REQUIREMENT, HTTPS_WIT_REQUIREMENT]); + expect(result).toMatchObject({ + imports: ["jco:node/http@0.1.0"], + exports: ["jco:node/http-callbacks@0.1.0"], + }); + const worldSource = await readFile(world, "utf8"); + expect(worldSource.match(/import jco:node\/http@0\.1\.0;/g)).toHaveLength(1); + expect(worldSource.match(/export jco:node\/http-callbacks@0\.1\.0;/g)).toHaveLength(1); + const source = await readFile(join(root, "deps/jco-node-0.1.0/http.wit"), "utf8"); + expect(source).toContain("record tls-options"); + expect(source).toContain("tls: option"); + expect(await injectNodeWitImports(root, undefined, [HTTPS_WIT_REQUIREMENT])).toBeUndefined(); + }); + + test.concurrent("names node:https in the generated comment for an https-only guest", async () => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:https;\nworld component {}\n"); + await injectNodeWitImports(root, undefined, [HTTPS_WIT_REQUIREMENT]); + const worldSource = await readFile(world, "utf8"); + expect(worldSource).toContain("bundled source imports node:https"); + expect(worldSource).toContain("import jco:node/http@0.1.0;"); + }); +}); + +// The direct mode's guest tests need two things before they can run: a published jco-std that +// carries the node:https exports, and a working direct round trip. Today a transpiled component +// also imports `jco:node/http-callbacks` (the `http` interface `use`s it, so the world imports it +// transitively) and the JSPI-suspended `request` import hands `undefined` back to the guest; the +// same happens for plain node:http, see the sibling tests in http.js. +describe("node:https in a component", () => { + // TODO(unskip): use the published jco-std node:https exports once a release containing them + // is available, and remove the callbacks/JSPI blockers described above. + test.skip("terminates TLS for a guest server through the host node:https", async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-https-server", + bundle: true, + copy: true, + extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", "direct"], + }); + expect(stderr).toContain("Jco added generated WIT import jco:node/http@0.1.0"); + expect(stderr).toContain("jco:node/http-callbacks@0.1.0"); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-https-server", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + // The same defaults the CLI applies: JSPI plus the async host imports. + extraArgs: withDefaultNodeCapabilities({ + asyncExports: ["*"], + map: { "jco:node/http@0.1.0": NODE_HOST }, + }), + }, + }, + }); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-https-server/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, NODE_HOST); + expect(output.stdout.trim()).toBe("POST /items: hello"); + } finally { + await cleanup(); + } + }, 600_000); + + // TODO(unskip): same blockers as above. + test.skip("performs a verified HTTPS request from a guest through the host node:https", async () => { + const { componentPath, stderr } = await componentizeFixture({ + fixture: "node-https", + bundle: true, + copy: true, + extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", "direct"], + }); + expect(stderr).toContain("Jco added generated WIT import jco:node/http@0.1.0"); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: "node-https-direct", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: withDefaultNodeCapabilities({ + asyncExports: ["run"], + map: { "jco:node/http@0.1.0": NODE_HOST }, + }), + }, + }, + }); + try { + const runner = fileURLToPath(new URL("../fixtures/componentize/node-https/run.js", import.meta.url)); + const output = await exec(runner, esModuleOutputPath, NODE_HOST); + expect(JSON.parse(output.stdout)).toEqual({ + statusCode: 200, + contentType: "text/plain", + body: "hello from node:https", + }); + } finally { + await cleanup(); + } + }, 600_000); +}); diff --git a/packages/jco/test/node/tls-wit.ts b/packages/jco/test/node/tls-wit.ts new file mode 100644 index 000000000..ce0d6aaf8 --- /dev/null +++ b/packages/jco/test/node/tls-wit.ts @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { injectNodeWitImports, HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS } from "../../src/node-wit.js"; +import { worldMetadataFor } from "../../src/cmd/componentize.js"; + +test.concurrent("TLS WIT injection is idempotent and shares IO 0.2.12 without feature handling", async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "jco-tls-wit-")); + try { + const source = "package tests:tls; world untouched {} world component { export run: func(); }\n"; + await writeFile(join(root, "world.wit"), source); + await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS); + const tlsPath = join(root, "deps/wasi-tls-0.2.0-draft/types.wit"); + const contract = await readFile(tlsPath, "utf8"); + expect(contract).not.toContain("@unstable"); + expect(contract).toContain("wasi:io/streams@0.2.12"); + expect(contract).toContain("is-available: func() -> bool"); + expect(await injectNodeWitImports(root, "component", HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS)).toBeUndefined(); + const world = await readFile(join(root, "world.wit"), "utf8"); + expect(world).toContain("world untouched {}"); + const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "tls", interface: "types" }), + ); + expect(metadata.imports).toContainEqual( + expect.objectContaining({ namespace: "wasi", package: "sockets", interface: "tcp" }), + ); + expect(metadata.exports).toEqual([]); // Free-standing functions are not interface metadata. + expect(metadata.imports).toContainEqual( + expect.objectContaining({ + package: "io", + interface: "streams", + version: expect.objectContaining({ patch: 12n }), + }), + ); + expect( + metadata.imports.filter((iface) => iface.package === "io").every((iface) => iface.version?.patch === 12n), + ).toBe(true); + expect(metadata.imports.some((iface) => iface.namespace === "jco")).toBe(false); + expect(await readFile(tlsPath, "utf8")).toBe(contract); + expect(await readFile(join(root, "world.wit"), "utf8")).toBe(world); + expect((await worldMetadataFor(root, "component")).imports).toContainEqual( + expect.objectContaining({ package: "tls" }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/jco/test/vitest.ts b/packages/jco/test/vitest.ts index b33820caf..403142472 100644 --- a/packages/jco/test/vitest.ts +++ b/packages/jco/test/vitest.ts @@ -16,7 +16,7 @@ export default defineConfig({ printConsoleTrace: true, passWithNoTests: false, setupFiles: ["test/meta-resolve-stub.ts"], - include: ["test/**/*.js"], + include: ["test/**/*.js", "test/node/**/*.ts"], exclude: [ "test/extended/*", "test/output/*", diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index b8948b5e2..61d483f27 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -256,3 +256,14 @@ See [LICENSE](LICENSE) for more details. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. + +### Host IO extensions + +Opt-in providers can use the Node-only `@bytecodealliance/preview2-shim/io-worker` +entry point to operate on existing streams in the shim's IO worker. Host-selected +modules load lazily and share the worker's stream, future, and poll ownership rules. +Guest code cannot select extension modules. + +The Node `wasi:tls` provider and its TLS policy live in +[`jco-std`](../jco-std/README.md#http-1), which wraps the supplied TCP streams +without opening a replacement connection. diff --git a/packages/preview2-shim/package.json b/packages/preview2-shim/package.json index 04885f69f..e9103997b 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -54,6 +54,10 @@ }, "./interfaces/*": { "types": "./types/interfaces/*.d.ts" + }, + "./io-worker": { + "types": "./dist/nodejs/io-worker.d.ts", + "node": "./dist/nodejs/io-worker.js" } }, "scripts": { diff --git a/packages/preview2-shim/src/io/calls.ts b/packages/preview2-shim/src/io/calls.ts index fba71242b..016e4b28d 100644 --- a/packages/preview2-shim/src/io/calls.ts +++ b/packages/preview2-shim/src/io/calls.ts @@ -130,6 +130,9 @@ export const SOCKET_RESOLVE_ADDRESS_TAKE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_SUBSCRIBE_REQUEST = ++call_id << CALL_SHIFT; export const SOCKET_RESOLVE_ADDRESS_DISPOSE_REQUEST = ++call_id << CALL_SHIFT; +// Host extensions execute alongside the streams they operate on. +export const WORKER_EXTENSION_CALL = ++call_id << CALL_SHIFT; + export const reverseMap = {}; import * as calls from "./calls.js"; diff --git a/packages/preview2-shim/src/io/extension.ts b/packages/preview2-shim/src/io/extension.ts new file mode 100644 index 000000000..1566c60e3 --- /dev/null +++ b/packages/preview2-shim/src/io/extension.ts @@ -0,0 +1,10 @@ +/** IO-worker integration for opt-in host providers. No capability is installed by importing this module. */ +import type { Readable, Writable } from "node:stream"; +export interface WorkerExtensionContext { + createFuture(promise: Promise): number; + createReadableStream(stream: Readable): number; + createWritableStream(stream: Writable): number; + getStream(id: number): unknown; + resourceCounts(): { streams: number; futures: number; polls: number; sockets: number }; +} +export type WorkerExtension = (operation: string, args: unknown[]) => unknown | Promise; diff --git a/packages/preview2-shim/src/io/worker-sockets.ts b/packages/preview2-shim/src/io/worker-sockets.ts index 2b42637f7..3286f1f74 100644 --- a/packages/preview2-shim/src/io/worker-sockets.ts +++ b/packages/preview2-shim/src/io/worker-sockets.ts @@ -101,12 +101,10 @@ export function socketResolveAddress(name: string) { (addresses) => { return (Array.isArray(addresses) ? addresses : [addresses]).map( ({ address, family }) => { - return [ - { - tag: "ipv" + family, - val: (family === 4 ? ipv4ToTuple : ipv6ToTuple)(address), - }, - ]; + return { + tag: "ipv" + family, + val: (family === 4 ? ipv4ToTuple : ipv6ToTuple)(address), + }; }, ); }, diff --git a/packages/preview2-shim/src/io/worker-thread.ts b/packages/preview2-shim/src/io/worker-thread.ts index 1ef97cc9b..932809212 100644 --- a/packages/preview2-shim/src/io/worker-thread.ts +++ b/packages/preview2-shim/src/io/worker-thread.ts @@ -1,3 +1,5 @@ +import { WORKER_EXTENSION_CALL } from "./calls.js"; +import type { WorkerExtension, WorkerExtensionContext } from "./extension.js"; import { createReadStream, createWriteStream, PathLike } from "node:fs"; import { hrtime, stderr, stdout } from "node:process"; import { PassThrough } from "node:stream"; @@ -321,6 +323,30 @@ export function getStreamOrThrow(streamId) { return stream; } +const extensions = new Map>(); +async function callExtension(module: string, operation: string, args: unknown[]): Promise { + let extension = extensions.get(module); + if (!extension) { + extension = import(module).then(({ default: create }) => { + const context: WorkerExtensionContext = { + createFuture: (promise) => createFuture(promise, undefined), + createReadableStream, + createWritableStream, + getStream: (id) => getStreamOrThrow(id).stream, + resourceCounts: () => ({ + streams: streams.size, + futures: futures.size, + polls: polls.size, + sockets: tcpSockets.size, + }), + }; + return create(context); + }); + extensions.set(module, extension); + } + return (await extension)(operation, args); +} + /** * @param {number} call * @param {number | null} id @@ -332,6 +358,8 @@ function handle(call, id, payload) { throw uncaughtException; } switch (call) { + case WORKER_EXTENSION_CALL: + return callExtension(payload.module, payload.operation, payload.args); // Http case HTTP_CREATE_REQUEST: { const { @@ -958,10 +986,7 @@ function handle(call, id, payload) { return futureTakeValue(id); case FUTURE_SUBSCRIBE: { - const { pollState } = futures.get(id); - const pollId = ++pollCnt; - polls.set(pollId, pollState); - return pollId; + return createPoll(futures.get(id).pollState); } case FUTURE_DISPOSE: return void futureDispose(id, true); diff --git a/packages/preview2-shim/src/nodejs/io-worker.ts b/packages/preview2-shim/src/nodejs/io-worker.ts new file mode 100644 index 000000000..175e83b52 --- /dev/null +++ b/packages/preview2-shim/src/nodejs/io-worker.ts @@ -0,0 +1,48 @@ +/** Low-level integration with the shared Node IO worker for opt-in host providers. */ +import * as io from "../io/worker-io.js"; +import { + WORKER_EXTENSION_CALL, + SOCKET_TCP, + FUTURE_TAKE_VALUE, + FUTURE_SUBSCRIBE, + FUTURE_DISPOSE, +} from "../io/calls.js"; +import type { InputStream, OutputStream } from "../../types/interfaces/wasi-io-streams.js"; +import type { Pollable } from "../../types/interfaces/wasi-io-poll.js"; +import type { Error as IoError } from "../../types/interfaces/wasi-io-error.js"; +export type { WorkerExtension, WorkerExtensionContext } from "../io/extension.js"; + +export type FutureResult = + | { tag: "err"; val?: undefined } + | { tag: "ok"; val: { tag: "ok"; val: T } | { tag: "err"; val: E } } + | undefined; + +/** Loads a host-selected module once in the existing worker; never accepts a guest module path. */ +export function callExtension(module: URL, operation: string, args: unknown[]): unknown { + return io.ioCall(WORKER_EXTENSION_CALL, null, { module: module.href, operation, args }); +} +export function inputStreamId(stream: InputStream): number { + return io.inputStreamId(stream); +} +export function outputStreamId(stream: OutputStream): number { + return io.outputStreamId(stream); +} +export function inputStreamCreate(id: number): InputStream { + return io.inputStreamCreate(SOCKET_TCP, id); +} +export function outputStreamCreate(id: number): OutputStream { + return io.outputStreamCreate(SOCKET_TCP, id); +} +export function futureSubscribe(id: number, parent: object): Pollable { + return io.pollableCreate(io.ioCall(FUTURE_SUBSCRIBE, id, undefined), parent); +} +/** T and E are the promise's value and rejection types chosen by the host extension. */ +export function futureTakeValue(id: number): FutureResult { + return io.ioCall(FUTURE_TAKE_VALUE, id, undefined); +} +export function futureDispose(id: number): void { + io.ioCall(FUTURE_DISPOSE, id, undefined); +} +export function createIoError(message: string): IoError { + return new io.error.Error(message); +} diff --git a/packages/preview2-shim/src/nodejs/sockets.ts b/packages/preview2-shim/src/nodejs/sockets.ts index 5ac2b909d..16f519400 100644 --- a/packages/preview2-shim/src/nodejs/sockets.ts +++ b/packages/preview2-shim/src/nodejs/sockets.ts @@ -65,6 +65,8 @@ const symbolDispose = Symbol.dispose || Symbol.for("dispose"); // Network class privately stores capabilities class Network implements NetworkNamespace.Network { + // Compatibility with the resource placeholder in the bundled WASI 0.2.10 WIT. + noop(): void {} #allowDnsLookup = true; #allowTcp = true; #allowUdp = true; @@ -121,8 +123,26 @@ export const instanceNetwork: typeof InstanceNetworkNamespace = { }, }; -export const network: typeof NetworkNamespace = { +export const network: typeof NetworkNamespace & { + networkErrorCode(error: { toDebugString(): string }): NetworkNamespace.ErrorCode | undefined; +} = { Network, + networkErrorCode(error): NetworkNamespace.ErrorCode | undefined { + const payload: unknown = "payload" in error ? error.payload : undefined; + if (typeof payload !== "object" || payload === null || !("code" in payload)) { + return undefined; + } + const codes: Partial> = { + ECONNRESET: "connection-reset", + ECONNREFUSED: "connection-refused", + ECONNABORTED: "connection-aborted", + ETIMEDOUT: "timeout", + EACCES: "access-denied", + EPERM: "access-denied", + ENETUNREACH: "remote-unreachable", + }; + return typeof payload.code === "string" ? codes[payload.code] : undefined; + }, }; class ResolveAddressStream implements IpNameLookupNamespace.ResolveAddressStream { diff --git a/packages/preview2-shim/test/fixtures/io-worker.ts b/packages/preview2-shim/test/fixtures/io-worker.ts new file mode 100644 index 000000000..2fa0696d5 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/io-worker.ts @@ -0,0 +1,19 @@ +import type { WorkerExtension, WorkerExtensionContext } from "../../src/io/extension.ts"; + +export default function create(context: WorkerExtensionContext): WorkerExtension { + let calls = 0; + return (operation: string): unknown => { + switch (operation) { + case "count": + return ++calls; + case "fail": + throw new Error("host extension failed"); + case "rejected-future": + return context.createFuture(Promise.reject({ message: "handshake failed" })); + case "resources": + return context.resourceCounts(); + default: + throw new Error("unknown test operation"); + } + }; +} diff --git a/packages/preview2-shim/test/io-worker.ts b/packages/preview2-shim/test/io-worker.ts new file mode 100644 index 000000000..2d8594b1c --- /dev/null +++ b/packages/preview2-shim/test/io-worker.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { expect, test } from "vitest"; +import { + callExtension, + futureSubscribe, + futureTakeValue, + futureDispose, +} from "../dist/nodejs/io-worker.js"; + +const module = new URL("./fixtures/io-worker.ts", import.meta.url); + +test("host extensions survive operation errors and retain their worker state", (): void => { + expect(callExtension(module, "count", [])).toBe(1); + expect(() => callExtension(module, "fail", [])).toThrow("host extension failed"); + expect(callExtension(module, "count", [])).toBe(2); +}); + +test("extension futures preserve rejection, polling and single-consumption ownership", (): void => { + const before = callExtension(module, "resources", []); + const id = callExtension(module, "rejected-future", []); + assert(typeof id === "number"); + const pollable = futureSubscribe(id, {}); + expect(() => futureDispose(id)).toThrow(/child poll/); + pollable.block(); + assert(Symbol.dispose in pollable); + const dispose = pollable[Symbol.dispose]; + assert(typeof dispose === "function"); + dispose.call(pollable); + expect(futureTakeValue(id)).toEqual({ + tag: "ok", + val: { tag: "err", val: { message: "handshake failed" } }, + }); + expect(futureTakeValue(id)).toEqual({ tag: "err", val: undefined }); + futureDispose(id); + expect(callExtension(module, "resources", [])).toEqual(before); +}); diff --git a/packages/preview2-shim/test/map-filesystem.test.ts b/packages/preview2-shim/test/map-filesystem.ts similarity index 100% rename from packages/preview2-shim/test/map-filesystem.test.ts rename to packages/preview2-shim/test/map-filesystem.ts diff --git a/packages/preview2-shim/test/socket-addresses.ts b/packages/preview2-shim/test/socket-addresses.ts index 5636b56e5..f4a907a3c 100644 --- a/packages/preview2-shim/test/socket-addresses.ts +++ b/packages/preview2-shim/test/socket-addresses.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { suite, test } from "vitest"; -import { ipSocketAddress } from "../src/io/worker-sockets.js"; +import { ipSocketAddress, socketResolveAddress } from "../src/io/worker-sockets.js"; import { checkTcpAddresses, checkUdpAddresses } from "./fixtures/sockets/address-families.mjs"; suite("socket address families", () => { @@ -30,3 +30,12 @@ suite("socket address families", () => { test.each(["ipv4", "ipv6"])("TCP worker addresses (%s)", checkTcpAddresses); test.each(["ipv4", "ipv6"])("UDP worker addresses (%s)", checkUdpAddresses); }); + +test.concurrent("DNS lookup returns individual WASI address records", async (): Promise => { + const addresses = await socketResolveAddress("localhost"); + assert(addresses.length > 0); + for (const address of addresses) { + assert(address.tag === "ipv4" || address.tag === "ipv6"); + assert.equal(address.val.length, address.tag === "ipv4" ? 4 : 8); + } +}); diff --git a/packages/preview3-shim/package.json b/packages/preview3-shim/package.json index f043783e2..edfe2c535 100644 --- a/packages/preview3-shim/package.json +++ b/packages/preview3-shim/package.json @@ -56,7 +56,7 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "pretest": "pnpm run build", - "test": "vitest --run", + "test": "vitest --run -c test/vitest.ts", "prebench": "pnpm run build", "bench": "vitest bench --run", "prepack": "pnpm run build" diff --git a/packages/preview3-shim/test/cli.test.js b/packages/preview3-shim/test/cli.js similarity index 100% rename from packages/preview3-shim/test/cli.test.js rename to packages/preview3-shim/test/cli.js diff --git a/packages/preview3-shim/test/clocks.test.js b/packages/preview3-shim/test/clocks.js similarity index 100% rename from packages/preview3-shim/test/clocks.test.js rename to packages/preview3-shim/test/clocks.js diff --git a/packages/preview3-shim/test/filesystem.test.js b/packages/preview3-shim/test/filesystem.js similarity index 100% rename from packages/preview3-shim/test/filesystem.test.js rename to packages/preview3-shim/test/filesystem.js diff --git a/packages/preview3-shim/test/future.test.js b/packages/preview3-shim/test/future.js similarity index 100% rename from packages/preview3-shim/test/future.test.js rename to packages/preview3-shim/test/future.js diff --git a/packages/preview3-shim/test/http/client.test.js b/packages/preview3-shim/test/http/client.js similarity index 100% rename from packages/preview3-shim/test/http/client.test.js rename to packages/preview3-shim/test/http/client.js diff --git a/packages/preview3-shim/test/http/fields.test.js b/packages/preview3-shim/test/http/fields.js similarity index 100% rename from packages/preview3-shim/test/http/fields.test.js rename to packages/preview3-shim/test/http/fields.js diff --git a/packages/preview3-shim/test/http/request.test.js b/packages/preview3-shim/test/http/request.js similarity index 100% rename from packages/preview3-shim/test/http/request.test.js rename to packages/preview3-shim/test/http/request.js diff --git a/packages/preview3-shim/test/http/response.test.js b/packages/preview3-shim/test/http/response.js similarity index 100% rename from packages/preview3-shim/test/http/response.test.js rename to packages/preview3-shim/test/http/response.js diff --git a/packages/preview3-shim/test/http/server.test.js b/packages/preview3-shim/test/http/server.js similarity index 100% rename from packages/preview3-shim/test/http/server.test.js rename to packages/preview3-shim/test/http/server.js diff --git a/packages/preview3-shim/test/random.test.js b/packages/preview3-shim/test/random.js similarity index 100% rename from packages/preview3-shim/test/random.test.js rename to packages/preview3-shim/test/random.js diff --git a/packages/preview3-shim/test/resource-worker.test.js b/packages/preview3-shim/test/resource-worker.js similarity index 100% rename from packages/preview3-shim/test/resource-worker.test.js rename to packages/preview3-shim/test/resource-worker.js diff --git a/packages/preview3-shim/test/stream.test.js b/packages/preview3-shim/test/stream.js similarity index 100% rename from packages/preview3-shim/test/stream.test.js rename to packages/preview3-shim/test/stream.js diff --git a/packages/preview3-shim/test/tcp.test.js b/packages/preview3-shim/test/tcp.js similarity index 100% rename from packages/preview3-shim/test/tcp.test.js rename to packages/preview3-shim/test/tcp.js diff --git a/packages/preview3-shim/test/udp.test.js b/packages/preview3-shim/test/udp.js similarity index 100% rename from packages/preview3-shim/test/udp.test.js rename to packages/preview3-shim/test/udp.js diff --git a/packages/preview3-shim/test/vitest.ts b/packages/preview3-shim/test/vitest.ts new file mode 100644 index 000000000..b4627ec02 --- /dev/null +++ b/packages/preview3-shim/test/vitest.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.js"], + exclude: ["test/helpers.js", "test/nop-worker.js", "test/**/*.bench.js"], + }, +}); diff --git a/packages/rolldown-plugin-jco/test/fixture.test.ts b/packages/rolldown-plugin-jco/test/fixture.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/fixture.test.ts rename to packages/rolldown-plugin-jco/test/fixture.ts diff --git a/packages/rolldown-plugin-jco/test/ids.test.ts b/packages/rolldown-plugin-jco/test/ids.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/ids.test.ts rename to packages/rolldown-plugin-jco/test/ids.ts diff --git a/packages/rolldown-plugin-jco/test/plugin.test.ts b/packages/rolldown-plugin-jco/test/plugin.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/plugin.test.ts rename to packages/rolldown-plugin-jco/test/plugin.ts diff --git a/packages/rolldown-plugin-jco/test/proxy.test.ts b/packages/rolldown-plugin-jco/test/proxy.ts similarity index 100% rename from packages/rolldown-plugin-jco/test/proxy.test.ts rename to packages/rolldown-plugin-jco/test/proxy.ts diff --git a/packages/rolldown-plugin-jco/test/vitest.ts b/packages/rolldown-plugin-jco/test/vitest.ts index 3c9d6972c..ec75d27aa 100644 --- a/packages/rolldown-plugin-jco/test/vitest.ts +++ b/packages/rolldown-plugin-jco/test/vitest.ts @@ -2,7 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/**/*.test.ts"], + include: ["test/**/*.ts"], + exclude: ["test/vitest.ts", "test/types.ts", "test/fixtures/**"], testTimeout: 120_000, hookTimeout: 120_000, }, diff --git a/scripts/create-idl-component.mjs b/scripts/create-idl-component.mjs new file mode 100644 index 000000000..ea29349f5 --- /dev/null +++ b/scripts/create-idl-component.mjs @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const jco = fileURLToPath(new URL('../packages/jco/dist/jco.js', import.meta.url)); + +/** @param {string} command @param {string[]} args */ +function run(command, args) { + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} failed (${result.signal ?? result.status})`); + } +} + +const output = join(root, 'packages/jco/test/output/idl'); +mkdirSync(output, { recursive: true }); + +// Generate WIT from the WebIDL fixtures before building their components. +run('cargo', ['xtask', 'generate', 'webidl-tests']); +for (const [name, world] of [ + ['dom', 'window-test'], + ['console', 'console-test'], +]) { + const fixture = `packages/jco/test/fixtures/wit/idl/${name}`; + run(process.execPath, [ + jco, + 'componentize', + `${fixture}.js`, + '--wit', + `${fixture}.wit`, + '-o', + join(output, `${name}.component.wasm`), + '--disable', + 'stdio', + '--disable', + 'random', + '--disable', + 'clocks', + '--disable', + 'http', + '--world-name', + world, + ]); + run(process.execPath, [ + jco, + 'transpile', + join(output, `${name}.component.wasm`), + '-o', + join(output, `${name}-test`), + ]); +} diff --git a/scripts/create-idl-component.sh b/scripts/create-idl-component.sh deleted file mode 100755 index ae5b8fc22..000000000 --- a/scripts/create-idl-component.sh +++ /dev/null @@ -1,12 +0,0 @@ -# Generate IDL from test/fixtures/idl/*.webidl to test/fixtures/idl/*.wit -cargo xtask generate idl -# Componentize the IDL test case at test/fixtures/*.test.js - -./dist/jco.js componentize test/fixtures/idl/dom.test.js --wit test/fixtures/idl/dom.wit -o dom.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name window-test -./dist/jco.js transpile dom.component.wasm -o dom-test - -./dist/jco.js componentize test/fixtures/idl/console.test.js --wit test/fixtures/idl/console.wit -o console.component.wasm --disable stdio --disable random --disable clocks --disable http --world-name console-test -./dist/jco.js transpile console.component.wasm -o console-test - -# Test it -# node --input-type=module -e "import { test } from './dom-test/dom.component.js'; test();"