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 f8f7549a1..552cd9928 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -106,29 +106,31 @@ 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:net` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/net/core` | TCP clients, servers, and address utilities over Preview 2 `wasi:sockets`; native handles and IPC are unsupported -- see below. | +| `node:inspector`, `node:inspector/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector` | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface -- see below. | +| `node:os` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os` | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider -- see below. | +| `node:buffer` | unenv's portable Buffer core with a Jco public adapter | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. | +| `node:events` | unenv's EventEmitter with a Jco layer from `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/events` | Covers the complete Node 24 module surface, including the `on()` async iterator and `EventEmitterAsyncResource`. Requires no WIT capability. | +| `node:querystring` | unenv's Node-derived querystring implementation | Covers the complete Node 24 module surface and shares the audited Buffer core used by `node:buffer`. | +| `node:stream/consumers` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers` | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. | +| `node:stream/iter` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter` | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. | ### Stream consumers and iterable streams @@ -270,14 +272,13 @@ need this import. Bundled source can use the documented Node 24 streaming decoder directly: ```js -import { Buffer } from "node:buffer"; -import { StringDecoder } from "node:string_decoder"; +import { Buffer } from 'node:buffer'; +import { StringDecoder } from 'node:string_decoder'; -const decoder = new StringDecoder("utf8"); +const decoder = new StringDecoder('utf8'); export function decode() { - return decoder.write(Buffer.from([0xf0, 0x9f])) + - decoder.end(Buffer.from([0x8c, 0x8d])); + return decoder.write(Buffer.from([0xf0, 0x9f])) + decoder.end(Buffer.from([0x8c, 0x8d])); } ``` @@ -612,12 +613,12 @@ jco transpile component.wasm \ The guest code is ordinary Node: ```js -import { Session } from "node:inspector/promises"; +import { Session } from 'node:inspector/promises'; const session = new Session(); session.connect(); -const { result } = await session.post("Runtime.evaluate", { expression: "6 * 7" }); -result.value; // 42, evaluated in the host isolate +const { result } = await session.post('Runtime.evaluate', { expression: '6 * 7' }); +result.value; // 42, evaluated in the host isolate ``` Argument validation, session state, the `EventEmitter` surface, and error reconstruction all run @@ -629,7 +630,7 @@ which is best-effort for functions, symbols, and cycles. #### The host calls back into the component The inspector's two callbacks -- a `post` response and a session notification -- run the other way, -from host to guest. A component cannot implement a resource declared on an *imported* interface (its +from host to guest. A component cannot implement a resource declared on an _imported_ interface (its methods would run host-side), so the callbacks are a guest-**exported** interface, `jco:node/inspector-callbacks@0.1.0`, holding one resource per callback kind: a one-shot `post-callback` and a long-lived `notification-listener`. When bundled source imports @@ -640,8 +641,8 @@ alongside the entry -- neither is written by hand. The embedder wires the exported interface to the host adapter after instantiation: ```js -import * as inspectorHost from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node"; -import * as component from "./transpiled/component.js"; +import * as inspectorHost from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node'; +import * as component from './transpiled/component.js'; inspectorHost.attachCallbacks(component.inspectorCallbacks); ``` @@ -672,15 +673,15 @@ internals (`_load`, `_resolveFilename`, `_findPath`, `_nodeModulePaths`, and the **Everything else is real**, because it is classification or arithmetic: -| Surface | Behavior | -| --- | --- | -| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | -| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | -| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | -| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | -| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | -| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | -| `new Module(id)` | Constructs, with Node's own-property shape. Its *methods* are what need a loader | +| Surface | Behavior | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `builtinModules`, `isBuiltin` | Node 24's list, verbatim. `isBuiltin` agrees with Node on every builtin in every spelling, including prefix-only ones -- `isBuiltin("node:test")` is true and `isBuiltin("test")` is false | +| `SourceMap` | Implemented in full: VLQ decoding, `findEntry`, `findOrigin`, `payload`, `lineLengths` | +| `wrap`, `wrapper` | Deprecated upstream but pure string work, so they behave as Node's do, including `wrap` reading a mutated `wrapper` live | +| `constants`, `findSourceMap`, `getSourceMapsSupport`, `getCompileCacheDir`, `flushCompileCache`, `syncBuiltinESMExports` | Exact, down to Node's null-prototype return objects | +| `globalPaths` | `[]` -- a true statement, not a refusal: there is no `$HOME/.node_modules` to search | +| `enableCompileCache` | Reports `{ status: FAILED, message }`. Node's own protocol for "could not", so callers that branch on `status` keep working instead of catching | +| `new Module(id)` | Constructs, with Node's own-property shape. Its _methods_ are what need a loader | #### `createRequire` @@ -693,10 +694,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` @@ -776,11 +777,56 @@ boundary does not expose an outstanding c-ares request that a later guest call could cancel. The provider boundary otherwise remains Node-independent, leaving room for a future browser implementation. +### TCP sockets + +`node:net` implements Node 24.19's 18-export module surface over Preview 2 +`wasi:sockets`. It includes TCP `Socket` and `Server`, `BoundSocket`, +`SocketAddress`, `BlockList`, IP-family predicates, overload normalization, and +the auto-family defaults. `connect` and `createConnection` are the same function, +and `Socket` and `Stream` are the same constructor, as in Node. + +```js +import { connect, createServer } from 'node:net'; + +createServer((socket) => socket.end('hello')).listen(8080, '127.0.0.1'); + +connect(8080, '127.0.0.1').setEncoding('utf8').on('data', console.log); +``` + +Jco injects only the selected world's Preview 2 DNS, TCP, stream, and pollable +interfaces and their standard WIT packages. QuickJS worlds use 0.2.12; +StarlingMonkey worlds can use 0.2.10. There is no Jco-specific network host +interface and no bare `net` alias. + +Preview 2 has no Unix-domain sockets, Windows named pipes, OS file descriptors, +libuv handles, TCP reset, IP type-of-service, or custom JavaScript DNS callback. +Those operations throw `ERR_JCO_UNSUPPORTED_NODE_API`. Address attempts are +sequential rather than reproducing Node's exact Happy Eyeballs timing. Socket +objects provide the common readable/writable methods and events but do not yet +inherit from classic `node:stream.Duplex`, because Jco does not have a faithful +classic stream core. + +Reads support Node string encodings, buffered `read()`, and async iteration. +Writable operations complete through blocking WASI writes and do not yet provide +classic stream backpressure. `setNoDelay()`, `ref()`, and `unref()` preserve the +callable surface but cannot control the host's TCP_NODELAY or event-loop references. +Nonzero socket timeouts require an engine with JavaScript timers; engines without +them reject `setTimeout()` explicitly. The deprecated `bufferSize` getter throws +the Jco deprecated-API error; use `writableLength` instead. + +Half-close depends on the host honoring WASI's directional `shutdown`. The +Preview 2 Node host shim 0.22.0 currently closes both directions; applications +using that host should let the peer finish its response before closing the +socket's writable side. + ### HTTP and selectable implementations The `node:http` adapter implements both client and server NodeJS HTTP APIs, with outbound `request()` and `get()` calls with Node-style `ClientRequest` -and buffered `IncomingMessage` objects along with `http.Server`. +and buffered `IncomingMessage` objects along with `http.Server`. `node:https` +is the same core driven with the `https:` protocol, port 443, and a TLS-aware +`Agent`, exactly as `lib/https.js` reuses `_http_client` and `_http_server` +upstream; it shares the implementation selection below. As this API obviously requires access to the outside world of some sort, and there are actually many ways to achieve that on the host side, you must select @@ -838,10 +884,89 @@ applications can continue mapping the Node provider module without this factory. > [!WARNING] > All modes currently buffer complete request and response bodies. -Connection pooling, upgrades, CONNECT tunnels, and HTTPS are explicit gaps. +Connection pooling, upgrades, and CONNECT proxy tunnels are explicit gaps. Unavailable operations throw `ERR_JCO_UNSUPPORTED_NODE_API` rather than silently doing nothing. +#### HTTPS + +`node:https` exposes Node 24's six exports: `Agent`, `globalAgent`, `Server`, +`createServer`, `get`, and `request`. `https.Agent` subclasses `http.Agent` on +both prototype chains, keeps Node's `defaultPort`/`protocol`/`maxCachedSessions` +defaults and its TLS session cache, and produces the same 23-field `getName()` +key as Node, so option bags pool the way they would natively. Requests reject +non-`https:` protocols with `ERR_INVALID_PROTOCOL` and elide `:443` from the +authority, and `https.get()` ends the request itself. + +TLS crosses the component boundary as a typed `tls-options` record on the +`jco:node/http@0.1.0` request and server options. It carries the serializable +subset of Node's `tls.connect` / `tls.createServer` options: `key`, `cert`, +`pfx`, `passphrase`, `ca`, `crl`, `dhparam`, `ciphers`, `ecdhCurve`, `sigalgs`, +`minVersion`, `maxVersion`, `secureProtocol`, `secureOptions`, +`sessionIdContext`, `honorCipherOrder`, `ALPNProtocols`, `servername`, +`rejectUnauthorized`, and `requestCert`. Material fields stay lists, so a +`key: [rsa, ecdsa]` bundle reaches the host intact. Options with no typed +representation -- `checkServerIdentity`, `SNICallback`, `ALPNCallback`, +`pskCallback`, `secureContext`, `session`, `ticketKeys`, and the OpenSSL engine +options -- throw `ERR_JCO_UNSUPPORTED_NODE_API` naming the option rather than +being dropped. + +| Value | `node:https` behaviour | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `direct` | Clients and servers. The opt-in Node provider routes `https` requests to `node:https.request` with the carried TLS options, and a server carrying a `tls` record to `node:https.createServer`, so the host's own TLS stack terminates the connection. | +| `wasi-sockets` | 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: @@ -869,12 +994,11 @@ jco componentize component.js --wit wit --bundle \ --with-nodejs-http2-via direct -o component.wasm ``` -| Value | Behavior | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | +| Value | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `direct` (default) | Typed `jco:node/http2@0.1.0`, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers. | | `wasi-sockets` | Cleartext prior-knowledge HTTP/2 (`h2c`) clients and TCP servers, with guest-side framing, HPACK, settings, ping, reset, and stream/connection flow control. | -| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | - +| `wasi-http` | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. | By default, the provider rejects both `connect()` and server construction with `ERR_JCO_HTTP2_ADAPTER_REQUIRED`. @@ -988,7 +1112,7 @@ These modules contain useful portable pieces, but their complete public surfaces also require operating-system access, Node internals, an event loop, or a larger set of coordinated shims: -`node:crypto`, `node:dgram`, `node:http2`, `node:https`, `node:net`, +`node:crypto`, `node:dgram`, `node:http2`, `node:perf_hooks`, `node:process`, `node:repl`, `node:sqlite`, `node:stream`, `node:stream/promises`, `node:stream/web`, `node:timers`, `node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, diff --git a/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 157aef3ec..89036b640 100644 --- a/packages/jco-node-fs/package.json +++ b/packages/jco-node-fs/package.json @@ -36,7 +36,7 @@ "lint": "cargo clippy --all-targets -- -D warnings", "prepare-release": "napi prepublish -t npm --no-gh-release --skip-optional-publish --root-publisher pnpm", "pretest": "pnpm run build:debug", - "test": "node --test test/*.test.js" + "test": "node --test test/*.js" }, "devDependencies": { "@napi-rs/cli": "^3.4.1", diff --git a/packages/jco-node-fs/test/fadvise.test.js b/packages/jco-node-fs/test/fadvise.js similarity index 100% rename from packages/jco-node-fs/test/fadvise.test.js rename to packages/jco-node-fs/test/fadvise.js diff --git a/packages/jco-node-fs/test/rename.test.js b/packages/jco-node-fs/test/rename.js similarity index 100% rename from packages/jco-node-fs/test/rename.test.js rename to packages/jco-node-fs/test/rename.js diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 2aeeeec04..8b7f47efd 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -36,6 +36,9 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/fs` | `node:fs` and `node:fs/promises` over an explicit host capability | | `wasi/0.2.x/node/24.x.x/http` | `node:http` API with direct, WASI sockets, and WASI HTTP implementations | | `wasi/0.2.x/node/24.x.x/http2` | `node:http2` API with direct and cleartext WASI sockets implementations | +| `wasi/0.2.x/node/24.x.x/https` | `node:https` API sharing the `node:http` core and implementations | +| `wasi/0.2.x/node/24.x.x/net` | `node:net` module over WASI Preview 2 0.2.12 sockets | +| `wasi/0.2.x/node/24.x.x/net/core` | `node:net` core over injected WASI Preview 2 sockets | | `wasi/0.2.x/node/24.x.x/os` | `node:os` guest adapter over an explicit host capability | | `wasi/0.2.x/node/24.x.x/path` | `node:path` adapter, Node 24 on WASI p2 | | `wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local `node:string_decoder` implementation for Node 24 | @@ -71,7 +74,8 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets` | `node:http` implementation over WASI Preview 2 sockets | | `wasi/0.2.x/node/24.x.x/http/impl/wasi-http` | `node:http` implementation over WASI Preview 2 HTTP | | `wasi/0.2.x/node/24.x.x/http/host` | Deny-by-default host for `jco:node/http` | -| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` | +| `wasi/0.2.x/node/24.x.x/http/host/node` | Opt-in host over the runtime's real `node:http` and `node:https` | +| `wasi/0.2.x/node/24.x.x/https/core` | `node:https` core shared by the selectable implementations | | `wasi/0.2.x/node/24.x.x/os/host` | Deny-by-default host for `jco:node/os` | | `wasi/0.2.x/node/24.x.x/os/host/node` | Opt-in host over the runtime's real `node:os` | | `node/path` | Legacy unversioned alias for `wasi/0.2.x/node/24.x.x/path` | @@ -154,8 +158,9 @@ 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; +- the `node:net` TCP client/server and address APIs over `wasi:sockets`; - `node:buffer`, with its modern core provided by Jco's audited unenv compatibility layer; - `node:querystring`, provided by Jco's audited unenv compatibility layer; @@ -551,6 +556,28 @@ interface does not expose an in-flight c-ares request as a resource that a later guest call could cancel. The WIT boundary remains runtime-neutral so a browser DNS provider can be added later. +### Net + +`node:net` uses Preview 2 DNS, TCP, IO streams, and pollables directly. Jco adds +the matching `wasi:sockets` and `wasi:io` imports for the selected 0.2.12 or +0.2.10 world; it does not add a Jco-specific network capability. Ordinary TCP +client and server code remains unchanged: + +```js +import { connect, createServer } from "node:net"; + +createServer((socket) => socket.end("hello")).listen(8080, "127.0.0.1"); +connect(8080, "127.0.0.1").setEncoding("utf8").on("data", console.log); +``` + +The module also provides `BlockList`, `SocketAddress`, IP predicates, +`BoundSocket`, family-selection defaults, and Node 24.19's exact named-export +surface. Unix-domain sockets, named pipes, arbitrary file-descriptor/libuv +handles, custom DNS callbacks, and socket options missing from Preview 2 are +rejected explicitly. `Socket` provides the common Duplex-shaped API but cannot +inherit from a classic `node:stream.Duplex` until that stream implementation is +available. + ### HTTP Application code uses the ordinary Node API: @@ -585,7 +612,19 @@ const server = createServer((request, response) => { server.listen(8080, "127.0.0.1"); ``` -Bundle it and select how `node:http` reaches the host: +`node:https` is the same core with the `https:` profile, port 443, and a +TLS-aware `Agent`. Servers take Node's TLS options and clients take the +`tls.connect` subset (`ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, +`ALPNProtocols`, and so on), which cross the boundary as a typed record: + +```js +import { createServer, get } from "node:https"; + +createServer({ key, cert }, (request, response) => response.end("secure")).listen(8443); +get("https://localhost:8443/", { ca: cert }, (response) => response.resume()); +``` + +Bundle it and select how `node:http` and `node:https` reach the host: ```console jco componentize component.js --wit wit --bundle \ @@ -597,12 +636,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 @@ -619,9 +678,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 7520288b8..16eaef86b 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": { @@ -124,9 +125,9 @@ "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/direct.js" }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets": { - "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.d.ts", - "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js", - "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js" + "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js" }, "./wasi/0.2.x/node/24.x.x/http/impl/wasi-http": { "types": "./dist/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.d.ts", @@ -176,6 +177,26 @@ "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/net": { + "types": "./dist/wasi/0.2.x/node/24.x.x/net.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/net.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/net.js" + }, + "./wasi/0.2.x/node/24.x.x/net/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/net/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/net/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/net/core.js" + }, "./wasi/0.2.x/node/24.x.x/path": { "types": "./dist/wasi/0.2.x/node/24.x.x/path.d.ts", "browser": "./dist/wasi/0.2.x/node/24.x.x/path.js", @@ -346,6 +367,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": { @@ -357,11 +386,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" @@ -378,5 +408,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 51cb56a1e..fce71f1e4 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http-host-node.ts @@ -2,16 +2,21 @@ * Opt-in Node.js HTTP provider. * * The operation mapping follows nodejs/node v24.19.0, commit - * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js and + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/http.js, lib/https.js, and * lib/_http_client.js (MIT license). The Node stream lifecycle is adapted to - * one buffered, typed WIT request/response exchange. + * one buffered, typed WIT request/response exchange. Requests with the `https` + * scheme and servers carrying a `tls` record go through real `node:https`, so + * TLS is terminated by the host's own stack. */ +import { Buffer } from "node:buffer"; import * as nodeHttp from "node:http"; import { CallbackResource, createCallbackQueue, retireCallbacks, } from "./internal/callback-resource.js"; +import * as nodeHttps from "node:https"; +import type * as nodeTls from "node:tls"; import { fieldsToRawHeaders, @@ -30,10 +35,56 @@ import type { DirectHttpServerAddress, DirectHttpServerConstructor, DirectHttpServerOptions, + DirectTlsOptions, } from "./http/types.js"; type AsyncResult = Promise>; type Timer = ReturnType; +type NodeTlsOptions = nodeTls.SecureContextOptions & + Pick & + Pick; + +function buffers(values: Uint8Array[]): Buffer[] { + return values.map((value) => Buffer.from(value)); +} + +/** + * Maps the WIT `tls-options` record onto the option names `node:tls` reads. + * + * Only present fields are copied, so Node applies its own defaults for the rest exactly as it + * would for a native caller. + */ +function nodeTlsOptions(tls: DirectTlsOptions): NodeTlsOptions { + const options: NodeTlsOptions = { + key: tls.key && buffers(tls.key), + cert: tls.cert && buffers(tls.cert), + pfx: tls.pfx && buffers(tls.pfx), + passphrase: tls.passphrase, + ca: tls.ca && buffers(tls.ca), + crl: tls.crl && buffers(tls.crl), + dhparam: tls.dhparam && Buffer.from(tls.dhparam), + ciphers: tls.ciphers, + ecdhCurve: tls.ecdhCurve, + sigalgs: tls.sigalgs, + minVersion: tls.minVersion as nodeTls.SecureVersion | undefined, + maxVersion: tls.maxVersion as nodeTls.SecureVersion | undefined, + secureProtocol: tls.secureProtocol, + secureOptions: tls.secureOptions, + sessionIdContext: tls.sessionIdContext, + honorCipherOrder: tls.honorCipherOrder, + ALPNProtocols: tls.alpnProtocols, + servername: tls.servername, + rejectUnauthorized: tls.rejectUnauthorized, + requestCert: tls.requestCert, + }; + for (const [name, value] of Object.entries(options)) { + if (value === undefined) { + delete options[name as keyof NodeTlsOptions]; + } + } + return options; +} + function timeoutError(syscall: string): Error & { code: string; syscall: string } { return Object.assign(new Error(`HTTP ${syscall} timed out`), { code: "ETIMEDOUT", @@ -50,12 +101,16 @@ export async function request(options: DirectHttpRequest): AsyncResult { clearTimeout(connectTimer); @@ -143,13 +198,23 @@ function serverAddress( class NodeHttpServer { readonly #pending = new Set>(); - readonly #server: nodeHttp.Server; + readonly #server: nodeHttp.Server | nodeHttps.Server; constructor( options: DirectHttpServerOptions, handle: (request: DirectHttpIncomingRequest) => Promise, ) { - this.#server = nodeHttp.createServer(nodeServerOptions(options), (request, response) => { + // A TLS record, including an empty one, selects a native HTTPS server. + const create = + options.tls === undefined + ? (handler: nodeHttp.RequestListener) => + nodeHttp.createServer(nodeServerOptions(options), handler) + : (handler: nodeHttp.RequestListener) => + nodeHttps.createServer( + { ...nodeServerOptions(options), ...nodeTlsOptions(options.tls!) }, + handler, + ); + this.#server = create((request, response) => { const pending = this.#handle(handle, request, response); this.#pending.add(pending); const complete = () => { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/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/body.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts index 2325cb25c..15c293fcc 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/body.ts @@ -1,38 +1,4 @@ -import { invalidArgType, invalidArgValue } from "./errors.js"; -import type { HttpBodyChunk } from "./types.js"; - -export function bodyBytes(value: HttpBodyChunk, encoding = "utf8"): Uint8Array { - if (typeof value === "string") { - const normalized = encoding.toLowerCase().replace("-", ""); - if (normalized === "utf8" || normalized === "utf") { - return new TextEncoder().encode(value); - } - if (normalized === "latin1" || normalized === "binary" || normalized === "ascii") { - return Uint8Array.from(value, (character) => character.charCodeAt(0) & 0xff); - } - throw invalidArgValue("encoding", encoding); - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value.slice(0)); - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array( - value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), - ); - } - throw invalidArgType("chunk", "string, Buffer, TypedArray, or DataView", value); -} - -export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const result = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; - } - return result; -} +export { bodyBytes, concatBytes } from "../internal/bytes.js"; export function base64(value: string): string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/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 df9a45b52..4c993d6a9 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.ts @@ -149,6 +149,17 @@ function method(value: string): WasiHttpMethod { : { tag: "other", val: value }; } +function scheme(value: string): WasiHttpScheme { + switch (value) { + case "http": + return { tag: "HTTP" }; + case "https": + return { tag: "HTTPS" }; + default: + return { tag: "other", val: value }; + } +} + function duration(milliseconds: number | undefined): bigint | undefined { return milliseconds === undefined ? undefined : BigInt(milliseconds) * 1_000_000n; } @@ -229,6 +240,12 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm "wasi:http outgoing-handler cannot accept arbitrary inbound HTTP connections", request(request) { + if (request.tls !== undefined) { + unsupported( + `${request.scheme}.request TLS options with the wasi-http implementation`, + "wasi:http/outgoing-handler owns certificate validation and cannot take per-request TLS configuration", + ); + } try { const host = request.headers.find(({ name }) => name.toLowerCase() === "host"); if ( @@ -250,9 +267,7 @@ export function createWasiHttpImplementation(provider: WasiHttpProvider): HttpIm ); const outgoing = new provider.types.OutgoingRequest(fields); outgoing.setMethod(method(request.method)); - outgoing.setScheme( - request.scheme === "http" ? { tag: "HTTP" } : { tag: "other", val: request.scheme }, - ); + outgoing.setScheme(scheme(request.scheme)); outgoing.setAuthority(request.authority); outgoing.setPathWithQuery(request.pathWithQuery); const body = outgoing.body(); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts deleted file mode 100644 index f2ea2d178..000000000 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { concatBytes } from "../body.js"; -import { fromImplementationError, invalidArgValue, unsupported, wasiErrorCode } from "../errors.js"; -import { - parseHttp1Request, - parseHttp1Response, - serializeHttp1Request, - serializeHttp1Response, -} from "../http1.js"; -import type { - HttpImplementation, - HttpImplementationRequest, - HttpImplementationResponse, - HttpIncomingRequestData, - HttpListenOptions, - HttpRequestHandler, - HttpServerAddress, - HttpServerImplementation, - HttpServerOptions, -} from "../types.js"; - -export type WasiIpAddress = - | { tag: "ipv4"; val: [number, number, number, number] } - | { tag: "ipv6"; val: [number, number, number, number, number, number, number, number] }; - -export type WasiIpSocketAddress = - | { tag: "ipv4"; val: { port: number; address: [number, number, number, number] } } - | { - tag: "ipv6"; - val: { - port: number; - flowInfo: number; - address: [number, number, number, number, number, number, number, number]; - scopeId: number; - }; - }; - -export interface WasiPollable { - block(): void; - [Symbol.dispose]?(): void; -} - -export interface WasiInputStream { - blockingRead(length: bigint): Uint8Array; - [Symbol.dispose]?(): void; -} - -export interface WasiOutputStream { - blockingWriteAndFlush(contents: Uint8Array): void; - [Symbol.dispose]?(): void; -} - -export interface WasiResolveAddressStream { - resolveNextAddress(): WasiIpAddress | undefined; - subscribe(): WasiPollable; - [Symbol.dispose]?(): void; -} - -export interface WasiTcpSocket { - startBind?(network: unknown, localAddress: WasiIpSocketAddress): void; - finishBind?(): void; - startConnect(network: unknown, remoteAddress: WasiIpSocketAddress): void; - finishConnect(): [WasiInputStream, WasiOutputStream]; - startListen?(): void; - finishListen?(): void; - accept?(): [WasiTcpSocket, WasiInputStream, WasiOutputStream]; - localAddress?(): WasiIpSocketAddress; - remoteAddress?(): WasiIpSocketAddress; - setListenBacklogSize?(value: bigint): void; - subscribe(): WasiPollable; - shutdown(direction: "receive" | "send" | "both"): void; - [Symbol.dispose]?(): void; -} - -export interface WasiNetwork { - [Symbol.dispose]?(): void; -} - -export interface WasiSocketsProvider { - instanceNetwork: { - instanceNetwork(): WasiNetwork; - }; - ipNameLookup: { - resolveAddresses(network: WasiNetwork, name: string): WasiResolveAddressStream; - }; - tcpCreateSocket: { - createTcpSocket(family: "ipv4" | "ipv6"): WasiTcpSocket; - }; - /** Convert a safe integer to the component engine's WIT u64 representation. */ - u64?: (value: number) => bigint; - schedule?: (task: () => void | Promise) => void; -} - -export function wasiU64(provider: WasiSocketsProvider, value: number): bigint { - return provider.u64?.(value) ?? BigInt(value); -} - -export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): void { - resource?.[Symbol.dispose]?.(); -} - -export const errorCode = wasiErrorCode; - -export function socketError(error: unknown, syscall: string, hostname?: string): Error { - const code = errorCode(error) ?? "unknown"; - const nodeCodes: Record = { - "access-denied": "EACCES", - "address-in-use": "EADDRINUSE", - "connection-aborted": "ECONNABORTED", - "connection-refused": "ECONNREFUSED", - "connection-reset": "ECONNRESET", - "name-unresolvable": "ENOTFOUND", - "remote-unreachable": "EHOSTUNREACH", - timeout: "ETIMEDOUT", - }; - return fromImplementationError({ - name: "Error", - message: `${syscall} ${nodeCodes[code] ?? "ERR_JCO_WASI_SOCKET"}${hostname ? ` ${hostname}` : ""}`, - code: nodeCodes[code] ?? "ERR_JCO_WASI_SOCKET", - syscall, - hostname, - }); -} - -function remoteAddress(address: WasiIpAddress, port: number): WasiIpSocketAddress { - return address.tag === "ipv4" - ? { tag: "ipv4", val: { address: address.val, port } } - : { - tag: "ipv6", - val: { address: address.val, port, flowInfo: 0, scopeId: 0 }, - }; -} - -function parseIpv4(value: string): [number, number, number, number] | undefined { - const parts = value.split("."); - if (parts.length !== 4) { - return undefined; - } - const numbers = parts.map(Number); - return numbers.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ? (numbers as [number, number, number, number]) - : undefined; -} - -export function localAddress(host: string, port: number): WasiIpSocketAddress { - const normalized = host === "localhost" ? "127.0.0.1" : host; - const ipv4 = parseIpv4(normalized); - if (ipv4) { - return { tag: "ipv4", val: { address: ipv4, port } }; - } - if (normalized === "::" || normalized === "::1") { - return { - tag: "ipv6", - val: { - address: normalized === "::" ? [0, 0, 0, 0, 0, 0, 0, 0] : [0, 0, 0, 0, 0, 0, 0, 1], - port, - flowInfo: 0, - scopeId: 0, - }, - }; - } - return unsupported( - "http.Server.listen host", - "the wasi-sockets implementation currently accepts localhost and IPv4, ::, or ::1 literals", - ); -} - -export function nodeAddress(address: WasiIpSocketAddress): Exclude { - if (address.tag === "ipv4") { - return { - address: address.val.address.join("."), - family: "IPv4", - port: address.val.port, - }; - } - const addressText = address.val.address.every((part) => part === 0) - ? "::" - : address.val.address.join(":"); - return { address: addressText, family: "IPv6", port: address.val.port }; -} - -export function authority(value: string): { hostname: string; port: number } { - try { - const url = new URL(`http://${value}`); - return { hostname: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port || 80) }; - } catch { - throw invalidArgValue("authority", value); - } -} - -function nextAddress(stream: WasiResolveAddressStream): WasiIpAddress | undefined { - for (;;) { - try { - return stream.resolveNextAddress(); - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = stream.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } -} - -export function connect( - provider: WasiSocketsProvider, - hostname: string, - port: number, -): { socket: WasiTcpSocket; input: WasiInputStream; output: WasiOutputStream } { - const network = provider.instanceNetwork.instanceNetwork(); - let addresses: WasiResolveAddressStream; - try { - addresses = provider.ipNameLookup.resolveAddresses(network, hostname); - } catch (error) { - throw socketError(error, "getaddrinfo", hostname); - } - try { - let lastError: unknown; - for (;;) { - let address: WasiIpAddress | undefined; - try { - address = nextAddress(addresses); - } catch (error) { - throw socketError(error, "getaddrinfo", hostname); - } - if (!address) { - throw socketError(lastError ?? "name-unresolvable", "connect", hostname); - } - const socket = provider.tcpCreateSocket.createTcpSocket(address.tag); - try { - socket.startConnect(network, remoteAddress(address, port)); - for (;;) { - try { - const [input, output] = socket.finishConnect(); - return { socket, input, output }; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = socket.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } - } catch (error) { - lastError = error; - dispose(socket); - } - } - } finally { - dispose(addresses); - dispose(network); - } -} - -function readResponse( - provider: WasiSocketsProvider, - input: WasiInputStream, - request: HttpImplementationRequest, -): HttpImplementationResponse { - const chunks: Uint8Array[] = []; - let closed = false; - for (;;) { - const bytes = concatBytes(chunks); - const response = parseHttp1Response(bytes, request.method, closed); - if (response) { - return response; - } - if (closed) { - throw socketError("connection-terminated", "read"); - } - try { - chunks.push(input.blockingRead(wasiU64(provider, 65_536))); - } catch (error) { - if (errorCode(error) !== "closed") { - throw socketError(error, "read"); - } - closed = true; - } - } -} - -export function finishPending(operation: () => void, socket: WasiTcpSocket): void { - for (;;) { - try { - operation(); - return; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = socket.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } -} - -function readRequest( - provider: WasiSocketsProvider, - input: WasiInputStream, -): HttpIncomingRequestData { - const chunks: Uint8Array[] = []; - let closed = false; - for (;;) { - const parsed = parseHttp1Request(concatBytes(chunks), closed); - if (parsed) { - return parsed.request; - } - if (closed) { - throw socketError("connection-terminated", "read"); - } - try { - chunks.push(input.blockingRead(wasiU64(provider, 65_536))); - } catch (error) { - if (errorCode(error) !== "closed") { - throw error; - } - closed = true; - } - } -} - -class WasiSocketsHttpServer implements HttpServerImplementation { - readonly #provider: WasiSocketsProvider; - readonly #handler: HttpRequestHandler; - readonly #onError: (error: Error) => void; - readonly #connections = new Set(); - #network: WasiNetwork | undefined; - #socket: WasiTcpSocket | undefined; - #address: Exclude | null = null; - #listening = false; - - constructor( - provider: WasiSocketsProvider, - options: HttpServerOptions, - handler: HttpRequestHandler, - onError: (error: Error) => void, - ) { - this.#provider = provider; - this.#handler = handler; - this.#onError = onError; - for (const name of [ - "requestTimeout", - "headersTimeout", - "keepAliveTimeout", - "keepAliveTimeoutBuffer", - "connectionsCheckingInterval", - "maxHeaderSize", - "joinDuplicateHeaders", - "noDelay", - "requireHostHeader", - "keepAlive", - "keepAliveInitialDelay", - "rejectNonStandardBodyWrites", - "optimizeEmptyRequests", - ] as const) { - if (options[name] !== undefined) { - unsupported( - `http.Server option ${name} with the wasi-sockets implementation`, - "the Preview 2 TCP and buffered HTTP/1.1 boundary cannot implement this option faithfully", - ); - } - } - } - - listen(options: HttpListenOptions): HttpServerAddress { - if (options.path !== undefined) { - return unsupported( - "http.Server.listen path", - "wasi:sockets Preview 2 exposes IP sockets but not Unix domain sockets", - ); - } - if (options.exclusive !== undefined || options.ipv6Only || options.reusePort) { - return unsupported( - "http.Server.listen options", - "exclusive, ipv6Only, and reusePort cannot be configured with wasi:sockets Preview 2", - ); - } - const address = localAddress(options.host ?? "::", options.port ?? 0); - const network = this.#provider.instanceNetwork.instanceNetwork(); - const socket = this.#provider.tcpCreateSocket.createTcpSocket(address.tag); - if ( - !socket.startBind || - !socket.finishBind || - !socket.startListen || - !socket.finishListen || - !socket.accept || - !socket.localAddress - ) { - dispose(socket); - dispose(network); - return unsupported( - "http.Server", - "the supplied wasi-sockets implementation does not expose TCP server operations", - ); - } - try { - if (options.backlog !== undefined) { - socket.setListenBacklogSize?.(wasiU64(this.#provider, options.backlog)); - } - socket.startBind(network, address); - finishPending(() => socket.finishBind!(), socket); - socket.startListen(); - finishPending(() => socket.finishListen!(), socket); - this.#network = network; - this.#socket = socket; - this.#address = nodeAddress(socket.localAddress()); - this.#listening = true; - this.#scheduleAccept(); - return this.#address; - } catch (error) { - dispose(socket); - dispose(network); - throw socketError(error, "listen", options.host); - } - } - - close(): boolean { - const wasListening = this.#listening; - this.#listening = false; - this.closeAllConnections(); - dispose(this.#socket); - dispose(this.#network); - this.#socket = undefined; - this.#network = undefined; - this.#address = null; - return wasListening; - } - - closeAllConnections(): void { - for (const connection of this.#connections) { - try { - connection.shutdown("both"); - } catch { - // The peer may already have closed the connection. - } - dispose(connection); - } - this.#connections.clear(); - } - - closeIdleConnections(): void { - // This implementation closes every connection after one response, so no - // persistent idle connections exist between request callbacks. - } - - getConnections(): number { - return this.#connections.size; - } - - address(): HttpServerAddress | null { - return this.#address; - } - - ref(): void {} - - unref(): void {} - - async #accept(): Promise { - const listener = this.#socket; - if (!this.#listening || !listener?.accept) { - return; - } - let connection: WasiTcpSocket | undefined; - let input: WasiInputStream | undefined; - let output: WasiOutputStream | undefined; - try { - for (;;) { - try { - [connection, input, output] = listener.accept(); - break; - } catch (error) { - if (errorCode(error) !== "would-block") { - throw error; - } - const pollable = listener.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } - this.#connections.add(connection); - const request = readRequest(this.#provider, input); - if ( - request.httpVersion === "1.1" && - !request.headers.some(({ name }) => name.toLowerCase() === "host") - ) { - output.blockingWriteAndFlush( - serializeHttp1Response({ - statusCode: 400, - statusMessage: "Bad Request", - headers: [], - body: new Uint8Array(), - }), - ); - return; - } - const remote = connection.remoteAddress?.(); - if (remote) { - const address = nodeAddress(remote); - request.remoteAddress = address.address; - request.remotePort = address.port; - } - const response = await this.#handler(request); - output.blockingWriteAndFlush(serializeHttp1Response(response)); - } catch (error) { - throw socketError(error, "accept"); - } finally { - if (connection) { - this.#connections.delete(connection); - try { - connection.shutdown("both"); - } catch { - // The peer may already have closed the connection. - } - } - dispose(output); - dispose(input); - dispose(connection); - if (this.#listening) { - this.#scheduleAccept(); - } - } - } - - #scheduleAccept(): void { - const task = async () => { - try { - await this.#accept(); - } catch (error) { - this.#onError(error instanceof Error ? error : new Error(String(error))); - } - }; - if (this.#provider.schedule) { - this.#provider.schedule(task); - } else { - queueMicrotask(() => void task()); - } - } -} - -export function createWasiSocketsHttpImplementation( - provider: WasiSocketsProvider, -): HttpImplementation { - return { - createServer(options, handler, onError) { - return new WasiSocketsHttpServer(provider, options, handler, onError); - }, - - request(request) { - if (request.scheme !== "http") { - throw invalidArgValue("protocol", `${request.scheme}:`); - } - if ( - request.connectTimeoutMs !== undefined || - request.firstByteTimeoutMs !== undefined || - request.betweenBytesTimeoutMs !== undefined - ) { - unsupported( - "http.ClientRequest.setTimeout with the wasi-sockets implementation", - "deadline polling across Preview 2 socket and clock resources is not implemented", - ); - } - const { hostname, port } = authority(request.authority); - const { socket, input, output } = connect(provider, hostname, port); - try { - output.blockingWriteAndFlush(serializeHttp1Request(request)); - return readResponse(provider, input, request); - } catch (error) { - if (error instanceof Error && "code" in error) { - throw error; - } - throw socketError(error, "request", hostname); - } finally { - try { - socket.shutdown("both"); - } catch { - // The peer may already have closed the connection. - } - dispose(output); - dispose(input); - dispose(socket); - } - }, - }; -} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts new file mode 100644 index 000000000..543fe7fa0 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.ts @@ -0,0 +1,407 @@ +import { + handshake, + validateTlsOptions, + type WasiTlsProvider, + type WasiTlsConnection, +} from "./tls.js"; +import { concatBytes } from "../../body.js"; +import { fromImplementationError, invalidArgValue, unsupported } from "../../errors.js"; +import { + accept as acceptTcp, + bind, + connect, + dispose, + errorCode, + listen as listenTcp, + nodeAddress, + socketError, + wasiU64, + type WasiInputStream, + type WasiNetwork, + type WasiOutputStream, + type WasiSocketsProvider as WasiTcpProvider, + type WasiTcpSocket, +} from "../../../internal/wasi-sockets.js"; +import { + parseHttp1Request, + parseHttp1Response, + serializeHttp1Request, + serializeHttp1Response, +} from "../../http1.js"; +import type { + HttpImplementation, + HttpImplementationRequest, + HttpImplementationResponse, + HttpIncomingRequestData, + HttpListenOptions, + HttpRequestHandler, + HttpServerAddress, + HttpServerImplementation, + HttpServerOptions, +} from "../../types.js"; + +// Preserve the implementation module's existing transport exports while their ownership moves +// to the shared layer used by node:net and node:http2. +export { + connect, + dispose, + errorCode, + finishPending, + localAddress, + nodeAddress, + socketError, + wasiU64, +} from "../../../internal/wasi-sockets.js"; +export type { + WasiInputStream, + WasiIpAddress, + WasiIpSocketAddress, + WasiNetwork, + WasiOutputStream, + WasiPollable, + WasiResolveAddressStream, + WasiTcpSocket, +} from "../../../internal/wasi-sockets.js"; + +export interface WasiSocketsProvider extends WasiTcpProvider { + tls?: WasiTlsProvider; +} + +export function authority(value: string, scheme = "http"): { hostname: string; port: number } { + try { + const url = new URL(`${scheme}://${value}`); + return { + hostname: url.hostname.replace(/^\[|\]$/g, ""), + port: Number(url.port || (scheme === "https" ? 443 : 80)), + }; + } catch { + throw invalidArgValue("authority", value); + } +} + +function readResponse( + provider: WasiSocketsProvider, + input: WasiInputStream, + request: HttpImplementationRequest, +): HttpImplementationResponse { + const chunks: Uint8Array[] = []; + let closed = false; + for (;;) { + const bytes = concatBytes(chunks); + const response = parseHttp1Response(bytes, request.method, closed); + if (response) { + return response; + } + if (closed) { + throw socketError("connection-terminated", "read"); + } + try { + chunks.push(input.blockingRead(wasiU64(provider, 65_536))); + } catch (error) { + if (errorCode(error) !== "closed") { + throw socketError(error, "read"); + } + closed = true; + } + } +} + +function readRequest( + provider: WasiSocketsProvider, + input: WasiInputStream, +): HttpIncomingRequestData { + const chunks: Uint8Array[] = []; + let closed = false; + for (;;) { + const parsed = parseHttp1Request(concatBytes(chunks), closed); + if (parsed) { + return parsed.request; + } + if (closed) { + throw socketError("connection-terminated", "read"); + } + try { + chunks.push(input.blockingRead(wasiU64(provider, 65_536))); + } catch (error) { + if (errorCode(error) !== "closed") { + throw error; + } + closed = true; + } + } +} + +class WasiSocketsHttpServer implements HttpServerImplementation { + readonly #provider: WasiSocketsProvider; + readonly #handler: HttpRequestHandler; + readonly #onError: (error: Error) => void; + readonly #connections = new Set(); + #network: WasiNetwork | undefined; + #socket: WasiTcpSocket | undefined; + #address: Exclude | null = null; + #listening = false; + + constructor( + provider: WasiSocketsProvider, + options: HttpServerOptions, + handler: HttpRequestHandler, + onError: (error: Error) => void, + ) { + this.#provider = provider; + this.#handler = handler; + this.#onError = onError; + for (const name of [ + "requestTimeout", + "headersTimeout", + "keepAliveTimeout", + "keepAliveTimeoutBuffer", + "connectionsCheckingInterval", + "maxHeaderSize", + "joinDuplicateHeaders", + "noDelay", + "requireHostHeader", + "keepAlive", + "keepAliveInitialDelay", + "rejectNonStandardBodyWrites", + "optimizeEmptyRequests", + ] as const) { + if (options[name] !== undefined) { + unsupported( + `http.Server option ${name} with the wasi-sockets implementation`, + "the Preview 2 TCP and buffered HTTP/1.1 boundary cannot implement this option faithfully", + ); + } + } + } + + listen(options: HttpListenOptions): HttpServerAddress { + if (options.path !== undefined) { + return unsupported( + "http.Server.listen path", + "wasi:sockets Preview 2 exposes IP sockets but not Unix domain sockets", + ); + } + if (options.exclusive !== undefined || options.ipv6Only || options.reusePort) { + return unsupported( + "http.Server.listen options", + "exclusive, ipv6Only, and reusePort cannot be configured with wasi:sockets Preview 2", + ); + } + const bound = bind( + this.#provider, + options.host ?? "::", + options.port ?? 0, + options.backlog, + "http.Server.listen", + ); + try { + listenTcp(bound.socket); + this.#network = bound.network; + this.#socket = bound.socket; + this.#address = bound.address; + this.#listening = true; + this.#scheduleAccept(); + return this.#address; + } catch (error) { + dispose(bound.socket); + dispose(bound.network); + throw socketError(error, "listen", options.host); + } + } + + close(): boolean { + const wasListening = this.#listening; + this.#listening = false; + this.closeAllConnections(); + dispose(this.#socket); + dispose(this.#network); + this.#socket = undefined; + this.#network = undefined; + this.#address = null; + return wasListening; + } + + closeAllConnections(): void { + for (const connection of this.#connections) { + try { + connection.shutdown("both"); + } catch { + // The peer may already have closed the connection. + } + dispose(connection); + } + this.#connections.clear(); + } + + closeIdleConnections(): void { + // This implementation closes every connection after one response, so no + // persistent idle connections exist between request callbacks. + } + + getConnections(): number { + return this.#connections.size; + } + + address(): HttpServerAddress | null { + return this.#address; + } + + ref(): void {} + + unref(): void {} + + async #accept(): Promise { + const listener = this.#socket; + if (!this.#listening || !listener?.accept) { + return; + } + let connection: WasiTcpSocket | undefined; + let input: WasiInputStream | undefined; + let output: WasiOutputStream | undefined; + try { + [connection, input, output] = acceptTcp(listener); + this.#connections.add(connection); + const request = readRequest(this.#provider, input); + if ( + request.httpVersion === "1.1" && + !request.headers.some(({ name }) => name.toLowerCase() === "host") + ) { + output.blockingWriteAndFlush( + serializeHttp1Response({ + statusCode: 400, + statusMessage: "Bad Request", + headers: [], + body: new Uint8Array(), + }), + ); + return; + } + const remote = connection.remoteAddress?.(); + if (remote) { + const address = nodeAddress(remote); + request.remoteAddress = address.address; + request.remotePort = address.port; + } + const response = await this.#handler(request); + output.blockingWriteAndFlush(serializeHttp1Response(response)); + } catch (error) { + throw socketError(error, "accept"); + } finally { + if (connection) { + this.#connections.delete(connection); + try { + connection.shutdown("both"); + } catch { + // The peer may already have closed the connection. + } + } + dispose(output); + dispose(input); + dispose(connection); + if (this.#listening) { + this.#scheduleAccept(); + } + } + } + + #scheduleAccept(): void { + const task = async () => { + try { + await this.#accept(); + } catch (error) { + this.#onError(error instanceof Error ? error : new Error(String(error))); + } + }; + if (this.#provider.schedule) { + this.#provider.schedule(task); + } else { + queueMicrotask(() => void task()); + } + } +} + +export function createWasiSocketsHttpImplementation( + provider: WasiSocketsProvider, +): HttpImplementation { + return { + createServer(options, handler, onError) { + if (options.tls !== undefined) { + unsupported( + "https.Server with the wasi-sockets implementation", + "wasi:tls@0.2.0-draft exposes only client TLS handshakes and has no server handshake or certificate configuration", + ); + } + return new WasiSocketsHttpServer(provider, options, handler, onError); + }, + + request(request) { + if (request.scheme === "https") { + validateTlsOptions(request.tls); + if (!provider.tls?.isAvailable()) { + throw fromImplementationError({ + name: "Error", + code: "ERR_JCO_TLS_ADAPTER_REQUIRED", + message: + "https: requests with the wasi-sockets implementation require the additional wasi:tls/types@0.2.0-draft TLS capability", + }); + } + } else if (request.scheme !== "http") { + unsupported(`${request.scheme}: requests`, "only HTTP and HTTPS are supported"); + } + if ( + request.connectTimeoutMs !== undefined || + request.firstByteTimeoutMs !== undefined || + request.betweenBytesTimeoutMs !== undefined + ) { + unsupported( + "http.ClientRequest.setTimeout with the wasi-sockets implementation", + "deadline polling across Preview 2 socket and clock resources is not implemented", + ); + } + const { hostname, port } = authority(request.authority, request.scheme); + const tcp = connect(provider, hostname, port); + const socket = tcp.socket; + let input: WasiInputStream | undefined = tcp.input; + let output: WasiOutputStream | undefined = tcp.output; + let tlsConnection: WasiTlsConnection | undefined; + try { + if (request.scheme === "https") { + input = undefined; + output = undefined; + [tlsConnection, input, output] = handshake( + provider.tls!, + request.tls?.servername ?? hostname, + tcp.input, + tcp.output, + ); + } + const bytes = serializeHttp1Request(request); + // wasi:io blocking-write-and-flush accepts at most 4096 bytes per call. + for (let offset = 0; offset < bytes.length; offset += 4096) { + output!.blockingWriteAndFlush(bytes.subarray(offset, offset + 4096)); + } + return readResponse(provider, input!, request); + } catch (error) { + if (error instanceof Error && "code" in error) { + throw error; + } + throw socketError(error, "request", hostname); + } finally { + try { + tlsConnection?.closeOutput(); + } catch { + /* The peer may have closed. */ + } + dispose(output); + dispose(input); + dispose(tlsConnection); + try { + socket.shutdown("both"); + } catch { + // The peer may already have closed the connection. + } + dispose(socket); + } + }, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts new file mode 100644 index 000000000..e90a5fd96 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/tls.ts @@ -0,0 +1,120 @@ +/** + * Guest contract for WebAssembly/wasi-tls wit/types.wit, revision + * 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). + * Local contract: wasi:io@0.2.12 and an availability query (see vendored README.md). + */ +import { fromImplementationError, unsupported } from "../../errors.js"; +import type { HttpTlsMaterial } from "../../types.js"; +import { + dispose, + type WasiInputStream, + type WasiOutputStream, + type WasiPollable, +} from "../../../internal/wasi-sockets.js"; + +export interface WasiTlsError { + toDebugString(): string; + [Symbol.dispose]?(): void; +} +export interface WasiTlsConnection { + closeOutput(): void; + [Symbol.dispose]?(): void; +} +export interface WasiTlsHandshake { + [Symbol.dispose]?(): void; +} +export type WasiTlsStreams = [WasiTlsConnection, WasiInputStream, WasiOutputStream]; +export type WasiTlsResult = + | { tag: "err"; val?: undefined } + | { + tag: "ok"; + val: { tag: "ok"; val: WasiTlsStreams } | { tag: "err"; val: WasiTlsError }; + }; +export interface WasiTlsFuture { + get(): WasiTlsResult | undefined; + subscribe(): WasiPollable; + [Symbol.dispose]?(): void; +} +export interface WasiTlsProvider { + isAvailable(): boolean; + ClientHandshake: { + new (serverName: string, input: WasiInputStream, output: WasiOutputStream): WasiTlsHandshake; + finish(handshake: WasiTlsHandshake): WasiTlsFuture; + }; +} + +export function validateTlsOptions(options: HttpTlsMaterial | undefined): void { + for (const [name, value] of Object.entries(options ?? {})) { + if ( + value === undefined || + name === "servername" || + (name === "rejectUnauthorized" && value === true) + ) { + continue; + } + unsupported( + `https.request TLS option ${name}`, + "wasi:tls@0.2.0-draft only accepts a server name; trust, verification, ALPN and other TLS settings are host policy", + ); + } + if (options?.servername === "") { + unsupported( + "https.request TLS option servername", + "wasi:tls requires a name for certificate verification and cannot disable SNI independently", + ); + } +} + +/** Takes ownership of input/output, including on handshake failure. */ +export function handshake( + provider: WasiTlsProvider, + serverName: string, + input: WasiInputStream, + output: WasiOutputStream, +): WasiTlsStreams { + let ownedInput: WasiInputStream | undefined = input; + let ownedOutput: WasiOutputStream | undefined = output; + let pending: WasiTlsHandshake | undefined; + let future: WasiTlsFuture | undefined; + try { + pending = new provider.ClientHandshake(serverName, ownedInput, ownedOutput); + ownedInput = undefined; + ownedOutput = undefined; + future = provider.ClientHandshake.finish(pending); + pending = undefined; + for (;;) { + const result = future.get(); + if (result) { + if (result.tag === "err") { + throw new Error("wasi:tls future was already consumed"); + } + if (result.val.tag === "err") { + const error = result.val.val; + try { + throw fromImplementationError({ + name: "Error", + code: "ERR_JCO_WASI_TLS", + message: `TLS handshake failed: ${error.toDebugString()}`, + syscall: "tls", + hostname: serverName, + }); + } finally { + dispose(error); + } + } + return result.val.val; + } + const poll = future.subscribe(); + try { + poll.block(); + } finally { + dispose(poll); + } + } + } finally { + dispose(future); + dispose(pending); + dispose(ownedOutput); + dispose(ownedInput); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts new file mode 100644 index 000000000..a30a1846a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/profile.ts @@ -0,0 +1,31 @@ +/** + * Protocol profile shared by the node:http and node:https shims. + * + * `node:https` is `lib/_http_client.js` and `lib/_http_server.js` driven with a different + * protocol, default port, and global agent (nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/https.js). Rather than fork those modules, + * every protocol-dependent constant is collected here and threaded through the shared core. + */ + +import { Agent, globalAgent } from "./agent.js"; + +export interface ProtocolProfile { + /** Module name used in error labels, e.g. `https.Server`. */ + readonly module: "http" | "https"; + /** `options.protocol` this module accepts; anything else is `ERR_INVALID_PROTOCOL`. */ + readonly protocol: "http:" | "https:"; + /** URI scheme handed to the selected implementation. */ + readonly scheme: "http" | "https"; + /** Port assumed when neither the options nor an explicit agent supply one. */ + readonly defaultPort: number; + /** Agent used when `options.agent` is absent, matching Node's `_defaultAgent`. */ + readonly globalAgent: Agent; +} + +export const HTTP_PROFILE: ProtocolProfile = { + module: "http", + protocol: "http:", + scheme: "http", + defaultPort: 80, + globalAgent, +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts index f45646d22..37dba01b7 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/server.ts @@ -16,6 +16,8 @@ import { codedError } from "../errors/core.js"; import { invalidArgType, invalidArgValue, unsupported } from "./errors.js"; import { IncomingMessage } from "./incoming-message.js"; import { OutgoingMessage } from "./outgoing-message.js"; +import type { ProtocolProfile } from "./profile.js"; +import { tlsMaterial } from "./tls.js"; import type { HttpBodyChunk, HttpCallback, @@ -27,12 +29,19 @@ import type { HttpOutgoingResponseData, HttpServerAddress, HttpServerImplementation, + HttpTlsOptions, } from "./types.js"; export type RequestListener = (request: IncomingMessage, response: ServerResponse) => void; export type GetConnectionsCallback = (error: Error | null, count: number) => void; -export interface ServerOptions { +/** + * `http.createServer` options plus the TLS material `https.createServer` accepts. + * + * Node routes the TLS half to `tls.Server` and the rest to `_http_server`; the shim keeps one + * option bag and lets the profile decide whether the TLS half is read at all. + */ +export interface ServerOptions extends HttpTlsOptions { requestTimeout?: number; headersTimeout?: number; keepAliveTimeout?: number; @@ -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 0b450e1e1..3fed1ee30 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http/types.ts @@ -4,7 +4,13 @@ import type { HostErrno, HostErrorBase } from "../internal/wit-types.js"; export type HttpHeaderValue = string | number | readonly string[]; export type HttpHeaders = Record; -export interface HttpRequestOptions { +/** + * `http.request` options plus the TLS options `https.request` accepts. + * + * The TLS members are read only by the `node:https` profile; `node:http` ignores them the way + * Node's `net.connect` does. + */ +export interface HttpRequestOptions extends HttpTlsOptions { protocol?: string; host?: string | null; hostname?: string | null; @@ -15,7 +21,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; @@ -32,6 +38,70 @@ export interface HttpRequestOptions { export interface AgentLike { readonly options: Readonly>; + readonly defaultPort?: number; +} + +/** PEM, DER, or PFX material as Node's TLS options accept it. */ +export type TlsMaterial = string | ArrayBufferView | ArrayBuffer; + +/** + * The subset of Node's `tls.createServer` / `tls.connect` options the typed WIT boundary + * carries. + * + * Follows nodejs/node v24.19.0, commit cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, + * lib/_tls_common.js `configSecureContext` and lib/_tls_wrap.js. Options with no serializable + * representation (`secureContext`, `SNICallback`, `ALPNCallback`, `checkServerIdentity`, + * engine identifiers, sessions, ticket keys) are refused by `tlsMaterial()` rather than + * silently dropped. + */ +export interface HttpTlsOptions { + key?: TlsMaterial | readonly TlsMaterial[]; + cert?: TlsMaterial | readonly TlsMaterial[]; + pfx?: TlsMaterial | readonly TlsMaterial[]; + passphrase?: string; + ca?: TlsMaterial | readonly TlsMaterial[]; + crl?: TlsMaterial | readonly TlsMaterial[]; + dhparam?: TlsMaterial; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + ALPNProtocols?: readonly string[] | TlsMaterial; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; +} + +/** + * Normalized TLS material handed to an implementation; mirrors the `tls-options` record of + * `jco:node/http@0.1.0` field for field. + */ +export interface HttpTlsMaterial { + key?: Uint8Array[]; + cert?: Uint8Array[]; + pfx?: Uint8Array[]; + passphrase?: string; + ca?: Uint8Array[]; + crl?: Uint8Array[]; + dhparam?: Uint8Array; + ciphers?: string; + ecdhCurve?: string; + sigalgs?: string; + minVersion?: string; + maxVersion?: string; + secureProtocol?: string; + secureOptions?: number; + sessionIdContext?: string; + honorCipherOrder?: boolean; + alpnProtocols?: string[]; + servername?: string; + rejectUnauthorized?: boolean; + requestCert?: boolean; } export type HttpBodyChunk = string | ArrayBuffer | ArrayBufferView; @@ -51,6 +121,8 @@ export interface HttpImplementationRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + /** Client TLS configuration; only ever set by `node:https`, and only when options carry some. */ + tls?: HttpTlsMaterial; } export interface HttpImplementationResponse { @@ -94,6 +166,12 @@ export interface HttpServerOptions { highWaterMark?: number; insecureHTTPParser?: boolean; uniqueHeaders?: Array; + /** + * Present for every `node:https` server, even when no material was supplied, so that an + * implementation without a TLS stack refuses instead of serving plaintext. Absent for + * `node:http` servers. + */ + tls?: HttpTlsMaterial; [name: string]: unknown; } @@ -201,6 +279,7 @@ export interface DirectHttpRequest { connectTimeoutMs?: number; firstByteTimeoutMs?: number; betweenBytesTimeoutMs?: number; + tls?: DirectTlsOptions; } export interface DirectHttpResponse { @@ -213,6 +292,9 @@ export interface DirectHttpResponse { export type DirectHttpResult = { tag: "ok"; val: T } | { tag: "err"; val: DirectHttpError }; +/** The `tls-options` record of `jco:node/http@0.1.0`. */ +export type DirectTlsOptions = HttpTlsMaterial; + export interface DirectHttpServerOptions { requestTimeout?: number; headersTimeout?: number; @@ -227,6 +309,7 @@ export interface DirectHttpServerOptions { keepAliveInitialDelay?: number; rejectNonStandardBodyWrites?: boolean; optimizeEmptyRequests?: boolean; + tls?: DirectTlsOptions; } export interface DirectHttpListenOptions { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts index 61aadbed3..4b448d758 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/frames.ts @@ -1,4 +1,4 @@ -import type { WasiInputStream, WasiOutputStream } from "../../http/impl/wasi-sockets.js"; +import type { WasiInputStream, WasiOutputStream } from "../../internal/wasi-sockets.js"; export const FRAME = { data: 0, diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts index 1648be640..9c6b693a7 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/client.ts @@ -5,7 +5,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../internal/wasi-sockets.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts index cbd68735c..5f7536167 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.ts @@ -1,4 +1,4 @@ -import type { WasiSocketsProvider } from "../../../http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../internal/wasi-sockets.js"; import type { Http2Implementation } from "../../types.js"; import { createWasiSocketsHttp2Client } from "./client.js"; import { createWasiSocketsHttp2Server } from "./server.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts index 00aa2f4c8..0cd6ecede 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/server.ts @@ -1,7 +1,8 @@ import { + accept, + bind, dispose, - finishPending, - localAddress, + listen as listenTcp, nodeAddress, socketError, wasiU64, @@ -10,7 +11,7 @@ import { type WasiOutputStream, type WasiSocketsProvider, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../internal/wasi-sockets.js"; import { unsupported } from "../../errors.js"; import { getDefaultSettings, validateSettings } from "../../settings.js"; import type { @@ -93,41 +94,24 @@ class Http2Server implements Http2ServerImplementation { if (options.path !== undefined) { unsupported("http2.Server.listen path", "wasi:sockets does not expose Unix domain sockets"); } - const address = localAddress(options.host ?? "::", options.port ?? 0); - const network = this.provider.instanceNetwork.instanceNetwork(); - const socket = this.provider.tcpCreateSocket.createTcpSocket(address.tag); - if ( - !socket.startBind || - !socket.finishBind || - !socket.startListen || - !socket.finishListen || - !socket.accept || - !socket.localAddress - ) { - dispose(socket); - dispose(network); - unsupported( - "http2.Server", - "the supplied wasi:sockets provider does not expose TCP server operations", - ); - } + const bound = bind( + this.provider, + options.host ?? "::", + options.port ?? 0, + options.backlog, + "http2.Server.listen", + ); try { - if (options.backlog !== undefined) { - socket.setListenBacklogSize?.(wasiU64(this.provider, options.backlog)); - } - socket.startBind(network, address); - finishPending(() => socket.finishBind!(), socket); - socket.startListen(); - finishPending(() => socket.finishListen!(), socket); - this.#network = network; - this.#socket = socket; - this.#address = nodeAddress(socket.localAddress()); + listenTcp(bound.socket); + this.#network = bound.network; + this.#socket = bound.socket; + this.#address = bound.address; this.#listening = true; this.#scheduleAccept(); return this.#address; } catch (error) { - dispose(socket); - dispose(network); + dispose(bound.socket); + dispose(bound.network); throw socketError(error, "listen", options.host); } } @@ -173,28 +157,7 @@ class Http2Server implements Http2ServerImplementation { return; } try { - let accepted: [WasiTcpSocket, WasiInputStream, WasiOutputStream]; - for (;;) { - try { - accepted = listener.accept(); - break; - } catch (error) { - const isWouldBlock = - typeof error === "object" && - error !== null && - "tag" in error && - error.tag === "would-block"; - if (!isWouldBlock) { - throw error; - } - const pollable = listener.subscribe(); - try { - pollable.block(); - } finally { - dispose(pollable); - } - } - } + const accepted = accept(listener); const connection = { socket: accepted[0], input: accepted[1], output: accepted[2] }; this.#connections.add(connection); this.#schedule(async () => { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts index 0e8598f57..193a356af 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/shared.ts @@ -3,7 +3,7 @@ import { type WasiInputStream, type WasiOutputStream, type WasiTcpSocket, -} from "../../../http/impl/wasi-sockets.js"; +} from "../../../internal/wasi-sockets.js"; import type { Http2Settings, HttpHeaderField } from "../../types.js"; import { encodeFrame, FLAG, FRAME, type Http2Frame } from "../frames.js"; import { encodeHeaders } from "../hpack.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/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/internal/bytes.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts new file mode 100644 index 000000000..0d62eb2ad --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/bytes.ts @@ -0,0 +1,37 @@ +import { invalidArgType, invalidArgValue } from "../errors/core.js"; + +export type ByteChunk = string | ArrayBuffer | ArrayBufferView; + +/** Convert the byte-like chunk accepted by Node stream and protocol APIs. */ +export function bodyBytes(value: ByteChunk, encoding = "utf8"): Uint8Array { + if (typeof value === "string") { + const normalized = encoding.toLowerCase().replace("-", ""); + if (normalized === "utf8" || normalized === "utf") { + return new TextEncoder().encode(value); + } + if (normalized === "latin1" || normalized === "binary" || normalized === "ascii") { + return Uint8Array.from(value, (character) => character.charCodeAt(0) & 0xff); + } + throw invalidArgValue("encoding", encoding); + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), + ); + } + throw invalidArgType("chunk", "string, Buffer, TypedArray, or DataView", value); +} + +export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts new file mode 100644 index 000000000..751db2e66 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.ts @@ -0,0 +1,516 @@ +/** + * Shared Preview 2 TCP transport for the Node-compatible net, HTTP/1, and HTTP/2 shims. + * + * The state transitions follow the WASI Preview 2 sockets specification. Node-facing address + * and error shapes follow nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). Local changes replace + * libuv handles with typed WASI resources and complete would-block operations through pollables. + */ + +import { codedError, invalidArgValue, unsupportedNodeApi } from "../errors/core.js"; +import { canonicalIpv6, isIP, parseIpv4, parseIpv6 } from "../net/ip.js"; + +export type WasiIpAddress = + | { tag: "ipv4"; val: [number, number, number, number] } + | { tag: "ipv6"; val: [number, number, number, number, number, number, number, number] }; + +export type WasiIpSocketAddress = + | { tag: "ipv4"; val: { port: number; address: [number, number, number, number] } } + | { + tag: "ipv6"; + val: { + port: number; + flowInfo: number; + address: [number, number, number, number, number, number, number, number]; + scopeId: number; + }; + }; + +export interface NodeTcpAddress { + address: string; + family: "IPv4" | "IPv6"; + port: number; +} + +export interface WasiPollable { + block(): void; + [Symbol.dispose]?(): void; +} + +export interface WasiInputStream { + blockingRead(length: bigint): Uint8Array; + [Symbol.dispose]?(): void; +} + +export interface WasiOutputStream { + blockingWriteAndFlush(contents: Uint8Array): void; + [Symbol.dispose]?(): void; +} + +export interface WasiResolveAddressStream { + resolveNextAddress(): WasiIpAddress | undefined; + subscribe(): WasiPollable; + [Symbol.dispose]?(): void; +} + +export interface WasiTcpSocket { + startBind?(network: WasiNetwork, localAddress: WasiIpSocketAddress): void; + finishBind?(): void; + startConnect(network: WasiNetwork, remoteAddress: WasiIpSocketAddress): void; + finishConnect(): [WasiInputStream, WasiOutputStream]; + startListen?(): void; + finishListen?(): void; + accept?(): [WasiTcpSocket, WasiInputStream, WasiOutputStream]; + localAddress?(): WasiIpSocketAddress; + remoteAddress?(): WasiIpSocketAddress; + setListenBacklogSize?(value: bigint): void; + setKeepAliveEnabled?(value: boolean): void; + setKeepAliveIdleTime?(value: bigint): void; + setKeepAliveInterval?(value: bigint): void; + setKeepAliveCount?(value: number): void; + subscribe(): WasiPollable; + shutdown(direction: "receive" | "send" | "both"): void; + [Symbol.dispose]?(): void; +} + +export interface WasiNetwork { + [Symbol.dispose]?(): void; +} + +export interface WasiSocketsProvider { + instanceNetwork: { + instanceNetwork(): WasiNetwork; + }; + ipNameLookup: { + resolveAddresses(network: WasiNetwork, name: string): WasiResolveAddressStream; + }; + tcpCreateSocket: { + createTcpSocket(family: "ipv4" | "ipv6"): WasiTcpSocket; + }; + /** Convert a safe integer to the component engine's WIT u64 representation. */ + u64?: (value: number) => bigint; + schedule?: (task: () => void | Promise) => void; +} + +export interface ConnectedTcpSocket { + socket: WasiTcpSocket; + input: WasiInputStream; + output: WasiOutputStream; + localAddress?: NodeTcpAddress; + remoteAddress?: NodeTcpAddress; + attemptedAddresses: string[]; +} + +export interface TcpConnectOptions { + family?: 0 | 4 | 6; + localAddress?: string; + localPort?: number; + allowAddress?: (address: string, family: 4 | 6) => boolean; + onLookup?: (address: string, family: 4 | 6, hostname: string) => void; + onAttempt?: (address: string, port: number, family: 4 | 6) => void; + socket?: WasiTcpSocket; + network?: WasiNetwork; +} + +export function wasiU64(provider: WasiSocketsProvider, value: number): bigint { + return provider.u64?.(value) ?? BigInt(value); +} + +export function dispose(resource: { [Symbol.dispose]?(): void } | undefined): void { + resource?.[Symbol.dispose]?.(); +} + +export function schedule(provider: WasiSocketsProvider, task: () => void | Promise): void { + if (provider.schedule) { + provider.schedule(task); + } else { + queueMicrotask(() => void task()); + } +} + +export function errorCode(error: unknown): string | undefined { + // ComponentizeJS wraps WIT result errors in an Error with a payload; direct + // providers and QuickJS expose the result's error value itself. + if (typeof error === "object" && error !== null && "payload" in error) { + error = error.payload; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error !== null && "tag" in error) { + const tag = (error as { tag?: unknown }).tag; + return typeof tag === "string" ? tag : undefined; + } + return undefined; +} + +export function socketError( + error: unknown, + syscall: string, + hostname?: string, + address?: string, + port?: number, +): Error & { + code: string; + syscall: string; + hostname?: string; + address?: string; + port?: number; +} { + const wasiCode = errorCode(error) ?? "unknown"; + const nodeCodes: Readonly> = { + "access-denied": "EACCES", + "address-in-use": "EADDRINUSE", + "connection-aborted": "ECONNABORTED", + "connection-refused": "ECONNREFUSED", + "connection-reset": "ECONNRESET", + "name-unresolvable": "ENOTFOUND", + "remote-unreachable": "EHOSTUNREACH", + timeout: "ETIMEDOUT", + }; + const code = nodeCodes[wasiCode] ?? "ERR_JCO_WASI_SOCKET"; + const target = address ?? hostname; + const message = `${syscall} ${code}${target ? ` ${target}` : ""}${port === undefined ? "" : `:${port}`}`; + return Object.assign(codedError(new Error(message), code), { + syscall, + hostname, + address, + port, + }); +} + +export function ipSocketAddress(address: string, port: number): WasiIpSocketAddress { + const family = isIP(address); + if (family === 4) { + return { + tag: "ipv4", + val: { + address: Array.from(parseIpv4(address)!) as [number, number, number, number], + port, + }, + }; + } + if (family === 6) { + const bytes = parseIpv6(address)!; + const parts = Array.from( + { length: 8 }, + (_, index) => (bytes[index * 2] << 8) | bytes[index * 2 + 1], + ) as [number, number, number, number, number, number, number, number]; + return { + tag: "ipv6", + val: { address: parts, port, flowInfo: 0, scopeId: 0 }, + }; + } + throw invalidArgValue("address", address, "must be a valid IP address"); +} + +export function localAddress( + host: string, + port: number, + api = "Server.listen host", +): WasiIpSocketAddress { + const normalized = host === "localhost" ? "127.0.0.1" : host; + try { + return ipSocketAddress(normalized, port); + } catch { + throw unsupportedNodeApi( + api, + "wasi:sockets Preview 2 accepts localhost or numeric IPv4 and IPv6 listen addresses", + ); + } +} + +export function nodeAddress(address: WasiIpSocketAddress): NodeTcpAddress { + if (address.tag === "ipv4") { + return { + address: address.val.address.join("."), + family: "IPv4", + port: address.val.port, + }; + } + const bytes = new Uint8Array(16); + address.val.address.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return { + address: canonicalIpv6(bytes), + family: "IPv6", + port: address.val.port, + }; +} + +export function finishPending(operation: () => void, socket: WasiTcpSocket): void { + for (;;) { + try { + operation(); + return; + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +function nextAddress(stream: WasiResolveAddressStream): WasiIpAddress | undefined { + for (;;) { + try { + return stream.resolveNextAddress(); + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = stream.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +function addressText(address: WasiIpAddress): string { + if (address.tag === "ipv4") { + return address.val.join("."); + } + const bytes = new Uint8Array(16); + address.val.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return canonicalIpv6(bytes); +} + +function remoteAddress(address: WasiIpAddress, port: number): WasiIpSocketAddress { + return address.tag === "ipv4" + ? { tag: "ipv4", val: { address: address.val, port } } + : { + tag: "ipv6", + val: { address: address.val, port, flowInfo: 0, scopeId: 0 }, + }; +} + +function literalAddress(hostname: string): WasiIpAddress | undefined { + const value = isIP(hostname); + const socketAddress = value === 0 ? undefined : ipSocketAddress(hostname, 0); + return socketAddress?.tag === "ipv4" + ? { tag: "ipv4", val: socketAddress.val.address } + : socketAddress?.tag === "ipv6" + ? { tag: "ipv6", val: socketAddress.val.address } + : undefined; +} + +/** Resolve and connect a TCP socket, trying WASI-provided addresses in order. */ +export function connect( + provider: WasiSocketsProvider, + hostname: string, + port: number, + options: TcpConnectOptions = {}, +): ConnectedTcpSocket { + const network = options.network ?? provider.instanceNetwork.instanceNetwork(); + const ownsNetwork = options.network === undefined; + const literal = literalAddress(hostname); + let addresses: WasiResolveAddressStream | undefined; + if (!literal) { + try { + addresses = provider.ipNameLookup.resolveAddresses(network, hostname); + } catch (error) { + if (ownsNetwork) { + dispose(network); + } + if (options.socket) { + dispose(options.socket); + } + throw socketError(error, "getaddrinfo", hostname); + } + } + const attemptedAddresses: string[] = []; + let literalConsumed = false; + let connected = false; + try { + let lastError: unknown; + for (;;) { + let address: WasiIpAddress | undefined; + try { + address = literal && !literalConsumed ? literal : addresses && nextAddress(addresses); + literalConsumed = true; + } catch (error) { + throw socketError(error, "getaddrinfo", hostname); + } + if (!address) { + throw socketError(lastError ?? "name-unresolvable", "connect", hostname, undefined, port); + } + const family = address.tag === "ipv4" ? 4 : 6; + if ( + (options.family && options.family !== family) || + options.allowAddress?.(addressText(address), family) === false + ) { + continue; + } + const text = addressText(address); + if (!literal) { + options.onLookup?.(text, family, hostname); + } + options.onAttempt?.(text, port, family); + attemptedAddresses.push(family === 6 ? `[${text}]:${port}` : `${text}:${port}`); + let socket: WasiTcpSocket | undefined; + try { + socket = options.socket ?? provider.tcpCreateSocket.createTcpSocket(address.tag); + if (options.localAddress !== undefined || options.localPort !== undefined) { + if (!socket.startBind || !socket.finishBind) { + throw unsupportedNodeApi( + "net.Socket local bind", + "the supplied wasi:sockets provider does not expose TCP bind operations", + ); + } + const local = localAddress( + options.localAddress ?? (address.tag === "ipv4" ? "0.0.0.0" : "::"), + options.localPort ?? 0, + "net.Socket localAddress", + ); + socket.startBind(network, local); + finishPending(socket.finishBind.bind(socket), socket); + } + socket.startConnect(network, remoteAddress(address, port)); + for (;;) { + try { + const [input, output] = socket.finishConnect(); + const result: ConnectedTcpSocket = { + socket, + input, + output, + localAddress: socket.localAddress && nodeAddress(socket.localAddress()), + remoteAddress: socket.remoteAddress + ? nodeAddress(socket.remoteAddress()) + : { address: text, family: family === 4 ? "IPv4" : "IPv6", port }, + attemptedAddresses, + }; + connected = true; + return result; + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } + } catch (error) { + lastError = error; + if (options.socket) { + throw error; + } + dispose(socket); + } + } + } finally { + dispose(addresses); + if (ownsNetwork) { + dispose(network); + } + if (options.socket && !connected) { + dispose(options.socket); + } + } +} + +export interface BoundTcpSocket { + socket: WasiTcpSocket; + network: WasiNetwork; + address: NodeTcpAddress; +} + +/** Bind a TCP resource without choosing whether it will listen or connect. */ +export function bind( + provider: WasiSocketsProvider, + host: string, + port: number, + backlog?: number, + api = "net.Server.listen", +): BoundTcpSocket { + const local = localAddress(host, port, `${api} host`); + const network = provider.instanceNetwork.instanceNetwork(); + const socket = provider.tcpCreateSocket.createTcpSocket(local.tag); + if (!socket.startBind || !socket.finishBind || !socket.localAddress) { + dispose(socket); + dispose(network); + throw unsupportedNodeApi( + api, + "the supplied wasi:sockets provider does not expose TCP bind operations", + ); + } + try { + if (backlog !== undefined) { + socket.setListenBacklogSize?.(wasiU64(provider, backlog)); + } + socket.startBind(network, local); + finishPending(socket.finishBind.bind(socket), socket); + return { socket, network, address: nodeAddress(socket.localAddress()) }; + } catch (error) { + dispose(socket); + dispose(network); + throw socketError(error, "listen", undefined, host, port); + } +} + +export function listen(socket: WasiTcpSocket): void { + if (!socket.startListen || !socket.finishListen || !socket.accept) { + throw unsupportedNodeApi( + "net.Server.listen", + "the supplied wasi:sockets provider does not expose TCP server operations", + ); + } + socket.startListen(); + finishPending(() => socket.finishListen!(), socket); +} + +export function accept(socket: WasiTcpSocket): [WasiTcpSocket, WasiInputStream, WasiOutputStream] { + if (!socket.accept) { + throw unsupportedNodeApi( + "net.Server", + "the supplied wasi:sockets provider does not expose TCP accept operations", + ); + } + for (;;) { + try { + return socket.accept(); + } catch (error) { + if (errorCode(error) !== "would-block") { + throw error; + } + const pollable = socket.subscribe(); + try { + pollable.block(); + } finally { + dispose(pollable); + } + } + } +} + +export function closeTransport( + socket: WasiTcpSocket, + input?: WasiInputStream, + output?: WasiOutputStream, +): void { + try { + socket.shutdown("both"); + } catch { + // The peer may already have closed the connection. + } + dispose(output); + dispose(input); + dispose(socket); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts new file mode 100644 index 000000000..58ff35a10 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net-interface.d.ts @@ -0,0 +1,11 @@ +declare module "wasi:sockets/instance-network@0.2.12" { + export const instanceNetwork: import("./internal/wasi-sockets.js").WasiSocketsProvider["instanceNetwork"]["instanceNetwork"]; +} + +declare module "wasi:sockets/ip-name-lookup@0.2.12" { + export const resolveAddresses: import("./internal/wasi-sockets.js").WasiSocketsProvider["ipNameLookup"]["resolveAddresses"]; +} + +declare module "wasi:sockets/tcp-create-socket@0.2.12" { + export const createTcpSocket: import("./internal/wasi-sockets.js").WasiSocketsProvider["tcpCreateSocket"]["createTcpSocket"]; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts new file mode 100644 index 000000000..b7a64f7c2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net.ts @@ -0,0 +1,36 @@ +import * as instanceNetwork from "wasi:sockets/instance-network@0.2.12"; +import * as ipNameLookup from "wasi:sockets/ip-name-lookup@0.2.12"; +import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@0.2.12"; + +import { createNet } from "./net/core.js"; + +const net = createNet({ instanceNetwork, ipNameLookup, tcpCreateSocket }); + +export type * from "./net/types.js"; +export type Socket = import("./net/socket.js").SocketBase; +export type Server = import("./net/server.js").ServerBase; +export type BoundSocket = import("./net/bound-socket.js").BoundSocketBase; +export type SocketAddress = import("./net/socket-address.js").SocketAddress; +export type BlockList = import("./net/block-list.js").BlockList; +export const { + BlockList, + BoundSocket, + Server, + Socket, + SocketAddress, + Stream, + _createServerHandle, + _normalizeArgs, + connect, + createConnection, + createServer, + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + isIP, + isIPv4, + isIPv6, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, +} = net; + +export default net; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts new file mode 100644 index 000000000..a7a8e7aa1 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/block-list.ts @@ -0,0 +1,201 @@ +/** + * Portable `net.BlockList`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/blocklist.js (MIT license). + * The native `block_list` handle is replaced by JavaScript byte/range comparisons while the + * public validation, newest-first rule ordering, JSON format, and IPv4-mapped behavior remain. + */ + +import { invalidArgType, invalidArgValue, outOfRange } from "./errors.js"; +import { mappedIpv4Bytes } from "./ip.js"; +import { SocketAddress, socketAddressDetails, type IPVersion } from "./socket-address.js"; + +type Rule = + | { kind: "address"; address: SocketAddress; text: string } + | { kind: "range"; start: SocketAddress; end: SocketAddress; text: string } + | { kind: "subnet"; network: SocketAddress; prefix: number; text: string }; + +function address(value: string | SocketAddress, family: IPVersion, name: string): SocketAddress { + if (SocketAddress.isSocketAddress(value)) { + return value; + } + if (typeof value !== "string") { + throw invalidArgType(name, "string", value); + } + const normalizedFamily = typeof family === "string" ? family.toLowerCase() : family; + if (normalizedFamily !== "ipv4" && normalizedFamily !== "ipv6") { + throw invalidArgValue("family", family); + } + return new SocketAddress({ address: value, family: normalizedFamily }); +} + +function numeric(value: SocketAddress): bigint { + let result = 0n; + for (const byte of socketAddressDetails(value).bytes) { + result = (result << 8n) | BigInt(byte); + } + return result; +} + +function sameFamily(first: SocketAddress, second: SocketAddress): void { + if (first.family !== second.family) { + throw invalidArgValue("family", second.family, "must match the first address family"); + } +} + +function matches(rule: Rule, candidate: SocketAddress): boolean { + if (rule.kind === "address") { + return rule.address.family === candidate.family && numeric(rule.address) === numeric(candidate); + } + if (rule.kind === "range") { + if (rule.start.family !== candidate.family) { + return false; + } + const value = numeric(candidate); + return value >= numeric(rule.start) && value <= numeric(rule.end); + } + if (rule.network.family !== candidate.family) { + return false; + } + const bits = candidate.family === "ipv4" ? 32 : 128; + const shift = BigInt(bits - rule.prefix); + return numeric(candidate) >> shift === numeric(rule.network) >> shift; +} + +export class BlockList { + readonly #rules: Rule[] = []; + + static isBlockList(value: unknown): value is BlockList { + return value instanceof BlockList; + } + + addAddress(value: string | SocketAddress, family: IPVersion = "ipv4"): void { + const parsed = address(value, family, "address"); + this.#rules.unshift({ + kind: "address", + address: parsed, + text: `Address: ${parsed.family === "ipv4" ? "IPv4" : "IPv6"} ${parsed.address}`, + }); + } + + addRange( + startValue: string | SocketAddress, + endValue: string | SocketAddress, + family: IPVersion = "ipv4", + ): void { + const start = address(startValue, family, "start"); + const end = address(endValue, family, "end"); + sameFamily(start, end); + if (numeric(start) > numeric(end)) { + throw invalidArgValue("start", start, "must come before end"); + } + this.#rules.unshift({ + kind: "range", + start, + end, + text: `Range: ${start.family === "ipv4" ? "IPv4" : "IPv6"} ${start.address}-${end.address}`, + }); + } + + addSubnet( + networkValue: string | SocketAddress, + prefix: number, + family: IPVersion = "ipv4", + ): void { + const network = address(networkValue, family, "network"); + const maximum = network.family === "ipv4" ? 32 : 128; + if (typeof prefix !== "number") { + throw invalidArgType("prefix", "number", prefix); + } + if (!Number.isInteger(prefix) || prefix < 0 || prefix > maximum) { + throw outOfRange("prefix", `>= 0 and <= ${maximum}`, prefix); + } + this.#rules.unshift({ + kind: "subnet", + network, + prefix: Object.is(prefix, -0) ? 0 : prefix, + text: `Subnet: ${network.family === "ipv4" ? "IPv4" : "IPv6"} ${network.address}/${prefix}`, + }); + } + + check(value: string | SocketAddress, family: IPVersion = "ipv4"): boolean { + if (!SocketAddress.isSocketAddress(value) && typeof value !== "string") { + throw invalidArgType("address", "string", value); + } + let candidate: SocketAddress; + try { + candidate = address(value, family, "address"); + } catch { + return false; + } + if (this.#rules.some((rule) => matches(rule, candidate))) { + return true; + } + if (candidate.family === "ipv4") { + const mapped = new SocketAddress({ address: `::ffff:${candidate.address}`, family: "ipv6" }); + return this.#rules.some((rule) => matches(rule, mapped)); + } + if (candidate.family === "ipv6") { + const mapped = mappedIpv4Bytes(socketAddressDetails(candidate).bytes); + if (mapped) { + const ipv4 = new SocketAddress({ address: Array.from(mapped).join("."), family: "ipv4" }); + return this.#rules.some((rule) => matches(rule, ipv4)); + } + } + return false; + } + + get rules(): readonly string[] { + return this.#rules.map((rule) => rule.text); + } + + toJSON(): readonly string[] { + return this.rules; + } + + fromJSON(data: string | readonly string[]): void { + let rules: unknown = data; + if (typeof rules === "string") { + rules = JSON.parse(rules) as unknown; + } + if (!Array.isArray(rules) || !rules.every((rule) => typeof rule === "string")) { + throw invalidArgType("data", ["string", "string[]"], data); + } + for (const rule of rules) { + if (rule.includes("IPv4")) { + let match = /Subnet: IPv4 (\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})/.exec(rule); + if (match) { + this.addSubnet(match[1], Number.parseInt(match[2])); + continue; + } + match = /Address: IPv4 (\d{1,3}(?:\.\d{1,3}){3})/.exec(rule); + if (match) { + this.addAddress(match[1]); + continue; + } + match = /Range: IPv4 (\d{1,3}(?:\.\d{1,3}){3})-(\d{1,3}(?:\.\d{1,3}){3})/.exec(rule); + if (match) { + this.addRange(match[1], match[2]); + continue; + } + } + if (rule.includes("IPv6")) { + let match = /Subnet: IPv6 ([0-9a-fA-F:]{1,39})\/([0-9]{1,3})/i.exec(rule); + if (match) { + this.addSubnet(match[1], Number.parseInt(match[2]), "ipv6"); + continue; + } + match = /Address: IPv6 ([0-9a-fA-F:]{1,39})/i.exec(rule); + if (match) { + this.addAddress(match[1], "ipv6"); + continue; + } + match = /Range: IPv6 ([0-9a-fA-F:]{1,39})-([0-9a-fA-F:]{1,39})/i.exec(rule); + if (match) { + this.addRange(match[1], match[2], "ipv6"); + } + } + } + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts new file mode 100644 index 000000000..e711e3299 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/bound-socket.ts @@ -0,0 +1,115 @@ +/** + * Preview 2-backed `net.BoundSocket`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js `BoundSocket` (MIT + * license). The libuv handle is a WASI TCP resource and `fd()` returns `-1` because components do + * not receive an operating-system descriptor. + */ + +import { + bind, + closeTransport, + dispose, + type BoundTcpSocket, + type NodeTcpAddress, + type WasiSocketsProvider, +} from "../internal/wasi-sockets.js"; +import { invalidArgType, invalidArgValue, socketHandleAdopted, unsupported } from "./errors.js"; +import { isIP } from "./ip.js"; +import { validatePort } from "./socket-address.js"; + +export interface BoundSocketOptions { + port?: number | string; + host?: string | null; + ipv6Only?: boolean; + reusePort?: boolean; +} + +export const consumeBoundSocket = Symbol("consumeBoundSocket"); + +export class BoundSocketBase { + #bound: BoundTcpSocket | undefined; + + constructor(provider: WasiSocketsProvider, options: BoundSocketOptions = {}) { + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + if (options.ipv6Only !== undefined && typeof options.ipv6Only !== "boolean") { + throw invalidArgType("options.ipv6Only", "boolean", options.ipv6Only); + } + if (options.reusePort !== undefined && typeof options.reusePort !== "boolean") { + throw invalidArgType("options.reusePort", "boolean", options.reusePort); + } + if (options.ipv6Only || options.reusePort) { + unsupported( + "net.BoundSocket ipv6Only/reusePort", + "wasi:sockets Preview 2 does not expose these bind flags", + ); + } + const host = options.host ?? (options.ipv6Only ? "::" : "0.0.0.0"); + if (typeof host !== "string") { + throw invalidArgType("options.host", "string", host); + } + if (isIP(host) === 0) { + throw invalidArgValue( + "options.host", + host, + "must be a numeric IP address; net.BoundSocket does not perform DNS resolution", + ); + } + this.#bound = bind(provider, host, validatePort(options.port ?? 0)); + } + + address(): NodeTcpAddress { + return { ...this.#get().address }; + } + + fd(): number { + this.#get(); + return -1; + } + + close(): void { + const bound = this.#get(); + dispose(bound.socket); + dispose(bound.network); + this.#bound = undefined; + } + + [Symbol.dispose](): void { + if (this.#bound) { + closeTransport(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + } + } + + [consumeBoundSocket](): BoundTcpSocket { + const bound = this.#get(); + this.#bound = undefined; + return bound; + } + + #get(): BoundTcpSocket { + if (!this.#bound) { + throw socketHandleAdopted(); + } + return this.#bound; + } +} + +export interface BoundSocketConstructor { + new (options?: BoundSocketOptions): BoundSocketBase; + readonly prototype: BoundSocketBase; +} + +export function createBoundSocketConstructor( + provider: WasiSocketsProvider, +): BoundSocketConstructor { + return class BoundSocket extends BoundSocketBase { + constructor(options?: BoundSocketOptions) { + super(provider, options); + } + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts new file mode 100644 index 000000000..bdd965aa3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/core.ts @@ -0,0 +1,110 @@ +/** + * Portable core for `node:net`. + * + * The export surface, aliases, factories, and overload normalization follow nodejs/node v24.19.0, + * commit cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). + */ + +import type { WasiSocketsProvider } from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { createBoundSocketConstructor, type BoundSocketConstructor } from "./bound-socket.js"; +import { + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, +} from "./defaults.js"; +import { unsupported } from "./errors.js"; +import { isIP, isIPv4, isIPv6 } from "./ip.js"; +import { normalizeArgs, type NormalizedArgs } from "./normalize.js"; +import { + createServerConstructor, + type ConnectionListener, + type ServerBase, + type ServerConstructor, +} from "./server.js"; +import { createSocketConstructor, type SocketBase, type SocketConstructor } from "./socket.js"; +import { SocketAddress } from "./socket-address.js"; +import type { NetCallback, ServerOptions, SocketConnectOptions } from "./types.js"; + +export interface CreateConnection { + (options: SocketConnectOptions, connectionListener?: NetCallback): SocketBase; + (port: number, host?: string | NetCallback, connectionListener?: NetCallback): SocketBase; + (path: string, connectionListener?: NetCallback): SocketBase; +} + +export interface CreateServer { + (connectionListener?: ConnectionListener): ServerBase; + (options?: ServerOptions | null, connectionListener?: ConnectionListener): ServerBase; +} + +export interface NodeNetModule { + BlockList: typeof BlockList; + BoundSocket: BoundSocketConstructor; + Server: ServerConstructor; + Socket: SocketConstructor; + SocketAddress: typeof SocketAddress; + Stream: SocketConstructor; + _createServerHandle: (...args: unknown[]) => never; + _normalizeArgs: (args: readonly unknown[]) => NormalizedArgs; + connect: CreateConnection; + createConnection: CreateConnection; + createServer: CreateServer; + getDefaultAutoSelectFamily: typeof getDefaultAutoSelectFamily; + getDefaultAutoSelectFamilyAttemptTimeout: typeof getDefaultAutoSelectFamilyAttemptTimeout; + isIP: typeof isIP; + isIPv4: typeof isIPv4; + isIPv6: typeof isIPv6; + setDefaultAutoSelectFamily: typeof setDefaultAutoSelectFamily; + setDefaultAutoSelectFamilyAttemptTimeout: typeof setDefaultAutoSelectFamilyAttemptTimeout; +} + +export function createNet(provider: WasiSocketsProvider): NodeNetModule { + const BoundSocket = createBoundSocketConstructor(provider); + const Socket = createSocketConstructor(provider); + const Server = createServerConstructor(provider, Socket); + + const createConnection = ((...args: unknown[]): SocketBase => { + const normalized = normalizeArgs(args); + const socket = new Socket(normalized[0] as unknown as SocketConnectOptions); + return (socket.connect as (...values: unknown[]) => SocketBase).call(socket, normalized); + }) as CreateConnection; + const connect = createConnection; + + const createServer = (( + optionsOrListener?: ServerOptions | ConnectionListener | null, + listener?: ConnectionListener, + ): ServerBase => new Server(optionsOrListener, listener)) as CreateServer; + + function _createServerHandle(..._args: unknown[]): never { + return unsupported( + "net._createServerHandle", + "components cannot create or expose Node/libuv server handles", + ); + } + + function _normalizeArgs(args: readonly unknown[]): NormalizedArgs { + return normalizeArgs(args); + } + + return { + BlockList, + BoundSocket, + Server, + Socket, + SocketAddress, + Stream: Socket, + _createServerHandle, + _normalizeArgs, + connect, + createConnection, + createServer, + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + isIP, + isIPv4, + isIPv6, + setDefaultAutoSelectFamily, + setDefaultAutoSelectFamilyAttemptTimeout, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts new file mode 100644 index 000000000..dc36b37fa --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/defaults.ts @@ -0,0 +1,32 @@ +/** Module-local defaults from nodejs/node v24.19.0 `lib/net.js`, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae (MIT license). Native flags become local state. */ + +import { invalidArgType, outOfRange } from "./errors.js"; + +let autoSelectFamily = true; +let autoSelectFamilyAttemptTimeout = 250; + +export function getDefaultAutoSelectFamily(): boolean { + return autoSelectFamily; +} + +export function setDefaultAutoSelectFamily(value: boolean): void { + if (typeof value !== "boolean") { + throw invalidArgType("value", "boolean", value); + } + autoSelectFamily = value; +} + +export function getDefaultAutoSelectFamilyAttemptTimeout(): number { + return autoSelectFamilyAttemptTimeout; +} + +export function setDefaultAutoSelectFamilyAttemptTimeout(value: number): void { + if (typeof value !== "number") { + throw invalidArgType("value", "number", value); + } + if (!Number.isInteger(value) || value < 1 || value > 0x7fff_ffff) { + throw outOfRange("value", "an integer >= 1 and <= 2147483647", value); + } + autoSelectFamilyAttemptTimeout = Math.max(value, 10); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts new file mode 100644 index 000000000..4370e3cf7 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/errors.ts @@ -0,0 +1,61 @@ +/** Node-style errors used by the portable `node:net` shim. */ + +import { + AbortError, + codedError, + deprecatedNodeApi, + invalidArgType, + invalidArgValue, + missingArgs, + outOfRange, + unsupportedNodeApi, +} from "../errors/core.js"; + +export { AbortError, invalidArgType, invalidArgValue, missingArgs, outOfRange }; + +export function invalidAddress(): Error & { code: "ERR_INVALID_ADDRESS" } { + return codedError(new Error("Invalid socket address"), "ERR_INVALID_ADDRESS"); +} + +export function serverAlreadyListening(): Error & { code: "ERR_SERVER_ALREADY_LISTEN" } { + return codedError( + new Error("Listen method has been called more than once without closing."), + "ERR_SERVER_ALREADY_LISTEN", + ); +} + +export function serverNotRunning(): Error & { code: "ERR_SERVER_NOT_RUNNING" } { + return codedError(new Error("Server is not running."), "ERR_SERVER_NOT_RUNNING"); +} + +export function socketClosed(): Error & { code: "ERR_SOCKET_CLOSED" } { + return codedError(new Error("Socket is closed"), "ERR_SOCKET_CLOSED"); +} + +export function socketClosedBeforeConnection(): Error & { + code: "ERR_SOCKET_CLOSED_BEFORE_CONNECTION"; +} { + return codedError( + new Error("Socket closed before the connection was established"), + "ERR_SOCKET_CLOSED_BEFORE_CONNECTION", + ); +} + +export function socketHandleAdopted(): Error & { code: "ERR_SOCKET_HANDLE_ADOPTED" } { + return codedError( + new Error("This socket handle has already been bound to another socket"), + "ERR_SOCKET_HANDLE_ADOPTED", + ); +} + +export function ipBlocked(address: string): Error & { code: "ERR_IP_BLOCKED" } { + return codedError(new Error(`IP ${address} is blocked`), "ERR_IP_BLOCKED"); +} + +export function unsupported(api: string, reason: string): never { + throw unsupportedNodeApi(api, reason); +} + +export function deprecated(api: string, replacement: string): never { + throw deprecatedNodeApi(api, replacement); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts new file mode 100644 index 000000000..f17ae0fcf --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/ip.ts @@ -0,0 +1,159 @@ +/** + * IP address predicates and portable parsing helpers. + * + * The regular expressions are mechanically adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/net.js (MIT license). Node calls the + * native predicates from `lib/net.js`; the local helpers additionally turn accepted addresses + * into bytes because a component has no `block_list` native binding. + */ + +const V4_SEGMENT = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"; +const V4_SOURCE = `(?:${V4_SEGMENT}\\.){3}${V4_SEGMENT}`; +const IPV4 = new RegExp(`^${V4_SOURCE}$`); +const V6_SEGMENT = "(?:[0-9a-fA-F]{1,4})"; +const IPV6 = new RegExp( + "^(?:" + + `(?:${V6_SEGMENT}:){7}(?:${V6_SEGMENT}|:)|` + + `(?:${V6_SEGMENT}:){6}(?:${V4_SOURCE}|:${V6_SEGMENT}|:)|` + + `(?:${V6_SEGMENT}:){5}(?::${V4_SOURCE}|(?::${V6_SEGMENT}){1,2}|:)|` + + `(?:${V6_SEGMENT}:){4}(?:(?::${V6_SEGMENT}){0,1}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,3}|:)|` + + `(?:${V6_SEGMENT}:){3}(?:(?::${V6_SEGMENT}){0,2}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,4}|:)|` + + `(?:${V6_SEGMENT}:){2}(?:(?::${V6_SEGMENT}){0,3}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,5}|:)|` + + `(?:${V6_SEGMENT}:){1}(?:(?::${V6_SEGMENT}){0,4}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,6}|:)|` + + `(?::(?:(?::${V6_SEGMENT}){0,5}:${V4_SOURCE}|(?::${V6_SEGMENT}){1,7}|:))` + + ")(?:%[0-9a-zA-Z-.:]{1,})?$", +); + +export type IpFamily = 4 | 6; + +export interface ParsedIpAddress { + family: IpFamily; + bytes: Uint8Array; + canonical: string; +} + +export function isIPv4(input: unknown): input is string { + return typeof input === "string" && IPV4.test(input); +} + +export function isIPv6(input: unknown): input is string { + return typeof input === "string" && IPV6.test(input); +} + +export function isIP(input: unknown): 0 | IpFamily { + return isIPv4(input) ? 4 : isIPv6(input) ? 6 : 0; +} + +export function parseIpv4(input: string): Uint8Array | undefined { + return isIPv4(input) ? Uint8Array.from(input.split(".").map(Number)) : undefined; +} + +function hextets(input: string): number[] | undefined { + let value = input; + const zone = value.indexOf("%"); + if (zone !== -1) { + value = value.slice(0, zone); + } + const ipv4Text = value.match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + if (ipv4Text) { + const ipv4 = parseIpv4(ipv4Text); + if (!ipv4) { + return undefined; + } + value = `${value.slice(0, -ipv4Text.length)}${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`; + } + const halves = value.split("::"); + if (halves.length > 2) { + return undefined; + } + const left = halves[0] ? halves[0].split(":") : []; + const right = halves[1] ? halves[1].split(":") : []; + if (halves.length === 1 ? left.length !== 8 : left.length + right.length > 7) { + return undefined; + } + const fill = new Array(8 - left.length - right.length).fill(0); + return [ + ...left.map((part) => Number.parseInt(part, 16)), + ...fill, + ...right.map((part) => Number.parseInt(part, 16)), + ]; +} + +export function parseIpv6(input: string): Uint8Array | undefined { + if (!isIPv6(input)) { + return undefined; + } + const parts = hextets(input); + if (!parts || parts.length !== 8) { + return undefined; + } + const bytes = new Uint8Array(16); + parts.forEach((part, index) => { + bytes[index * 2] = part >>> 8; + bytes[index * 2 + 1] = part & 0xff; + }); + return bytes; +} + +function mappedIpv4(bytes: Uint8Array): string | undefined { + if ( + bytes.length === 16 && + bytes.slice(0, 10).every((byte) => byte === 0) && + bytes[10] === 0xff && + bytes[11] === 0xff + ) { + return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`; + } + return undefined; +} + +export function canonicalIpv6(bytes: Uint8Array): string { + const mapped = mappedIpv4(bytes); + if (mapped) { + return `::ffff:${mapped}`; + } + const parts = Array.from({ length: 8 }, (_, index) => + ((bytes[index * 2] << 8) | bytes[index * 2 + 1]).toString(16), + ); + let bestStart = -1; + let bestLength = 0; + for (let start = 0; start < parts.length; ) { + if (parts[start] !== "0") { + start += 1; + continue; + } + let end = start + 1; + while (end < parts.length && parts[end] === "0") { + end += 1; + } + if (end - start > bestLength && end - start > 1) { + bestStart = start; + bestLength = end - start; + } + start = end; + } + if (bestStart === -1) { + return parts.join(":"); + } + return `${parts.slice(0, bestStart).join(":")}::${parts.slice(bestStart + bestLength).join(":")}`; +} + +export function parseIp(input: string, family?: "ipv4" | "ipv6"): ParsedIpAddress | undefined { + if (family !== "ipv6") { + const bytes = parseIpv4(input); + if (bytes) { + return { family: 4, bytes, canonical: Array.from(bytes).join(".") }; + } + } + if (family !== "ipv4") { + const bytes = parseIpv6(input); + if (bytes) { + return { family: 6, bytes, canonical: canonicalIpv6(bytes) }; + } + } + return undefined; +} + +export function mappedIpv4Bytes(bytes: Uint8Array): Uint8Array | undefined { + return mappedIpv4(bytes) === undefined ? undefined : bytes.slice(12); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts new file mode 100644 index 000000000..9844fefc2 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/normalize.ts @@ -0,0 +1,35 @@ +/** Overload normalization adapted from nodejs/node v24.19.0 `lib/net.js`, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae (MIT license). Uses a local normalization symbol. */ + +export const normalizedArgsSymbol = Symbol("normalizedArgs"); + +export type NormalizedArgs = [ + options: Record, + callback: ((...args: unknown[]) => void) | null, +] & { + [normalizedArgsSymbol]: true; +}; + +function isPipeName(value: unknown): value is string { + return typeof value === "string" && !(Number(value) >= 0); +} + +export function normalizeArgs(args: readonly unknown[]): NormalizedArgs { + let options: Record; + if (args.length === 0) { + options = {}; + } else if (typeof args[0] === "object" && args[0] !== null) { + options = args[0] as Record; + } else if (isPipeName(args[0])) { + options = { path: args[0] }; + } else { + options = { port: args[0] }; + if (typeof args[1] === "string") { + options.host = args[1]; + } + } + const last = args.at(-1); + const result = [options, typeof last === "function" ? last : null] as NormalizedArgs; + result[normalizedArgsSymbol] = true; + return result; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts new file mode 100644 index 000000000..ddf387939 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/server.ts @@ -0,0 +1,495 @@ +/** + * Preview 2-backed `net.Server`. + * + * Public lifecycle and connection-count behavior are adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). libuv listen handles are + * replaced by WASI TCP resources. + */ + +import { EventEmitter } from "../internal/event-emitter.js"; +import { + accept, + bind, + closeTransport, + dispose, + listen, + nodeAddress, + schedule, + socketError, + type BoundTcpSocket, + type NodeTcpAddress, + type WasiSocketsProvider, +} from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { BoundSocketBase, consumeBoundSocket } from "./bound-socket.js"; +import { + invalidArgType, + outOfRange, + serverAlreadyListening, + serverNotRunning, + unsupported, +} from "./errors.js"; +import { + attachAcceptedTransport, + startSocketReading, + type SocketBase, + type SocketConstructor, +} from "./socket.js"; +import { validatePort } from "./socket-address.js"; +import type { + AddressInfo, + DropArgument, + ListenOptions, + NetCallback, + ServerOptions, +} from "./types.js"; + +type Listener = (...args: never[]) => unknown; +type CloseCallback = (error?: Error) => void; +type ConnectionsCallback = (error: Error | null, count: number) => void; +export type ConnectionListener = (socket: SocketBase) => void; +export interface ServerEventMap { + close: []; + connection: [socket: SocketBase]; + listening: []; + error: [error: Error]; + drop: [data: DropArgument]; +} +const DEFAULT_HIGH_WATER_MARK = 65_536; + +function callbackFrom(values: readonly unknown[]): NetCallback | undefined { + const last = values.at(-1); + return typeof last === "function" ? (last as NetCallback) : undefined; +} + +function backlogFrom(values: readonly unknown[]): number | undefined { + for (const value of values.slice(1)) { + if (typeof value === "number") { + return value; + } + } + return undefined; +} + +function listenOptions(values: readonly unknown[]): ListenOptions | BoundSocketBase { + const first = values[0]; + if (first instanceof BoundSocketBase) { + return first; + } + if (first === undefined || typeof first === "function") { + return { port: 0 }; + } + if (typeof first === "object" && first !== null) { + return first as ListenOptions; + } + if (typeof first === "string" && !(Number(first) >= 0)) { + return { path: first }; + } + const options: ListenOptions = { port: validatePort(first, "options.port") }; + if (typeof values[1] === "string") { + options.host = values[1]; + } + options.backlog = backlogFrom(values); + return options; +} + +function validateBacklog(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw invalidArgType("options.backlog", "integer", value); + } + if (value < 0 || value > 0x7fff_ffff) { + throw outOfRange("options.backlog", ">= 0 and <= 2147483647", value); + } + return value; +} + +export class ServerBase extends EventEmitter { + readonly #provider: WasiSocketsProvider; + readonly #Socket: SocketConstructor; + #bound: BoundTcpSocket | undefined; + #listening = false; + #closing = false; + #connections = new Set(); + #address: NodeTcpAddress | undefined; + + readonly allowHalfOpen: boolean; + readonly pauseOnConnect: boolean; + readonly noDelay: boolean; + readonly keepAlive: boolean; + readonly keepAliveInitialDelay: number; + readonly highWaterMark: number; + declare readonly blockList: BlockList | undefined; + declare maxConnections: number | undefined; + declare dropMaxConnection: boolean | undefined; + + constructor( + provider: WasiSocketsProvider, + Socket: SocketConstructor, + options: ServerOptions | ConnectionListener | null = {}, + connectionListener?: ConnectionListener, + ) { + super(); + if (typeof options === "function") { + connectionListener = options; + options = {}; + } + if (options === null) { + options = {}; + } else if (typeof options !== "object") { + throw invalidArgType("options", "Object", options); + } + if (options.blockList !== undefined && !BlockList.isBlockList(options.blockList)) { + throw invalidArgType("options.blockList", "net.BlockList", options.blockList); + } + if ( + options.keepAliveInitialDelay !== undefined && + typeof options.keepAliveInitialDelay !== "number" + ) { + throw invalidArgType( + "options.keepAliveInitialDelay", + "number", + options.keepAliveInitialDelay, + ); + } + if (options.highWaterMark !== undefined && typeof options.highWaterMark !== "number") { + throw invalidArgType("options.highWaterMark", "number", options.highWaterMark); + } + this.#provider = provider; + this.#Socket = Socket; + this.allowHalfOpen = Boolean(options.allowHalfOpen); + this.pauseOnConnect = Boolean(options.pauseOnConnect); + this.noDelay = Boolean(options.noDelay); + this.keepAlive = Boolean(options.keepAlive); + this.keepAliveInitialDelay = ~~(Math.max(0, options.keepAliveInitialDelay ?? 0) / 1_000); + this.highWaterMark = + options.highWaterMark === undefined || options.highWaterMark < 0 + ? DEFAULT_HIGH_WATER_MARK + : options.highWaterMark; + if (options.blockList) { + this.blockList = options.blockList; + } + if (connectionListener) { + this.on("connection", connectionListener as Listener); + } + } + + get listening(): boolean { + return this.#listening; + } + + on( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + on(event: string, listener: Listener): this; + on(event: string, listener: Listener): this { + return super.on(event, listener); + } + + addListener( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + addListener(event: string, listener: Listener): this; + addListener(event: string, listener: Listener): this { + return this.on(event, listener); + } + + once( + event: K, + listener: (...args: ServerEventMap[K]) => unknown, + ): this; + once(event: string, listener: Listener): this; + once(event: string, listener: Listener): this { + return super.once(event, listener); + } + + listen(options?: ListenOptions | BoundSocketBase, listeningListener?: NetCallback): this; + listen(port?: number, hostname?: string | NetCallback, listeningListener?: NetCallback): this; + listen(path: string, listeningListener?: NetCallback): this; + listen(...args: unknown[]): this { + if (this.#listening || this.#bound) { + throw serverAlreadyListening(); + } + const callback = callbackFrom(args); + if (callback) { + this.once("listening", callback as Listener); + } + const rawOptions = listenOptions(args); + let boundSocket: BoundSocketBase | undefined; + let options: ListenOptions; + if (rawOptions instanceof BoundSocketBase) { + boundSocket = rawOptions; + options = {}; + } else { + options = rawOptions; + if (options.handle instanceof BoundSocketBase) { + boundSocket = options.handle; + } + } + if (options.path !== undefined) { + return unsupported( + "net.Server.listen path", + "wasi:sockets Preview 2 exposes IP sockets but not Unix-domain sockets or named pipes", + ); + } + if (options.handle !== undefined && !boundSocket) { + return unsupported( + "net.Server.listen handle", + "components can adopt only a net.BoundSocket, not a libuv or file-descriptor handle", + ); + } + if (options.exclusive !== undefined && typeof options.exclusive !== "boolean") { + throw invalidArgType("options.exclusive", "boolean", options.exclusive); + } + for (const name of ["ipv6Only", "reusePort"] as const) { + if (options[name] !== undefined && typeof options[name] !== "boolean") { + throw invalidArgType(`options.${name}`, "boolean", options[name]); + } + if (options[name]) { + return unsupported( + `net.Server.listen ${name}`, + "wasi:sockets Preview 2 does not expose this listen flag", + ); + } + } + const backlog = validateBacklog(options.backlog ?? backlogFrom(args)); + const port = validatePort(options.port ?? 0, "options.port"); + try { + this.#bound = + boundSocket?.[consumeBoundSocket]() ?? + bind(this.#provider, options.host ?? "::", port, backlog); + if (backlog !== undefined) { + this.#bound.socket.setListenBacklogSize?.(this.#provider.u64?.(backlog) ?? BigInt(backlog)); + } + listen(this.#bound.socket); + this.#address = this.#bound.address; + this.#listening = true; + this.#closing = false; + const signal = options.signal; + if (signal) { + const abort = () => this.close(); + if (signal.aborted) { + queueMicrotask(abort); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + } + queueMicrotask(() => { + if (!this.#listening) { + return; + } + this.emit("listening"); + this.#scheduleAccept(); + }); + } catch (error) { + this.#disposeListener(); + const failure = error instanceof Error ? error : socketError(error, "listen"); + queueMicrotask(() => this.emit("error", failure)); + } + return this; + } + + close(callback?: CloseCallback): this { + if (callback) { + if (this.#listening || this.#closing) { + this.once("close", callback as Listener); + } else { + queueMicrotask(() => callback(serverNotRunning())); + } + } + if (!this.#listening && !this.#closing) { + return this; + } + this.#listening = false; + this.#closing = true; + this.#disposeListener(); + this.#finishClose(); + return this; + } + + closeAllConnections(): void { + for (const socket of this.#connections) { + socket.destroy(); + } + } + + closeIdleConnections(): void { + // Raw TCP has no protocol-level notion of idle. Preserve established sockets. + } + + address(): AddressInfo | null { + return this.#address ? { ...this.#address } : null; + } + + getConnections(callback: ConnectionsCallback): void { + if (typeof callback !== "function") { + throw invalidArgType("callback", "Function", callback); + } + queueMicrotask(() => callback(null, this.#connections.size)); + } + + ref(): this { + return this; + } + + unref(): this { + return this; + } + + async [Symbol.asyncDispose](): Promise { + if (!this.#listening && !this.#closing) { + return; + } + await new Promise((resolve, reject) => { + this.close((error) => (error ? reject(error) : resolve())); + }); + } + + #scheduleAccept(): void { + if (!this.#listening) { + return; + } + schedule(this.#provider, () => this.#acceptOne()); + } + + #acceptOne(): void { + const listener = this.#bound?.socket; + if (!listener || !this.#listening) { + return; + } + try { + const [socketHandle, input, output] = accept(listener); + const local = socketHandle.localAddress + ? nodeAddress(socketHandle.localAddress()) + : this.#address; + const remote = socketHandle.remoteAddress + ? nodeAddress(socketHandle.remoteAddress()) + : undefined; + const drop = this.#dropReason(remote); + if (drop) { + closeTransport(socketHandle, input, output); + this.emit("drop", drop); + } else { + const socket = new this.#Socket({ + allowHalfOpen: this.allowHalfOpen, + noDelay: this.noDelay, + keepAlive: this.keepAlive, + keepAliveInitialDelay: this.keepAliveInitialDelay * 1_000, + }); + socket[attachAcceptedTransport]({ + socket: socketHandle, + input, + output, + localAddress: local, + remoteAddress: remote, + }); + socket.server = this; + socket._server = this; + this.#connections.add(socket); + socket.once("close", (() => { + this.#connections.delete(socket); + this.#finishClose(); + }) as Listener); + if (this.pauseOnConnect) { + socket.pause(); + } + this.emit("connection", socket); + if (!this.pauseOnConnect) { + socket[startSocketReading](); + } + } + } catch (error) { + if (this.#listening) { + const failure = error instanceof Error ? error : socketError(error, "accept"); + this.emit("error", failure); + } + } finally { + if (this.#listening) { + this.#scheduleAccept(); + } + } + } + + #dropReason(remote: NodeTcpAddress | undefined): DropArgument | undefined { + if ( + remote && + this.blockList?.check(remote.address, remote.family === "IPv4" ? "ipv4" : "ipv6") + ) { + return { + localAddress: this.#address?.address, + localPort: this.#address?.port, + localFamily: this.#address?.family, + remoteAddress: remote.address, + remotePort: remote.port, + remoteFamily: remote.family, + }; + } + if (this.maxConnections !== undefined && this.#connections.size >= this.maxConnections) { + return { + localAddress: this.#address?.address, + localPort: this.#address?.port, + localFamily: this.#address?.family, + remoteAddress: remote?.address, + remotePort: remote?.port, + remoteFamily: remote?.family, + }; + } + return undefined; + } + + #disposeListener(): void { + if (!this.#bound) { + return; + } + dispose(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + this.#address = undefined; + } + + #finishClose(): void { + if (!this.#closing || this.#connections.size !== 0) { + return; + } + this.#closing = false; + queueMicrotask(() => this.emit("close")); + } +} + +export interface ServerConstructor { + ( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ): ServerBase; + new ( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ): ServerBase; + readonly prototype: ServerBase; +} + +export function createServerConstructor( + provider: WasiSocketsProvider, + Socket: SocketConstructor, +): ServerConstructor { + class Server extends ServerBase { + constructor( + options?: ServerOptions | ConnectionListener | null, + connectionListener?: ConnectionListener, + ) { + super(provider, Socket, options, connectionListener); + } + } + return new Proxy(Server, { + apply(_target, _thisArgument, argumentsList) { + return new Server( + argumentsList[0] as ServerOptions | ConnectionListener | null | undefined, + argumentsList[1] as ConnectionListener | undefined, + ); + }, + }) as ServerConstructor; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts new file mode 100644 index 000000000..5c0b4723d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket-address.ts @@ -0,0 +1,142 @@ +/** + * Portable `net.SocketAddress`. + * + * Adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/internal/socketaddress.js (MIT license). + * The native `block_list` address handle is replaced by immutable validated JavaScript state. + */ + +import { codedError } from "../errors/core.js"; +import { invalidAddress, invalidArgType, invalidArgValue, outOfRange } from "./errors.js"; +import { parseIp, type IpFamily } from "./ip.js"; + +export type IPVersion = "ipv4" | "ipv6"; + +export interface SocketAddressInitOptions { + address?: string; + family?: IPVersion; + port?: number; + flowlabel?: number; +} + +export interface SocketAddressJson { + address: string; + port: number; + family: IPVersion; + flowlabel: number; +} + +export interface SocketAddressDetails { + familyNumber: IpFamily; + bytes: Uint8Array; +} + +const details = new WeakMap(); + +function validatePort(value: unknown, name = "options.port"): number { + if (typeof value !== "number" && typeof value !== "string") { + throw invalidArgType(name, ["number", "string"], value); + } + const port = typeof value === "string" && value.trim() !== "" ? Number(value) : value; + if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65_535) { + throw codedError( + new RangeError(`${name} should be >= 0 and < 65536. Received ${String(value)}.`), + "ERR_SOCKET_BAD_PORT", + ); + } + return port; +} + +export class SocketAddress { + readonly #address: string; + readonly #family: IPVersion; + readonly #port: number; + readonly #flowlabel: number; + + static isSocketAddress(value: unknown): value is SocketAddress { + return value instanceof SocketAddress; + } + + static parse(input: string): SocketAddress | undefined { + if (typeof input !== "string") { + throw invalidArgType("input", "string", input); + } + try { + const url = new URL(`http://${input}`); + const bracketed = url.hostname.startsWith("[") && url.hostname.endsWith("]"); + return new SocketAddress({ + address: bracketed ? url.hostname.slice(1, -1) : url.hostname, + family: bracketed ? "ipv6" : "ipv4", + port: url.port === "" ? 0 : Number(url.port), + }); + } catch { + return undefined; + } + } + + constructor(options: SocketAddressInitOptions = {}) { + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + const rawFamily: unknown = options.family ?? "ipv4"; + if (typeof rawFamily !== "string") { + throw invalidArgValue("options.family", rawFamily); + } + const family = rawFamily.toLowerCase(); + if (family !== "ipv4" && family !== "ipv6") { + throw invalidArgValue("options.family", options.family); + } + const address = options.address ?? (family === "ipv4" ? "127.0.0.1" : "::"); + if (typeof address !== "string") { + throw invalidArgType("options.address", "string", address); + } + const parsed = parseIp(address, family); + if (!parsed) { + throw invalidAddress(); + } + const port = validatePort(options.port ?? 0); + const flowlabel = family === "ipv4" ? 0 : (options.flowlabel ?? 0); + if (typeof flowlabel !== "number") { + throw invalidArgType("options.flowlabel", "number", flowlabel); + } + if (!Number.isInteger(flowlabel) || flowlabel < 0 || flowlabel > 0x000f_ffff) { + throw outOfRange("options.flowlabel", ">= 0 and <= 1048575", flowlabel); + } + this.#address = parsed.canonical; + this.#family = family; + this.#port = port; + this.#flowlabel = flowlabel; + details.set(this, { familyNumber: parsed.family, bytes: parsed.bytes }); + } + + get address(): string { + return this.#address; + } + + get family(): IPVersion { + return this.#family; + } + + get port(): number { + return this.#port; + } + + get flowlabel(): number { + return this.#flowlabel; + } + + toJSON(): SocketAddressJson { + return { + address: this.address, + port: this.port, + family: this.family, + flowlabel: this.flowlabel, + }; + } +} + +export function socketAddressDetails(value: SocketAddress): SocketAddressDetails { + return details.get(value)!; +} + +export { validatePort }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts new file mode 100644 index 000000000..ec9a081e3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/socket.ts @@ -0,0 +1,882 @@ +/** + * Preview 2-backed `net.Socket`. + * + * The public state transitions and event ordering are adapted from nodejs/node v24.19.0, commit + * cdc1b38d40cb567b7ad0b39c86addf830a0af0ae, lib/net.js (MIT license). libuv stream handles are + * replaced by WASI TCP/input/output resources. Since jco-std has no classic Node Duplex core yet, + * this class supplies the commonly used Duplex-shaped methods directly over its EventEmitter. + */ + +import { Buffer } from "node:buffer"; + +import { bodyBytes } from "../internal/bytes.js"; +import { StringDecoder } from "../string-decoder.js"; +import { EventEmitter } from "../internal/event-emitter.js"; +import { + closeTransport, + connect as connectTcp, + dispose, + errorCode, + schedule, + socketError, + wasiU64, + type BoundTcpSocket, + type ConnectedTcpSocket, + type NodeTcpAddress, + type WasiInputStream, + type WasiOutputStream, + type WasiSocketsProvider, + type WasiTcpSocket, +} from "../internal/wasi-sockets.js"; +import { BlockList } from "./block-list.js"; +import { BoundSocketBase, consumeBoundSocket } from "./bound-socket.js"; +import { + AbortError, + deprecated, + invalidArgType, + invalidArgValue, + ipBlocked, + outOfRange, + socketClosed, + socketClosedBeforeConnection, + unsupported, +} from "./errors.js"; +import { + getDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, +} from "./defaults.js"; +import { normalizeArgs, normalizedArgsSymbol, type NormalizedArgs } from "./normalize.js"; +import { validatePort } from "./socket-address.js"; +import type { + AddressInfo, + NetCallback, + NetChunk, + NetEncoding, + NetErrorCallback, + SocketConnectOptions, + SocketConstructorOptions, + SocketEventMap, + SocketReadyState, + TcpSocketConnectOptions, + WritableDestination, +} from "./types.js"; + +type Timer = ReturnType; +type Listener = (...args: never[]) => unknown; + +interface PendingRead { + resolve: (result: IteratorResult) => void; + reject: (error: Error) => void; +} + +interface AcceptedTransport { + socket: WasiTcpSocket; + input: WasiInputStream; + output: WasiOutputStream; + localAddress?: NodeTcpAddress; + remoteAddress?: NodeTcpAddress; +} + +export const attachAcceptedTransport = Symbol("attachAcceptedTransport"); +export const startSocketReading = Symbol("startSocketReading"); + +function callbackFrom(values: readonly unknown[]): NetCallback | undefined { + const last = values.at(-1); + return typeof last === "function" ? (last as NetCallback) : undefined; +} + +function normalizedConnectArgs(args: readonly unknown[]): NormalizedArgs { + const first = args[0]; + return Array.isArray(first) && + (first as unknown as { [normalizedArgsSymbol]?: unknown })[normalizedArgsSymbol] + ? (first as NormalizedArgs) + : normalizeArgs(args); +} + +function family(value: unknown): 0 | 4 | 6 | undefined { + if (value === undefined || value === null || value === 0 || value === 4 || value === 6) { + return value ?? undefined; + } + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (normalized === "ipv4") { + return 4; + } + if (normalized === "ipv6") { + return 6; + } + } + throw invalidArgValue("options.family", value); +} + +export class SocketBase extends EventEmitter implements AsyncIterable { + readonly #provider: WasiSocketsProvider; + readonly #allowHalfOpen: boolean; + readonly #onread: SocketConstructorOptions["onread"]; + #bound: BoundTcpSocket | undefined; + #socket: WasiTcpSocket | undefined; + #input: WasiInputStream | undefined; + #output: WasiOutputStream | undefined; + #local: NodeTcpAddress | undefined; + #remote: NodeTcpAddress | undefined; + #decoder: StringDecoder | undefined; + #paused = false; + #reading = false; + #readQueue: Array = []; + #pendingReads: PendingRead[] = []; + #timeoutTimer: Timer | undefined; + #timeout: number | undefined; + #closed = false; + #hadError = false; + #error: Error | undefined; + #signal: AbortSignal | undefined; + #abort: (() => void) | undefined; + #noDelay = false; + #keepAlive = false; + #keepAliveInitialDelay = 0; + #keepAliveInterval: number | undefined; + #keepAliveCount: number | undefined; + #bytesRead = 0; + #bytesWritten = 0; + #connecting = false; + #readable: boolean; + #writable: boolean; + + declare autoSelectFamilyAttemptedAddresses: string[] | undefined; + readonly allowHalfOpen: boolean; + server: unknown = null; + _server: unknown = null; + + constructor(provider: WasiSocketsProvider, options: SocketConstructorOptions = {}) { + super(); + if (typeof options !== "object" || options === null) { + throw invalidArgType("options", "Object", options); + } + for (const name of ["objectMode", "readableObjectMode", "writableObjectMode"] as const) { + if (options[name]) { + throw invalidArgValue(`options.${name}`, options[name], "is not supported"); + } + } + if (options.blockList !== undefined && !BlockList.isBlockList(options.blockList)) { + throw invalidArgType("options.blockList", "net.BlockList", options.blockList); + } + if ( + options.keepAliveInitialDelay !== undefined && + typeof options.keepAliveInitialDelay !== "number" + ) { + throw invalidArgType( + "options.keepAliveInitialDelay", + "number", + options.keepAliveInitialDelay, + ); + } + if (options.fd !== undefined) { + unsupported( + "net.Socket options.fd", + "operating-system file descriptors cannot cross the component boundary", + ); + } + this.#provider = provider; + this.#allowHalfOpen = Boolean(options.allowHalfOpen); + this.allowHalfOpen = this.#allowHalfOpen; + const onread = options.onread; + this.#onread = + onread !== null && + typeof onread === "object" && + (onread.buffer instanceof Uint8Array || typeof onread.buffer === "function") && + typeof onread.callback === "function" + ? onread + : undefined; + this.#readable = options.readable !== false; + this.#writable = options.writable !== false; + this.#noDelay = Boolean(options.noDelay); + this.#keepAlive = Boolean(options.keepAlive); + this.#keepAliveInitialDelay = + ~~(Math.max(0, options.keepAliveInitialDelay ?? 0) / 1_000) * 1_000; + if (options.handle !== undefined) { + if (!(options.handle instanceof BoundSocketBase)) { + unsupported( + "net.Socket options.handle", + "components can adopt only a net.BoundSocket, not a libuv or file-descriptor handle", + ); + } + this.#bound = options.handle[consumeBoundSocket](); + this.#local = this.#bound.address; + } + this.#setSignal(options.signal); + } + + get connecting(): boolean { + return this.#connecting; + } + + on( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + on(event: string, listener: Listener): this; + on(event: string, listener: Listener): this { + super.on(event, listener); + if (event === "data" && !this.#paused) { + queueMicrotask(() => { + if (!this.#paused) { + this.resume(); + } + }); + } + return this; + } + + addListener( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + addListener(event: string, listener: Listener): this; + addListener(event: string, listener: Listener): this { + return this.on(event, listener); + } + + once( + event: K, + listener: (...args: SocketEventMap[K]) => unknown, + ): this; + once(event: string, listener: Listener): this; + once(event: string, listener: Listener): this { + super.once(event, listener); + if (event === "data" && !this.#paused) { + queueMicrotask(() => { + if (!this.#paused) { + this.resume(); + } + }); + } + return this; + } + + get pending(): boolean { + return this.#socket === undefined || this.#connecting; + } + + get destroyed(): boolean { + return this.#closed; + } + + get readable(): boolean { + return this.#readable; + } + + get writable(): boolean { + return this.#writable; + } + + get readableEnded(): boolean { + return !this.#readable; + } + + get writableEnded(): boolean { + return !this.#writable; + } + + get writableLength(): number { + return 0; + } + + get readyState(): SocketReadyState { + if (this.#connecting) { + return "opening"; + } + if (this.#readable && this.#writable) { + return "open"; + } + if (this.#readable) { + return "readOnly"; + } + if (this.#writable) { + return "writeOnly"; + } + return "closed"; + } + + get bufferSize(): never { + return deprecated("net.Socket.bufferSize", "socket.writableLength"); + } + + get bytesRead(): number { + return this.#bytesRead; + } + + get bytesWritten(): number { + return this.#bytesWritten; + } + + get remoteAddress(): string | undefined { + return this.#remote?.address; + } + + get remoteFamily(): string | undefined { + return this.#remote?.family; + } + + get remotePort(): number | undefined { + return this.#remote?.port; + } + + get localAddress(): string | undefined { + return this.#local?.address; + } + + get localFamily(): string | undefined { + return this.#local?.family; + } + + get localPort(): number | undefined { + return this.#local?.port; + } + + get timeout(): number | undefined { + return this.#timeout; + } + + connect(options: SocketConnectOptions, connectionListener?: NetCallback): this; + connect(port: number, host?: string | NetCallback, connectionListener?: NetCallback): this; + connect(path: string, connectionListener?: NetCallback): this; + connect(...args: unknown[]): this { + if (this.#closed) { + throw socketClosed(); + } + const [rawOptions, listener] = normalizedConnectArgs(args); + const options = rawOptions as Partial & { path?: unknown }; + this.#setSignal(options.signal); + const connectionListener = (listener as NetCallback | null) ?? callbackFrom(args); + if (connectionListener) { + this.once("connect", connectionListener as Listener); + } + if (options.path !== undefined) { + return unsupported( + "net.Socket.connect path", + "wasi:sockets Preview 2 exposes IP sockets but not Unix-domain sockets or named pipes", + ); + } + const port = validatePort(options.port, "options.port"); + const host = options.host ?? "localhost"; + if (typeof host !== "string") { + throw invalidArgType("options.host", "string", host); + } + if (options.lookup !== undefined) { + return unsupported( + "net.Socket.connect custom lookup", + "asynchronous JavaScript DNS callbacks cannot be retained across the Preview 2 boundary", + ); + } + const selectedFamily = family(options.family); + if (options.timeout !== undefined) { + this.setTimeout(options.timeout); + } + const autoSelect = options.autoSelectFamily ?? getDefaultAutoSelectFamily(); + if (typeof autoSelect !== "boolean") { + throw invalidArgType("options.autoSelectFamily", "boolean", autoSelect); + } + const attemptTimeout = + options.autoSelectFamilyAttemptTimeout ?? getDefaultAutoSelectFamilyAttemptTimeout(); + if ( + typeof attemptTimeout !== "number" || + !Number.isInteger(attemptTimeout) || + attemptTimeout < 1 || + attemptTimeout > 0x7fff_ffff + ) { + throw outOfRange("options.autoSelectFamilyAttemptTimeout", "an integer >= 1", attemptTimeout); + } + const lookupEvents: Array<[string, 4 | 6, string]> = []; + if (this.#signal?.aborted) { + return this; + } + this.#connecting = true; + try { + const bound = this.#bound; + this.#bound = undefined; + let transport: ConnectedTcpSocket; + try { + transport = connectTcp(this.#provider, host, port, { + family: selectedFamily, + localAddress: options.localAddress, + localPort: options.localPort, + socket: bound?.socket, + network: bound?.network, + allowAddress: (address, addressFamily) => { + if (options.blockList?.check(address, addressFamily === 4 ? "ipv4" : "ipv6")) { + throw ipBlocked(address); + } + return true; + }, + onLookup: (address, addressFamily, hostname) => + lookupEvents.push([address, addressFamily, hostname]), + onAttempt: (address, attemptPort, addressFamily) => + queueMicrotask(() => + this.emit("connectionAttempt", address, attemptPort, addressFamily), + ), + }); + } finally { + if (bound) { + dispose(bound.network); + } + } + this.#attach(transport); + if (autoSelect && transport.attemptedAddresses.length > 1) { + this.autoSelectFamilyAttemptedAddresses = transport.attemptedAddresses; + } + queueMicrotask(() => { + if (this.#closed) { + return; + } + for (const [address, addressFamily, hostname] of lookupEvents) { + this.emit("lookup", null, address, addressFamily, hostname); + } + this.#connecting = false; + this.emit("connect"); + this.emit("ready"); + this[startSocketReading](); + }); + } catch (error) { + this.#connecting = false; + const failure = + error instanceof Error ? error : socketError(error, "connect", host, undefined, port); + queueMicrotask(() => this.destroy(failure)); + } + return this; + } + + write(chunk: NetChunk, callback?: NetErrorCallback): boolean; + write(chunk: NetChunk, encoding?: string, callback?: NetErrorCallback): boolean; + write( + chunk: NetChunk, + encodingOrCallback?: string | NetErrorCallback, + callback?: NetErrorCallback, + ): boolean { + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (!this.#writable || this.#closed || !this.#output) { + const error = socketClosed(); + queueMicrotask(() => { + done?.(error); + this.emit("error", error); + }); + return false; + } + try { + if (encoding !== undefined && !Buffer.isEncoding(encoding)) { + throw invalidArgValue("encoding", encoding); + } + const bytes = typeof chunk === "string" ? Buffer.from(chunk, encoding) : bodyBytes(chunk); + this.#output.blockingWriteAndFlush(bytes); + this.#bytesWritten += bytes.byteLength; + this.#refreshTimeout(); + queueMicrotask(() => done?.(null)); + return true; + } catch (error) { + const failure = error instanceof Error ? error : socketError(error, "write"); + queueMicrotask(() => { + done?.(failure); + this.destroy(failure); + }); + return false; + } + } + + end(callback?: NetCallback): this; + end(chunk: NetChunk, callback?: NetCallback): this; + end(chunk: NetChunk, encoding?: string, callback?: NetCallback): this; + end( + chunkOrCallback?: NetChunk | NetCallback, + encodingOrCallback?: string | NetCallback, + callback?: NetCallback, + ): this { + const chunk = typeof chunkOrCallback === "function" ? undefined : chunkOrCallback; + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; + const done = + typeof chunkOrCallback === "function" + ? chunkOrCallback + : typeof encodingOrCallback === "function" + ? encodingOrCallback + : callback; + if (chunk !== undefined) { + this.write(chunk, encoding); + } + if (this.#writable) { + this.#writable = false; + try { + this.#socket?.shutdown("send"); + } catch (error) { + if (errorCode(error) !== "not-connected") { + this.#hadError = true; + } + } + dispose(this.#output); + this.#output = undefined; + queueMicrotask(() => { + this.emit("finish"); + done?.(); + if (!this.#readable) { + this.destroy(); + } + }); + } else { + queueMicrotask(() => done?.()); + } + return this; + } + + destroy(error?: Error): this { + if (this.#closed) { + return this; + } + if (this.#connecting && !error) { + error = socketClosedBeforeConnection(); + } + this.#closed = true; + this.#connecting = false; + this.#readable = false; + this.#writable = false; + this.#hadError ||= error !== undefined; + this.#error = error; + if (this.#signal && this.#abort) { + this.#signal.removeEventListener("abort", this.#abort); + this.#signal = undefined; + this.#abort = undefined; + } + if (this.#timeoutTimer !== undefined) { + clearTimeout(this.#timeoutTimer); + this.#timeoutTimer = undefined; + } + if (this.#socket) { + closeTransport(this.#socket, this.#input, this.#output); + } + if (this.#bound) { + closeTransport(this.#bound.socket); + dispose(this.#bound.network); + this.#bound = undefined; + } + this.#socket = undefined; + this.#input = undefined; + this.#output = undefined; + while (this.#pendingReads.length > 0) { + const pending = this.#pendingReads.shift()!; + if (error) { + pending.reject(error); + } else { + pending.resolve({ done: true, value: undefined }); + } + } + queueMicrotask(() => { + if (error) { + this.emit("error", error); + } + this.emit("close", this.#hadError); + }); + return this; + } + + destroySoon(): void { + if (this.#writable) { + this.end(() => this.destroy()); + } else { + this.destroy(); + } + } + + resetAndDestroy(): never { + return unsupported( + "net.Socket.resetAndDestroy", + "wasi:sockets Preview 2 does not expose TCP reset-on-close", + ); + } + + pause(): this { + this.#paused = true; + return this; + } + + resume(): this { + this.#paused = false; + while (!this.#paused && this.#readQueue.length > 0) { + this.emit("data", this.#readQueue.shift()); + } + this[startSocketReading](); + return this; + } + + read(): Uint8Array | string | null { + return this.#readQueue.shift() ?? null; + } + + pipe(destination: WritableDestination): WritableDestination { + this.on("data", ((chunk: Uint8Array | string) => destination.write(chunk)) as Listener); + this.once("end", (() => destination.end?.()) as Listener); + this.resume(); + return destination; + } + + setEncoding(encoding: NetEncoding = "utf8"): this { + this.#decoder = new StringDecoder(encoding); + return this; + } + + setTimeout(timeout: number, callback?: NetCallback): this { + if (typeof timeout !== "number") { + throw invalidArgType("msecs", "number", timeout); + } + if (!Number.isFinite(timeout) || timeout < 0 || timeout > 0xffff_ffff) { + throw outOfRange("msecs", ">= 0 and <= 4294967295", timeout); + } + if (timeout > 0 && typeof setTimeout !== "function") { + return unsupported("net.Socket.setTimeout", "the component engine does not provide timers"); + } + this.#timeout = Math.trunc(timeout); + if (callback) { + this.once("timeout", callback as Listener); + } + this.#refreshTimeout(); + return this; + } + + setNoDelay(noDelay = true): this { + this.#noDelay = Boolean(noDelay); + return this; + } + + setKeepAlive(enable = false, initialDelay = 0, interval?: number, count?: number): this { + this.#keepAlive = Boolean(enable); + this.#keepAliveInitialDelay = ~~(initialDelay / 1_000) * 1_000; + this.#keepAliveInterval = interval === undefined ? undefined : ~~(interval / 1_000) * 1_000; + this.#keepAliveCount = count; + this.#applySocketOptions(); + return this; + } + + setTypeOfService(tos: number): never { + if (typeof tos !== "number" || Number.isNaN(tos)) { + throw invalidArgType("tos", "number", tos); + } + if (!Number.isInteger(tos) || tos < 0 || tos > 255) { + throw outOfRange("tos", ">= 0 and <= 255", tos); + } + return unsupported( + "net.Socket.setTypeOfService", + "wasi:sockets Preview 2 does not expose the IP type-of-service socket option", + ); + } + + getTypeOfService(): never { + return unsupported( + "net.Socket.getTypeOfService", + "wasi:sockets Preview 2 does not expose the IP type-of-service socket option", + ); + } + + address(): AddressInfo | Record { + return this.#local ? { ...this.#local } : {}; + } + + ref(): this { + return this; + } + + unref(): this { + return this; + } + + [attachAcceptedTransport](transport: AcceptedTransport): void { + this.#attach({ ...transport, attemptedAddresses: [] }); + this.#connecting = false; + } + + [startSocketReading](): void { + if (this.#reading || this.#paused || !this.#readable || this.#closed || !this.#input) { + return; + } + this.#reading = true; + schedule(this.#provider, () => this.#readOnce()); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + // An iterator consumes stream errors through its rejected next() promise. + const onError = (): void => {}; + this.on("error", onError); + try { + for (;;) { + if (this.#error) { + throw this.#error; + } + const queued = this.#readQueue.shift(); + if (queued !== undefined) { + yield queued; + continue; + } + if (!this.#readable || this.#closed) { + return; + } + const result = await new Promise>((resolve, reject) => { + this.#pendingReads.push({ resolve, reject }); + this[startSocketReading](); + }); + if (result.done) { + return; + } + yield result.value; + } + } finally { + if (!this.#closed) { + this.destroy(); + } + queueMicrotask(() => this.off("error", onError)); + } + } + + #attach(transport: ConnectedTcpSocket): void { + this.#socket = transport.socket; + this.#input = transport.input; + this.#output = transport.output; + this.#local = transport.localAddress; + this.#remote = transport.remoteAddress; + this.#readable = true; + this.#writable = true; + this.#applySocketOptions(); + this.#refreshTimeout(); + } + + #applySocketOptions(): void { + const socket = this.#socket; + if (!socket) { + return; + } + socket.setKeepAliveEnabled?.(this.#keepAlive); + if (this.#keepAlive && this.#keepAliveInitialDelay > 0) { + socket.setKeepAliveIdleTime?.( + wasiU64(this.#provider, this.#keepAliveInitialDelay * 1_000_000), + ); + } + if (this.#keepAlive && this.#keepAliveInterval !== undefined && this.#keepAliveInterval > 0) { + socket.setKeepAliveInterval?.( + wasiU64(this.#provider, Math.trunc(this.#keepAliveInterval) * 1_000_000), + ); + } + if (this.#keepAlive && this.#keepAliveCount !== undefined && this.#keepAliveCount > 0) { + socket.setKeepAliveCount?.(Math.trunc(this.#keepAliveCount)); + } + void this.#noDelay; + } + + #refreshTimeout(): void { + if (this.#timeoutTimer !== undefined) { + clearTimeout(this.#timeoutTimer); + this.#timeoutTimer = undefined; + } + if (!this.#timeout) { + return; + } + this.#timeoutTimer = setTimeout(() => this.emit("timeout"), this.#timeout); + } + + #setSignal(signal: AbortSignal | undefined): void { + if (!signal || signal === this.#signal) { + return; + } + if (this.#signal && this.#abort) { + this.#signal.removeEventListener("abort", this.#abort); + } + this.#signal = signal; + this.#abort = () => { + this.destroy(new AbortError()); + }; + if (signal.aborted) { + queueMicrotask(this.#abort); + } else { + signal.addEventListener("abort", this.#abort, { once: true }); + } + } + + #readOnce(): void { + this.#reading = false; + if (this.#paused || !this.#readable || this.#closed || !this.#input) { + return; + } + try { + const target = this.#onread + ? typeof this.#onread.buffer === "function" + ? this.#onread.buffer() + : this.#onread.buffer + : undefined; + if (target && target.byteLength === 0) { + throw invalidArgValue("options.onread.buffer", target, "must not be empty"); + } + const bytes = this.#input.blockingRead(wasiU64(this.#provider, target?.byteLength ?? 65_536)); + if (bytes.byteLength > 0) { + this.#bytesRead += bytes.byteLength; + this.#refreshTimeout(); + if (this.#onread && target) { + target.set(bytes); + if (this.#onread.callback(bytes.byteLength, target) === false) { + this.#paused = true; + } + } else { + this.#deliver(this.#decoder ? this.#decoder.write(bytes) : Buffer.from(bytes)); + } + } + this[startSocketReading](); + } catch (error) { + if (errorCode(error) === "closed") { + if (this.#decoder) { + this.#deliver(this.#decoder.end()); + } + this.#readable = false; + dispose(this.#input); + this.#input = undefined; + while (this.#pendingReads.length > 0) { + this.#pendingReads.shift()!.resolve({ done: true, value: undefined }); + } + this.emit("end"); + if (!this.#allowHalfOpen && this.#writable) { + this.end(); + } + if (!this.#writable) { + this.destroy(); + } + } else { + this.destroy(error instanceof Error ? error : socketError(error, "read")); + } + } + } + + #deliver(value: Uint8Array | string): void { + if (value.length === 0) { + return; + } + const pending = this.#pendingReads.shift(); + if (pending) { + pending.resolve({ done: false, value }); + } else if (this.#paused || this.listenerCount("data") === 0) { + this.#readQueue.push(value); + this.emit("readable"); + } else { + this.emit("data", value); + } + } +} + +export interface SocketConstructor { + (options?: SocketConstructorOptions): SocketBase; + new (options?: SocketConstructorOptions): SocketBase; + readonly prototype: SocketBase; +} + +export function createSocketConstructor(provider: WasiSocketsProvider): SocketConstructor { + class Socket extends SocketBase { + constructor(options?: SocketConstructorOptions) { + super(provider, options); + } + } + return new Proxy(Socket, { + apply(_target, _thisArgument, argumentsList) { + return new Socket(argumentsList[0] as SocketConstructorOptions | undefined); + }, + }) as SocketConstructor; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts new file mode 100644 index 000000000..ffb44cdf3 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/net/types.ts @@ -0,0 +1,122 @@ +import type { BlockList } from "./block-list.js"; + +export interface AddressInfo { + address: string; + family: string; + port: number; +} + +export type SocketReadyState = "opening" | "open" | "readOnly" | "writeOnly" | "closed"; +export type NetChunk = string | ArrayBuffer | ArrayBufferView; +export type NetEncoding = + | "ascii" + | "utf8" + | "utf-8" + | "utf16le" + | "utf-16le" + | "ucs2" + | "ucs-2" + | "base64" + | "base64url" + | "latin1" + | "binary" + | "hex"; +export type NetCallback = () => void; +export type NetErrorCallback = (error?: Error | null) => void; + +export interface SocketEventMap { + close: [hadError: boolean]; + connect: []; + ready: []; + data: [chunk: Uint8Array | string]; + drain: []; + end: []; + finish: []; + readable: []; + error: [error: Error]; + timeout: []; + lookup: [error: Error | null, address: string, family: number, host: string]; + connectionAttempt: [address: string, port: number, family: number]; + connectionAttemptFailed: [address: string, port: number, family: number, error: Error]; + connectionAttemptTimeout: [address: string, port: number, family: number]; +} + +export interface OnReadOptions { + buffer: Uint8Array | (() => Uint8Array); + callback(bytesWritten: number, buffer: Uint8Array): boolean; +} + +export interface SocketConstructorOptions { + allowHalfOpen?: boolean; + onread?: OnReadOptions; + readable?: boolean; + writable?: boolean; + signal?: AbortSignal; + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + blockList?: BlockList; + /** Internal adoption hook used by `BoundSocket`; arbitrary handles are unsupported. */ + handle?: unknown; + /** Native file descriptors cannot cross the component boundary. */ + fd?: unknown; + objectMode?: boolean; + readableObjectMode?: boolean; + writableObjectMode?: boolean; +} + +export interface TcpSocketConnectOptions extends SocketConstructorOptions { + port: number; + host?: string; + localAddress?: string; + localPort?: number; + family?: 0 | 4 | 6; + lookup?: unknown; + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + timeout?: number; +} + +export interface IpcSocketConnectOptions extends SocketConstructorOptions { + path: string; + timeout?: number; +} + +export type SocketConnectOptions = TcpSocketConnectOptions | IpcSocketConnectOptions; + +export interface ServerOptions { + allowHalfOpen?: boolean; + pauseOnConnect?: boolean; + noDelay?: boolean; + keepAlive?: boolean; + keepAliveInitialDelay?: number; + highWaterMark?: number; + blockList?: BlockList; +} + +export interface ListenOptions { + port?: number; + host?: string; + backlog?: number; + path?: string; + exclusive?: boolean; + ipv6Only?: boolean; + reusePort?: boolean; + signal?: AbortSignal; + /** Internal adoption hook used by `BoundSocket`; arbitrary handles are unsupported. */ + handle?: unknown; +} + +export interface DropArgument { + localAddress?: string; + localPort?: number; + localFamily?: string; + remoteAddress?: string; + remotePort?: number; + remoteFamily?: string; +} + +export interface WritableDestination { + write(chunk: Uint8Array | string): unknown; + end?(): unknown; +} 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 13bcbf7d1..e88198878 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/conformance.ts @@ -5,11 +5,11 @@ import { type WasiHttpFields, type WasiHttpProvider, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-http.js"; -import { - createWasiSocketsHttpImplementation, - type WasiSocketsProvider, - type WasiTcpSocket, -} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import { createWasiSocketsHttpImplementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets/index.js"; +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; import { parseHttp1Response, serializeHttp1Request, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/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 1a7eb321f..2ea724368 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http/wasi-sockets.ts @@ -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/internal/wasi-sockets.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts index 1e027c260..f7f42e2ee 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/conformance.ts @@ -14,7 +14,7 @@ import { encodeHeaders, HpackDecoder, } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/hpack.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; import type { DirectHttp2Settings, DirectHttp2StreamListener, diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts index 2e0515c72..754b67250 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/host.ts @@ -105,18 +105,8 @@ describe("Node HTTP/2 host provider", () => { test("uses a real TLS client session with h2 ALPN", async () => { const [key, cert] = await Promise.all([ - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.key", - import.meta.url, - ), - ), - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.crt", - import.meta.url, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]); const server = nodeHttp2.createSecureServer({ key, cert }); closeables.push(() => new Promise((resolve) => server.close(() => resolve()))); @@ -152,18 +142,8 @@ describe("Node HTTP/2 host provider", () => { test.each([false, true])("uses a real %s server callback round trip", async (secure) => { const [key, cert] = secure ? await Promise.all([ - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.key", - import.meta.url, - ), - ), - readFile( - new URL( - "../../../../../../../preview2-shim/test/fixtures/tls/localhost.crt", - import.meta.url, - ), - ), + readFile(new URL("../https/helpers/tls/localhost.key", import.meta.url)), + readFile(new URL("../https/helpers/tls/localhost.crt", import.meta.url)), ]) : [undefined, undefined]; const listener: DirectHttp2StreamListener = { diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts index 15519394a..c543d1b28 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/http2/unsupported.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createHttp2 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/core.js"; import { createWasiHttpHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-http/index.js"; import { createWasiSocketsHttp2Implementation } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http2/impl/wasi-sockets/index.js"; -import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/http/impl/wasi-sockets.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; describe("node:http2 via wasi-http", () => { const createImplementation = createWasiHttpHttp2Implementation; 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..cd49a2831 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/https/host.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { + createHttpHost, + request, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/http-host-node.js"; +import type { + DirectHttpCallbacks, + DirectHttpRequestListener, + 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))); + +type HttpHostServer = InstanceType["Server"]>; +const servers = new Set(); + +afterEach(async () => { + await Promise.all([...servers].map((server) => server.close())); + servers.clear(); +}); + +const echo: DirectHttpRequestListener = { + handle: async (incoming) => ({ + 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: HttpHostServer; port: number }> { + const callbacks: DirectHttpCallbacks = { + takeRequestListener(id: number): DirectHttpRequestListener { + expect(id).toBe(1); + return echo; + }, + }; + const { Server } = createHttpHost(() => callbacks); + const server = new Server(options, 1); + servers.add(server); + const started = await server.listen({ port: 0, host: "127.0.0.1" }); + if (started.tag !== "ok" || 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/test/wasi/0.2.x/node/24.x.x/net/address.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts new file mode 100644 index 000000000..d45f34216 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/address.ts @@ -0,0 +1,85 @@ +import nodeNet from "node:net"; + +import { describe, expect, test } from "vitest"; + +import { BlockList } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/block-list.js"; +import { isIP, isIPv4, isIPv6 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/ip.js"; +import { SocketAddress } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/socket-address.js"; + +describe("node:net address utilities", () => { + test("matches Node IP predicates", () => { + for (const input of [ + "127.0.0.1", + "255.255.255.255", + "256.0.0.1", + "::", + "2001:0db8::1", + "::ffff:192.0.2.1", + "fe80::1%eth0", + "not-an-address", + 4, + null, + ]) { + expect(isIP(input)).toBe(nodeNet.isIP(input as string)); + expect(isIPv4(input)).toBe(nodeNet.isIPv4(input as string)); + expect(isIPv6(input)).toBe(nodeNet.isIPv6(input as string)); + } + }); + + test("matches SocketAddress construction and parsing", () => { + for (const options of [ + {}, + { family: "IPv6" }, + { address: "2001:0db8::1", family: "ipv6", port: 81 }, + { address: "2001:db8:0:1:2:3:4:5", family: "ipv6", port: 81 }, + { address: "0:0:0:0:0:ffff:192.0.2.1", family: "ipv6" }, + { address: "::ffff:192.0.2.1", family: "ipv6", flowlabel: 7 }, + ] as const) { + expect(new SocketAddress(options).toJSON()).toEqual( + new nodeNet.SocketAddress(options).toJSON(), + ); + } + for (const input of ["127.0.0.1:81", "127.0.0.1:80", "[2001:db8::1]:443", "localhost:80"]) { + expect(SocketAddress.parse(input)?.toJSON()).toEqual( + nodeNet.SocketAddress.parse(input)?.toJSON(), + ); + } + expect(Object.keys(new SocketAddress())).toEqual(Object.keys(new nodeNet.SocketAddress())); + expect(new SocketAddress({ flowlabel: 1 }).flowlabel).toBe(0); + expect(() => new SocketAddress({ address: "invalid" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ADDRESS" }), + ); + }); + + test("matches BlockList ordering, ranges, subnets, mapping, and JSON", () => { + const actual = new BlockList(); + const expected = new nodeNet.BlockList(); + for (const blockList of [actual, expected]) { + blockList.addAddress("192.0.2.1"); + blockList.addRange("192.0.2.5", "192.0.2.10"); + blockList.addSubnet("2001:db8::", 32, "ipv6"); + } + expect(actual.rules).toEqual(expected.rules); + expect(actual.toJSON()).toEqual(expected.toJSON()); + for (const [address, family] of [ + ["192.0.2.1", "ipv4"], + ["192.0.2.7", "ipv4"], + ["192.0.2.11", "ipv4"], + ["2001:db8::abcd", "ipv6"], + ["::ffff:192.0.2.1", "ipv6"], + ] as const) { + expect(actual.check(address, family)).toBe(expected.check(address, family)); + } + expect(() => actual.check(123 as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + const mapped = new BlockList(); + mapped.addSubnet("::ffff:192.0.2.0", 120, "ipv6"); + expect(mapped.check("192.0.2.1")).toBe(true); + const restored = new BlockList(); + const expectedRestored = new nodeNet.BlockList(); + restored.fromJSON(JSON.stringify(actual)); + expectedRestored.fromJSON(JSON.stringify(expected)); + expect(restored.rules).toEqual(expectedRestored.rules); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts new file mode 100644 index 000000000..5c4f717d5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/helpers/provider.ts @@ -0,0 +1,86 @@ +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +/** Deterministic transport that obeys the WASI read limit and tracks resource ownership. */ +export function createProvider(chunks: Uint8Array[] = []): { + provider: WasiSocketsProvider; + tasks: Array<() => void | Promise>; + disposed: string[]; + writes: Uint8Array[]; + readLengths: bigint[]; + failRead(error: unknown): void; +} { + const tasks: Array<() => void | Promise> = []; + const disposed: string[] = []; + const writes: Uint8Array[] = []; + const readLengths: bigint[] = []; + const queue = chunks.map((chunk) => chunk.slice()); + let readError: unknown; + const socket: WasiTcpSocket = { + startConnect: () => undefined, + finishConnect: () => [ + { + blockingRead(length) { + readLengths.push(length); + if (readError !== undefined) { + throw readError; + } + const chunk = queue.shift(); + if (!chunk) { + throw { tag: "closed" }; + } + const count = Math.min(chunk.length, Number(length)); + if (count < chunk.length) { + queue.unshift(chunk.subarray(count)); + } + return chunk.subarray(0, count); + }, + [Symbol.dispose]: () => { + disposed.push("input"); + }, + }, + { + blockingWriteAndFlush: (bytes) => { + writes.push(Uint8Array.from(bytes)); + }, + [Symbol.dispose]: () => { + disposed.push("output"); + }, + }, + ], + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + [Symbol.dispose]: () => { + disposed.push("socket"); + }, + }; + return { + provider: { + instanceNetwork: { + instanceNetwork: () => ({ + [Symbol.dispose]: () => { + disposed.push("network"); + }, + }), + }, + ipNameLookup: { + resolveAddresses: () => { + throw new Error("Unexpected DNS lookup"); + }, + }, + tcpCreateSocket: { createTcpSocket: () => socket }, + schedule: (task) => { + tasks.push(task); + }, + }, + tasks, + disposed, + writes, + readLengths, + failRead: (error) => { + readError = error; + }, + }; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts new file mode 100644 index 000000000..06feb086a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/module.ts @@ -0,0 +1,76 @@ +import nodeNet from "node:net"; + +import { describe, expect, test } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import type { WasiSocketsProvider } from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +const provider = { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { resolveAddresses: () => void 0 as never }, + tcpCreateSocket: { createTcpSocket: () => void 0 as never }, +} satisfies WasiSocketsProvider; + +describe("node:net module", () => { + test("matches the Node 24.19 export surface and aliases", () => { + const net = createNet(provider); + expect(Object.keys(net).sort()).toEqual(Object.keys(nodeNet).sort()); + expect(net.connect).toBe(net.createConnection); + expect(net.Socket).toBe(net.Stream); + }); + + test("provides callable Socket and Server constructors", () => { + const net = createNet(provider); + expect(net.Socket()).toBeInstanceOf(net.Socket); + expect(net.Server()).toBeInstanceOf(net.Server); + expect(net.Socket({ allowHalfOpen: true }).allowHalfOpen).toBe(true); + expect(net.Server({ keepAliveInitialDelay: 2_000, highWaterMark: -1 })).toMatchObject({ + keepAliveInitialDelay: 2, + highWaterMark: 65_536, + }); + }); + + test("normalizes the stable connect and listen overloads", () => { + const callback = () => undefined; + for (const args of [ + [80, "example.com", callback], + ["/tmp/example.sock", callback], + [{ port: 443, host: "example.com" }, callback], + ] as const) { + const [actualOptions, actualCallback] = createNet(provider)._normalizeArgs(args); + const [expectedOptions, expectedCallback] = nodeNet._normalizeArgs(args); + expect(actualOptions).toEqual(expectedOptions); + expect(actualCallback).toBe(expectedCallback); + } + }); + + test("matches family-default state and validation", () => { + const net = createNet(provider); + const originalFamily = nodeNet.getDefaultAutoSelectFamily(); + const originalTimeout = nodeNet.getDefaultAutoSelectFamilyAttemptTimeout(); + try { + net.setDefaultAutoSelectFamily(false); + expect(net.getDefaultAutoSelectFamily()).toBe(false); + net.setDefaultAutoSelectFamilyAttemptTimeout(1); + expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(10); + expect(() => net.setDefaultAutoSelectFamily("yes" as never)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => net.setDefaultAutoSelectFamilyAttemptTimeout(0)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + } finally { + net.setDefaultAutoSelectFamily(originalFamily); + net.setDefaultAutoSelectFamilyAttemptTimeout(originalTimeout); + } + }); + + test("fails raw libuv handle creation explicitly", () => { + expect(() => createNet(provider)._createServerHandle()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => createNet(provider).Socket({ fd: 1 })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts new file mode 100644 index 000000000..0665aeebb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/server.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import type { + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; +import type { SocketBase } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/socket.js"; + +function serverProvider(): { + provider: WasiSocketsProvider; + scheduled: Array<() => void | Promise>; + shutdowns: string[]; +} { + const scheduled: Array<() => void | Promise> = []; + const shutdowns: string[] = []; + let accepted = false; + const connection: WasiTcpSocket = { + startConnect: () => undefined, + finishConnect: () => void 0 as never, + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 8080 } }), + remoteAddress: () => ({ tag: "ipv4", val: { address: [192, 0, 2, 10], port: 54321 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: (direction) => shutdowns.push(direction), + }; + const listener: WasiTcpSocket = { + startBind(_network, address) { + expect(address).toEqual({ + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: 8080 }, + }); + }, + finishBind: () => undefined, + startConnect: () => undefined, + finishConnect: () => void 0 as never, + startListen: () => undefined, + finishListen: () => undefined, + accept() { + if (accepted) { + throw { tag: "would-block" }; + } + accepted = true; + return [ + connection, + { blockingRead: () => void 0 as never }, + { blockingWriteAndFlush: () => undefined }, + ]; + }, + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 8080 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + }; + return { + provider: { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { resolveAddresses: () => void 0 as never }, + tcpCreateSocket: { createTcpSocket: () => listener }, + schedule: (task) => scheduled.push(task), + }, + scheduled, + shutdowns, + }; +} + +describe("node:net Server", () => { + test("rejects invalid listen ports synchronously", () => { + const net = createNet(serverProvider().provider); + for (const port of [-1, 65_536, 1.5, NaN]) { + expect(() => net.createServer().listen({ port })).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_BAD_PORT" }), + ); + } + }); + test("listens, accepts a Socket, tracks it, and waits to close", async () => { + const { provider, scheduled, shutdowns } = serverProvider(); + const net = createNet(provider); + let accepted: SocketBase | undefined; + const server = net.createServer((socket) => { + accepted = socket; + }); + const listening = new Promise((resolve) => server.once("listening", resolve as never)); + server.listen(8080, "127.0.0.1"); + await listening; + expect(server.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 8080 }); + await scheduled.shift()?.(); + expect(accepted).toBeInstanceOf(net.Socket); + expect(accepted).toMatchObject({ + remoteAddress: "192.0.2.10", + remotePort: 54321, + server, + }); + await expect( + new Promise((resolve, reject) => + server.getConnections((error, count) => (error ? reject(error) : resolve(count))), + ), + ).resolves.toBe(1); + + const closed = new Promise((resolve) => + server.close((error) => (error ? void 0 : resolve())), + ); + let settled = false; + void closed.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + accepted!.destroy(); + await closed; + expect(shutdowns).toContain("both"); + expect(server.address()).toBeNull(); + }); + + test("drops blocked peers before emitting connection", async () => { + const { provider, scheduled } = serverProvider(); + const net = createNet(provider); + const blockList = new net.BlockList(); + blockList.addSubnet("192.0.2.0", 24); + const server = net.createServer({ blockList }); + const dropped = new Promise((resolve) => server.once("drop", resolve as never)); + server.listen({ port: 8080, host: "127.0.0.1" }); + await Promise.resolve(); + await scheduled.shift()?.(); + await expect(dropped).resolves.toMatchObject({ + remoteAddress: "192.0.2.10", + remotePort: 54321, + }); + server.close(); + }); + + test("transfers a BoundSocket exactly once", () => { + const { provider } = serverProvider(); + const net = createNet(provider); + const bound = new net.BoundSocket({ port: 8080, host: "127.0.0.1" }); + const server = net.createServer(); + server.listen(bound); + expect(() => bound.close()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_HANDLE_ADOPTED" }), + ); + server.close(); + }); + + test("rejects IPC and native listen flags explicitly", () => { + const net = createNet(serverProvider().provider); + expect(() => net.createServer().listen("/tmp/example.sock")).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => net.createServer().listen({ port: 80, reusePort: true })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts new file mode 100644 index 000000000..a2c995974 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/socket.ts @@ -0,0 +1,255 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createNet } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/core.js"; +import { createProvider } from "./helpers/provider.js"; +import type { + WasiInputStream, + WasiOutputStream, + WasiSocketsProvider, + WasiTcpSocket, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function clientProvider(): { provider: WasiSocketsProvider; writes: Uint8Array[] } { + const writes: Uint8Array[] = []; + let read = false; + const input: WasiInputStream = { + blockingRead() { + if (read) { + throw { tag: "closed" }; + } + read = true; + return encoder.encode("reply"); + }, + }; + const output: WasiOutputStream = { + blockingWriteAndFlush(contents) { + writes.push(contents.slice()); + }, + }; + const socket: WasiTcpSocket = { + startConnect(_network, address) { + expect(address).toEqual({ + tag: "ipv4", + val: { address: [192, 0, 2, 8], port: 8080 }, + }); + }, + finishConnect: () => [input, output], + localAddress: () => ({ tag: "ipv4", val: { address: [127, 0, 0, 1], port: 49152 } }), + remoteAddress: () => ({ tag: "ipv4", val: { address: [192, 0, 2, 8], port: 8080 } }), + subscribe: () => ({ block: () => undefined }), + shutdown: () => undefined, + }; + const provider: WasiSocketsProvider = { + instanceNetwork: { instanceNetwork: () => ({}) }, + ipNameLookup: { + resolveAddresses: () => { + let yielded = false; + return { + resolveNextAddress() { + if (yielded) { + return undefined; + } + yielded = true; + return { tag: "ipv4", val: [192, 0, 2, 8] }; + }, + subscribe: () => ({ block: () => undefined }), + }; + }, + }, + tcpCreateSocket: { createTcpSocket: () => socket }, + }; + return { provider, writes }; +} + +describe("node:net Socket", () => { + test("does not acquire a transport for an already aborted signal", async () => { + const { provider, disposed } = createProvider(); + const acquire = vi.spyOn(provider.instanceNetwork, "instanceNetwork"); + const socket = createNet(provider).connect({ port: 80, signal: AbortSignal.abort() }); + const error = new Promise((resolve) => socket.once("error", resolve)); + await expect(error).resolves.toMatchObject({ code: "ABORT_ERR" }); + expect(acquire).not.toHaveBeenCalled(); + expect(disposed).toEqual([]); + }); + + test("removes the abort listener when a socket is destroyed", async () => { + const { provider } = createProvider(); + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + signal: controller.signal, + }); + await Promise.resolve(); + socket.destroy(); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + test("keeps the receive side open after end() and honors pause/resume", async () => { + const { provider, tasks, disposed } = createProvider([encoder.encode("reply")]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("utf8"); + const received: string[] = []; + socket.on("data", (chunk: string) => received.push(chunk)); + await Promise.resolve(); + socket.pause().end("request"); + await tasks.shift()?.(); + expect(received).toEqual([]); + expect(socket.readyState).toBe("readOnly"); + expect(disposed).toEqual(["network", "output"]); + socket.resume(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(received).toEqual(["reply"]); + expect(socket.destroyed).toBe(true); + }); + + test("preserves unread data for read() and delayed async iteration", async () => { + const { provider, tasks } = createProvider([encoder.encode("one"), encoder.encode("two")]); + const socket = createNet(provider).connect(80, "127.0.0.1"); + socket.setEncoding("utf8"); + await Promise.resolve(); + await tasks.shift()?.(); + expect(socket.read()).toBe("one"); + await tasks.shift()?.(); + await tasks.shift()?.(); + const received: string[] = []; + for await (const chunk of socket) { + received.push(String(chunk)); + } + expect(received).toEqual(["two"]); + }); + + test("decodes split UTF-8 and flushes incomplete characters at EOF", async () => { + const { provider, tasks } = createProvider([ + new Uint8Array([0xe2]), + new Uint8Array([0x82, 0xac, 0xe2]), + ]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("utf8"); + const data: string[] = []; + socket.on("data", (chunk: string) => data.push(chunk)); + await Promise.resolve(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(data).toEqual(["€", "�"]); + }); + + test("supports Node encodings for reads and writes", async () => { + const { provider, tasks, writes } = createProvider([new Uint8Array([0x80, 0xff])]); + const socket = createNet(provider).connect(80, "127.0.0.1").setEncoding("latin1"); + socket.write("cafe", "hex"); + await Promise.resolve(); + await tasks.shift()?.(); + expect(socket.read()).toBe("\u0080\u00ff"); + expect(writes).toEqual([new Uint8Array([0xca, 0xfe])]); + socket.destroy(); + }); + + test("limits onread reads to the supplied buffer without losing bytes", async () => { + const { provider, tasks, readLengths } = createProvider([encoder.encode("abcdef")]); + const data: string[] = []; + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + onread: { + buffer: new Uint8Array(2), + callback(length, buffer) { + data.push(decoder.decode(buffer.subarray(0, length))); + return true; + }, + }, + }); + await Promise.resolve(); + while (tasks.length) { + await tasks.shift()?.(); + } + expect(data.join("")).toBe("abcdef"); + expect(readLengths).toEqual([2n, 2n, 2n, 2n]); + expect(socket.bytesRead).toBe(6); + }); + + test("rejects async iteration on a transport error and disposes resources once", async () => { + const { provider, tasks, disposed, failRead } = createProvider(); + const socket = createNet(provider).connect(80, "127.0.0.1"); + await Promise.resolve(); + const iterator = socket[Symbol.asyncIterator](); + const next = expect(iterator.next()).rejects.toMatchObject({ code: "ECONNRESET" }); + failRead({ tag: "connection-reset" }); + await tasks.shift()?.(); + await next; + socket.destroy(); + expect(disposed.sort()).toEqual(["input", "network", "output", "socket"]); + }); + + test("destroySoon closes a half-open socket after its writes finish", async () => { + const { provider, disposed, writes } = createProvider(); + const socket = createNet(provider).connect({ + port: 80, + host: "127.0.0.1", + allowHalfOpen: true, + }); + await Promise.resolve(); + socket.write("done"); + socket.destroySoon(); + await Promise.resolve(); + expect(socket.destroyed).toBe(true); + expect(writes).toEqual([encoder.encode("done")]); + expect(disposed.sort()).toEqual(["input", "network", "output", "socket"]); + }); + + test("does not emit lookup for a numeric address", async () => { + const { provider } = createProvider(); + const socket = createNet(provider).connect(80, "127.0.0.1"); + const lookups: unknown[] = []; + socket.on("lookup", (...args: unknown[]) => { + lookups.push(args); + }); + await Promise.resolve(); + expect(lookups).toEqual([]); + socket.destroy(); + }); + + test("connects, exposes addresses, writes, reads, and closes", async () => { + const { provider, writes } = clientProvider(); + const net = createNet(provider); + const socket = net.createConnection(8080, "example.com"); + socket.setEncoding("utf8"); + const events: string[] = []; + socket.on("lookup", (() => events.push("lookup")) as never); + socket.on("connect", (() => { + events.push("connect"); + socket.write("request"); + }) as never); + const data = new Promise((resolve) => socket.once("data", resolve as never)); + const close = new Promise((resolve) => socket.once("close", resolve as never)); + expect(await data).toBe("reply"); + await close; + expect(events).toEqual(["lookup", "connect"]); + expect(decoder.decode(writes[0])).toBe("request"); + expect(socket.bytesWritten).toBe(7); + expect(socket.bytesRead).toBe(5); + expect(socket.remoteAddress).toBe("192.0.2.8"); + expect(socket.address()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 49152 }); + }); + + test("rejects IPC, custom lookup, and native-only socket options explicitly", () => { + const net = createNet(clientProvider().provider); + expect(() => net.connect("/tmp/example.sock")).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => net.connect({ port: 80, lookup: () => undefined })).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => new net.Socket().setTypeOfService(1)).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => new net.Socket().resetAndDestroy()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts new file mode 100644 index 000000000..1185a515c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/net/transport.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { + connect, + errorCode, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/wasi-sockets.js"; +import { createProvider } from "./helpers/provider.js"; + +describe("shared WASI TCP transport", () => { + test("recognizes ComponentizeJS result errors", () => { + expect(errorCode(Object.assign(new Error("closed"), { payload: { tag: "closed" } }))).toBe( + "closed", + ); + expect(errorCode({ payload: "would-block" })).toBe("would-block"); + expect(errorCode(new Error("unrelated"))).toBeUndefined(); + }); + test("maps socket allocation failures and releases the network", () => { + const { provider, disposed } = createProvider(); + provider.tcpCreateSocket.createTcpSocket = (): never => { + throw { payload: "access-denied" }; + }; + expect(() => connect(provider, "127.0.0.1", 443)).toThrow( + expect.objectContaining({ code: "EACCES", syscall: "connect", port: 443 }), + ); + expect(disposed).toEqual(["network"]); + }); + + test("terminates when a literal address is filtered by family", () => { + const { provider, disposed } = createProvider(); + expect(() => connect(provider, "127.0.0.1", 80, { family: 6 })).toThrow(); + expect(disposed).toEqual(["network"]); + }); + + test("terminates when a literal address is denied", () => { + const { provider, disposed } = createProvider(); + expect(() => connect(provider, "127.0.0.1", 80, { allowAddress: () => false })).toThrow(); + expect(disposed).toEqual(["network"]); + }); +}); diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index f571596e4..47aa89f4e 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -11,6 +11,9 @@ "declarationMap": true, "skipLibCheck": true, "paths": { + "wasi:sockets/instance-network@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], + "wasi:sockets/ip-name-lookup@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], + "wasi:sockets/tcp-create-socket@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], "jco:node/child-process@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/child-process-interface.d.ts"], "jco:node/cluster@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/cluster-interface.d.ts"], "jco:node/console@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/console-interface.d.ts"], diff --git a/packages/jco-std/wit/node-0.1.0/http.wit b/packages/jco-std/wit/node-0.1.0/http.wit index 2d20a4fbe..7862c1350 100644 --- a/packages/jco-std/wit/node-0.1.0/http.wit +++ b/packages/jco-std/wit/node-0.1.0/http.wit @@ -79,6 +79,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -89,6 +91,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -103,6 +133,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { 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 3e17b780e..07869a7fe 100644 --- a/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/http.wit @@ -81,6 +81,8 @@ interface http { connect-timeout-ms: option, first-byte-timeout-ms: option, between-bytes-timeout-ms: option, + /// Set only for `https` requests that carry TLS options. + tls: option, } record response { @@ -91,6 +93,34 @@ interface http { body: list, } + /// TLS configuration for one side of a connection, mirroring the serializable subset of + /// Node's `tls.createServer` / `tls.connect` options. Material fields are lists because Node + /// accepts arrays of PEM/DER blobs and OpenSSL reads only the first key of a concatenated PEM. + record tls-options { + key: option>>, + cert: option>>, + pfx: option>>, + passphrase: option, + ca: option>>, + crl: option>>, + dhparam: option>, + ciphers: option, + ecdh-curve: option, + sigalgs: option, + min-version: option, + max-version: option, + secure-protocol: option, + secure-options: option, + session-id-context: option, + honor-cipher-order: option, + alpn-protocols: option>, + /// Client side only: the SNI name sent to the server. + servername: option, + reject-unauthorized: option, + /// Server side only: request a client certificate. + request-cert: option, + } + record server-options { request-timeout: option, headers-timeout: option, @@ -105,6 +135,8 @@ interface http { keep-alive-initial-delay: option, reject-non-standard-body-writes: option, optimize-empty-requests: option, + /// Present for every `node:https` server, even when empty: the host terminates TLS. + tls: option, } record listen-options { diff --git a/packages/jco/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 1a4e20b1b..1ffedc1ba 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -45,6 +45,7 @@ const HTTP2_ASYNC_IMPORTS = [ `${HTTP2_CAPABILITY}#[method]server.close`, ]; const DEFAULT_NODE_CAPABILITY_MAP = { + "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 925d5f8e1..6d2eaec3a 100644 --- a/packages/jco/src/node-builtins.ts +++ b/packages/jco/src/node-builtins.ts @@ -15,9 +15,15 @@ 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, + NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + NET_WASI_SOCKETS_WIT_REQUIREMENTS, OS_WIT_REQUIREMENT, type NodeWitRequirement, } from "./node-wit.js"; @@ -56,6 +62,8 @@ 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"; +const NET_SPECIFIER = "node:net"; export const HTTP_CALLBACKS_SPECIFIER = "jco:node-http-callbacks"; const HTTP2_SPECIFIER = "node:http2"; export const HTTP2_CALLBACKS_SPECIFIER = "jco:node-http2-callbacks"; @@ -303,6 +311,11 @@ export interface NodeBuiltinOptions { httpCoreModule?: string; httpWasiSocketsImplementationModule?: string; httpWasiHttpImplementationModule?: string; + /** Paths to jco-std's HTTPS modules (overridable for tests). */ + httpsModule?: string; + httpsCoreModule?: string; + /** Path to jco-std's portable `node:net` core module (overridable for tests). */ + netCoreModule?: string; /** Implementation used for `node:http2` host operations. */ nodejsHttp2Via?: NodejsHttp2Via; /** WASI socket module version supplied by the selected component engine. */ @@ -697,18 +710,32 @@ const HTTP_EXPORTS = [ "validateHeaderValue", ] as const; -function httpExports(moduleExpression: string): string { +/** `node:https` at the pinned release: six exports, no deprecated members. */ +const HTTPS_EXPORTS = ["Agent", "Server", "createServer", "get", "globalAgent", "request"] as const; + +/** The two protocol modules share one core, one implementation set, and one host interface. */ +type HttpProtocol = "http" | "https"; + +const PROTOCOL_EXPORTS: Record = { + http: HTTP_EXPORTS, + https: HTTPS_EXPORTS, +}; + +/** Factory exported by the protocol's core module (`createHttp` / `createHttps`). */ +const PROTOCOL_FACTORY: Record = { http: "createHttp", https: "createHttps" }; + +function protocolExports(protocol: HttpProtocol, moduleExpression: string): string { return ` -const http = ${moduleExpression}; -export default http; -export const { ${HTTP_EXPORTS.join(", ")} } = http; +const ${protocol} = ${moduleExpression}; +export default ${protocol}; +export const { ${PROTOCOL_EXPORTS[protocol].join(", ")} } = ${protocol}; `; } -function httpDirectAdapter(httpModule: string): string { +function protocolDirectAdapter(protocol: HttpProtocol, entryModule: string): string { return ` -import directHttp from ${JSON.stringify(httpModule)}; -${httpExports("directHttp")} +import direct from ${JSON.stringify(entryModule)}; +${protocolExports(protocol, "direct")} `; } @@ -717,28 +744,73 @@ function httpCallbacksAdapter(httpModule: string): string { return `export { httpCallbacks } from ${JSON.stringify(httpModule)};`; } -function httpWasiSocketsAdapter(coreModule: string, implementationModule: string, version: string): string { +interface WasiSocketsProviderSource { + imports: string; + value: string; +} + +/** Shared Preview 2 provider source used by net, HTTP/1, HTTP/2, and HTTPS adapters. */ +function wasiSocketsProviderSource(version: string): WasiSocketsProviderSource { + const u64 = version === "0.2.10" ? "BigInt(value)" : "value"; const schedule = version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""; - return ` + return { + imports: ` import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; -import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; -import { createHttp } from ${JSON.stringify(coreModule)}; +import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}";`, + value: `{ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${u64}${schedule} }`, + }; +} + +function protocolWasiSocketsAdapter( + protocol: HttpProtocol, + coreModule: string, + implementationModule: string, + version: string, +): string { + const factory = PROTOCOL_FACTORY[protocol]; + const provider = wasiSocketsProviderSource(version); + const tlsImports = protocol === "https" ? 'import * as tls from "wasi:tls/types@0.2.0-draft";' : ""; + const providerValue = protocol === "https" ? `{ ...${provider.value}, tls }` : provider.value; + return ` +${provider.imports} +${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(${providerValue}))`)} `; } -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", @@ -781,28 +853,50 @@ function http2PortableAdapter( ): string { const factory = via === "wasi-sockets" ? "createWasiSocketsHttp2Implementation" : "createWasiHttpHttp2Implementation"; - const provider = - via === "wasi-sockets" - ? ` -import * as instanceNetwork from "wasi:sockets/instance-network@${version}"; -import * as ipNameLookup from "wasi:sockets/ip-name-lookup@${version}"; -import * as tcpCreateSocket from "wasi:sockets/tcp-create-socket@${version}"; -` - : ""; - const factoryArguments = - via === "wasi-sockets" - ? `{ instanceNetwork, ipNameLookup, tcpCreateSocket, u64: value => ${version === "0.2.10" ? "BigInt(value)" : "value"}${version === "0.2.10" ? ", schedule: task => setTimeout(task, 0)" : ""} }` - : ""; + const provider = via === "wasi-sockets" ? wasiSocketsProviderSource(version) : undefined; return ` -${provider} +${provider?.imports ?? ""} import { createHttp2 } from ${JSON.stringify(coreModule)}; import { ${factory} } from ${JSON.stringify(implementationModule)}; -${http2Exports(`createHttp2(${factory}(${factoryArguments}))`)} +${http2Exports(`createHttp2(${factory}(${provider?.value ?? ""}))`)} +`; +} + +const NET_EXPORTS = [ + "BlockList", + "BoundSocket", + "Server", + "Socket", + "SocketAddress", + "Stream", + "_createServerHandle", + "_normalizeArgs", + "connect", + "createConnection", + "createServer", + "getDefaultAutoSelectFamily", + "getDefaultAutoSelectFamilyAttemptTimeout", + "isIP", + "isIPv4", + "isIPv6", + "setDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", +] as const; + +function netAdapter(coreModule: string, version: string): string { + const provider = wasiSocketsProviderSource(version); + return ` +${provider.imports} +import { createNet } from ${JSON.stringify(coreModule)}; +const net = createNet(${provider.value}); +export default net; +export const { ${NET_EXPORTS.join(", ")} } = net; `; } function requireWasiHttpVersion( worldMetadata: WorldMetadata, + specifier: string, via: Exclude, version = "0.2.12", ): void { @@ -818,7 +912,7 @@ function requireWasiHttpVersion( if (incompatible) { const { major, minor, patch } = incompatible.version!; throw new Error( - `node:http via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, + `${specifier} via ${via} requires wasi:${packageName}@${version}, but the selected WIT world imports wasi:${packageName}@${major}.${minor}.${patch}`, ); } } @@ -1026,8 +1120,32 @@ 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 netCoreModule = () => stdModule(options.netCoreModule, "net/core"); const httpVia = options.nodejsHttpVia ?? "direct"; const wasiSocketsVersion = options.wasiSocketsVersion ?? "0.2.12"; + const protocolOf = (specifier: string): HttpProtocol | undefined => + specifier === HTTP_SPECIFIER ? "http" : specifier === HTTPS_SPECIFIER ? "https" : undefined; + /** + * Facade for `node:http` or `node:https` under the selected implementation. Each jco-std + * path is resolved only on the branch that emits it, so a build never touches an entry point + * it does not use. + */ + const protocolAdapter = (protocol: HttpProtocol): string => { + if (httpVia === "direct") { + return protocolDirectAdapter(protocol, protocol === "http" ? httpModule() : httpsModule()); + } + const coreModule = protocol === "http" ? httpCoreModule() : httpsCoreModule(); + return httpVia === "wasi-sockets" + ? protocolWasiSocketsAdapter( + protocol, + coreModule, + httpWasiSocketsImplementationModule(), + wasiSocketsVersion, + ) + : protocolWasiHttpAdapter(protocol, coreModule, httpWasiHttpImplementationModule()); + }; const http2Module = () => options.http2Module ?? fileURLToPath(import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2")); @@ -1137,24 +1255,27 @@ 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 { + if (id === NET_SPECIFIER) { + requireWasiHttpVersion(worldMetadata, id, "wasi-sockets", wasiSocketsVersion); + for (const requirement of wasiSocketsVersion === "0.2.12" + ? NET_WASI_SOCKETS_WIT_REQUIREMENTS + : NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS) { + options.onWitRequirement?.(requirement); + } + return `${VIRTUAL_PREFIX}${id}`; + } + const protocol = protocolOf(id); + if (protocol !== undefined) { + if (httpVia !== "direct") { requireWasiHttpVersion( worldMetadata, + id, httpVia, httpVia === "wasi-sockets" ? wasiSocketsVersion : "0.2.12", ); - const requirements = - httpVia === "wasi-sockets" - ? wasiSocketsVersion === "0.2.12" - ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS - : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS - : HTTP_WASI_HTTP_WIT_REQUIREMENTS; - for (const requirement of requirements) { - options.onWitRequirement?.(requirement); - } + } + for (const requirement of protocolWitRequirements(protocol, httpVia, wasiSocketsVersion)) { + options.onWitRequirement?.(requirement); } return `${VIRTUAL_PREFIX}${id}`; } @@ -1162,7 +1283,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui if (http2Via === "direct") { options.onWitRequirement?.(HTTP2_WIT_REQUIREMENT); } else if (http2Via === "wasi-sockets") { - requireWasiHttpVersion(worldMetadata, http2Via, wasiSocketsVersion); + requireWasiHttpVersion(worldMetadata, HTTP2_SPECIFIER, http2Via, wasiSocketsVersion); for (const requirement of wasiSocketsVersion === "0.2.12" ? HTTP_WASI_SOCKETS_WIT_REQUIREMENTS : HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS) { @@ -1254,17 +1375,12 @@ 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()); + if (value === NET_SPECIFIER) { + return netAdapter(netCoreModule(), wasiSocketsVersion); + } + 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..90540546d 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,44 @@ export const HTTP_WASI_HTTP_WIT_REQUIREMENTS = [ wasiRequirement("wasi:http/types@0.2.12", WASI_HTTP_DEPENDENCIES), ] as const; +function forNodeSpecifier(requirements: readonly NodeWitRequirement[], nodeSpecifier: string): NodeWitRequirement[] { + return requirements.map((requirement) => ({ ...requirement, nodeSpecifier })); +} + +function tlsRequirements(): NodeWitRequirement[] { + const tlsRoot = new URL("../lib/wit/builtin/wasi-tls-0.2.0-draft/", import.meta.url); + return forNodeSpecifier( + [ + wasiRequirement("wasi:tls/types@0.2.0-draft", [ + { + dependencyDirectory: "wasi-tls-0.2.0-draft", + dependencySources: ["world.wit", "types.wit"].map((name) => fileURLToPath(new URL(name, tlsRoot))), + }, + WASI_IO_DEPENDENCY, + ]), + ], + "node:https", + ); +} +export const HTTPS_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = [ + ...forNodeSpecifier(HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, "node:https"), + ...tlsRequirements(), +] as const; + +export const HTTPS_WASI_SOCKETS_WIT_REQUIREMENTS = [ + ...forNodeSpecifier(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, "node:https"), + ...tlsRequirements(), +]; + +export const HTTPS_WASI_HTTP_WIT_REQUIREMENTS = forNodeSpecifier(HTTP_WASI_HTTP_WIT_REQUIREMENTS, "node:https"); + +export const NET_WASI_SOCKETS_WIT_REQUIREMENTS = forNodeSpecifier(HTTP_WASI_SOCKETS_WIT_REQUIREMENTS, "node:net"); + +export const NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS = forNodeSpecifier( + HTTP_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + "node:net", +); + export interface WitInjectionResult { witPath: string; worldFile: string; diff --git a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js index 291bedfa5..aecbbc59c 100644 --- a/packages/jco/test/fixtures/componentize/node-http2/run-direct.js +++ b/packages/jco/test/fixtures/componentize/node-http2/run-direct.js @@ -80,8 +80,12 @@ async function request(port, path, secure = false) { } const [key, cert] = await Promise.all([ - readFile(new URL("../../../../../preview2-shim/test/fixtures/tls/localhost.key", import.meta.url)), - readFile(new URL("../../../../../preview2-shim/test/fixtures/tls/localhost.crt", import.meta.url)), + readFile( + new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.key", import.meta.url), + ), + readFile( + new URL("../../../../../jco-std/test/wasi/0.2.x/node/24.x.x/https/helpers/tls/localhost.crt", import.meta.url), + ), ]); const peer = http2.createServer(); peer.on("stream", (stream) => { diff --git a/packages/jco/test/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/componentize/node-net/component.js b/packages/jco/test/fixtures/componentize/node-net/component.js new file mode 100644 index 000000000..089554da4 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/component.js @@ -0,0 +1,50 @@ +import net, { connect, createServer, Socket, Stream } from "node:net"; + +let server; +let handled; + +export function surface() { + const blockList = new net.BlockList(); + blockList.addSubnet("192.0.2.0", 24); + return JSON.stringify({ + exports: Object.keys(net).sort(), + aliases: connect === net.createConnection && Socket === Stream, + ipv6: net.isIP("::ffff:192.0.2.1"), + blocked: blockList.check("192.0.2.5"), + }); +} + +export async function runClient(port) { + const socket = connect(port, "127.0.0.1").setEncoding("utf8"); + const response = new Promise((resolve, reject) => { + let body = ""; + socket.on("data", (chunk) => { + body += chunk; + }); + socket.once("end", () => resolve(body)); + socket.once("error", reject); + }); + // preview2-shim 0.22.0 destroys both directions on shutdown("send"). Let the + // peer close after its reply; directional shutdown is covered by provider tests. + socket.write("client"); + return await response; +} + +export function startServer() { + handled = new Promise((resolve, reject) => { + server = createServer((socket) => { + server.close(); + socket.setEncoding("utf8"); + socket.once("error", reject); + socket.once("data", (chunk) => socket.end(`guest:${chunk}`, resolve)); + }); + server.once("error", reject); + }); + server.listen(0, "127.0.0.1"); + return server.address().port; +} + +export async function serveOne() { + await handled; + await server[Symbol.asyncDispose](); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/peer.js b/packages/jco/test/fixtures/componentize/node-net/peer.js new file mode 100644 index 000000000..289459da6 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/peer.js @@ -0,0 +1,8 @@ +import net from "node:net"; + +// WASI's synchronous pollables require the peer to run outside the guest process. +const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.once("data", (chunk) => socket.end(`host:${chunk}`)); +}); +server.listen(0, "127.0.0.1", () => process.stdout.write(`${server.address().port}\n`)); diff --git a/packages/jco/test/fixtures/componentize/node-net/run.js b/packages/jco/test/fixtures/componentize/node-net/run.js new file mode 100644 index 000000000..965f52158 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/run.js @@ -0,0 +1,63 @@ +import net from "node:net"; +import { spawn } from "node:child_process"; +import { argv, stdout } from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const { instantiate } = await import(pathToFileURL(argv[2])); +const imports = new WASIShim().getImportObject(); +Object.assign(imports["wasi:sockets/instance-network"], imports["wasi:sockets/network"]); +Object.assign(imports["wasi:sockets/ip-name-lookup"], imports["wasi:sockets/network"]); +Object.assign(imports["wasi:sockets/tcp-create-socket"], imports["wasi:sockets/tcp"]); +// The existing StarlingMonkey bindings require these compatibility members. +imports["wasi:sockets/network"].Network.prototype.noop ??= () => {}; +imports["wasi:sockets/network"].networkErrorCode ??= () => undefined; +const instance = await instantiate(undefined, imports); +if (argv[3] === "server") { + const port = await instance.startServer(); + stdout.write(`${port}\n`); + await instance.serveOne(); + process.exit(0); +} +const surface = JSON.parse(await instance.surface()); + +function childPort(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => reject(new Error(`Peer exited before listening: ${code}`))); + child.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim()))); + }); +} + +const host = spawn(process.execPath, [fileURLToPath(new URL("./peer.js", import.meta.url))], { + stdio: ["ignore", "pipe", "inherit"], +}); +let guest; +try { + const client = await instance.runClient(await childPort(host)); + guest = spawn(process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), argv[2], "server"], { + stdio: ["ignore", "pipe", "inherit"], + }); + const guestExit = new Promise((resolve, reject) => { + guest.once("error", reject); + guest.once("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Guest server exited: ${code}`)))); + }); + const port = await childPort(guest); + const response = new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1").setEncoding("utf8"); + let body = ""; + socket.on("data", (chunk) => { + body += chunk; + }); + socket.once("error", reject); + socket.once("end", () => resolve(body)); + socket.end("runner"); + }); + const server = await response; + await guestExit; + stdout.write(`${JSON.stringify({ surface, client, server })}\n`); +} finally { + guest?.kill(); + host.kill(); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit b/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit new file mode 100644 index 000000000..f4a42175a --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/wit-starling/component.wit @@ -0,0 +1,8 @@ +package test:net; + +world component { + export surface: func() -> string; + export run-client: func(port: u16) -> string; + export start-server: func() -> u16; + export serve-one: func(); +} diff --git a/packages/jco/test/fixtures/componentize/node-net/wit/component.wit b/packages/jco/test/fixtures/componentize/node-net/wit/component.wit new file mode 100644 index 000000000..87497958f --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-net/wit/component.wit @@ -0,0 +1,8 @@ +package test:net; + +world component { + export surface: func() -> string; + export run-client: async func(port: u16) -> string; + export start-server: func() -> u16; + export serve-one: async func(); +} diff --git a/packages/jco/test/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 88ea13135..9aa7266e4 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 2c4034d42..d2a41ae76 100644 --- a/packages/jco/test/node/http.js +++ b/packages/jco/test/node/http.js @@ -235,7 +235,15 @@ describe.skipIf(!hasJspi)("node:http in a component", () => { copy: true, extraArgs: ["--backend", "starlingmonkey", "--with-nodejs-http-via", implementation], }); + // `--with-nodejs-http-via` selects which capability is injected; a wrong mode still + // injects *something*, so the assertion names the interface the mode must add. + const injected = { + direct: "jco:node/http@0.1.0", + "wasi-sockets": "wasi:sockets/instance-network@0.2.10", + "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/net.js b/packages/jco/test/node/net.js new file mode 100644 index 000000000..5ceca4132 --- /dev/null +++ b/packages/jco/test/node/net.js @@ -0,0 +1,159 @@ +import { cp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test, vi } from "vitest"; + +import { bundleComponentSource } from "../../src/bundle.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins.js"; +import { + injectNodeWitImports, + NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS, + NET_WASI_SOCKETS_WIT_REQUIREMENTS, +} from "../../src/node-wit.js"; +import { exec, getTmpDir, jcoPath, setupAsyncTest } from "../helpers.js"; + +const NET_EXPORTS = [ + "BlockList", + "BoundSocket", + "Server", + "Socket", + "SocketAddress", + "Stream", + "_createServerHandle", + "_normalizeArgs", + "connect", + "createConnection", + "createServer", + "getDefaultAutoSelectFamily", + "getDefaultAutoSelectFamilyAttemptTimeout", + "isIP", + "isIPv4", + "isIPv6", + "setDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", +]; + +describe("node:net builtin adapter", () => { + test.each(["0.2.12", "0.2.10"])("generates the Preview 2 %s facade", (wasiSocketsVersion) => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { netCoreModule: "/jco/net/core.js", wasiSocketsVersion, onWitRequirement }, + ); + const id = plugin.resolveId("node:net"); + expect(id).toBe("\0jco-node-builtin:node:net"); + const source = plugin.load(id); + expect(source).toContain('from "/jco/net/core.js"'); + expect(source).toContain(`wasi:sockets/instance-network@${wasiSocketsVersion}`); + expect(source).toContain(`wasi:sockets/tcp-create-socket@${wasiSocketsVersion}`); + expect(source).toContain("createNet("); + expect(source).toContain("export default net"); + for (const name of NET_EXPORTS) { + expect(source).toMatch(new RegExp(`\\b${name}\\b`)); + } + expect(onWitRequirement).toHaveBeenCalledTimes(7); + expect(onWitRequirement).toHaveBeenCalledWith( + expect.objectContaining({ + nodeSpecifier: "node:net", + witImport: `wasi:sockets/instance-network@${wasiSocketsVersion}`, + }), + ); + if (wasiSocketsVersion === "0.2.10") { + expect(source).toContain("u64: value => BigInt(value)"); + expect(source).toContain("schedule: task => setTimeout(task, 0)"); + } + }); + + test("does not intercept bare net and tree-shakes an unused builtin", async () => { + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { netCoreModule: "/jco/net/core.js" }); + expect(plugin.resolveId("net")).toBeNull(); + const root = await getTmpDir(); + const entry = join(root, "entry.js"); + await writeFile(entry, "export const answer = 42;\n"); + const source = await bundleComponentSource(entry, { plugins: [plugin] }); + expect(source).not.toContain("wasi:sockets/instance-network"); + expect(source).not.toContain("/jco/net/core.js"); + }); + + test("rejects an incompatible Preview 2 sockets package", () => { + const plugin = nodeBuiltinPlugin( + { + imports: [ + { + namespace: "wasi", + package: "sockets", + interface: "tcp", + version: { major: 0n, minor: 2n, patch: 10n }, + }, + ], + exports: [], + }, + { netCoreModule: "/jco/net/core.js" }, + ); + expect(() => plugin.resolveId("node:net")).toThrow(/node:net via wasi-sockets requires wasi:sockets@0\.2\.12/); + }); +}); + +describe("node:net WIT installation", () => { + test.each([ + ["0.2.12", NET_WASI_SOCKETS_WIT_REQUIREMENTS], + ["0.2.10", NET_WASI_SOCKETS_0_2_10_WIT_REQUIREMENTS], + ])("installs only standard wasi:sockets %s requirements", async (version, requirements) => { + const root = await getTmpDir(); + const world = join(root, "component.wit"); + await writeFile(world, "package test:net;\nworld component {}\n"); + const result = await injectNodeWitImports(root, undefined, requirements); + expect(result?.imports).toContain(`wasi:sockets/instance-network@${version}`); + expect(result?.imports).toContain(`wasi:sockets/tcp@${version}`); + expect(result?.exports).toEqual([]); + const source = await readFile(world, "utf8"); + expect(source).toContain("bundled source imports node:net"); + expect(source).not.toContain("jco:node/net"); + }); +}); + +describe("node:net in a component", () => { + test.each(["quickjs", "starlingmonkey"])( + "runs TCP clients and servers using %s", + async (backend) => { + const root = await getTmpDir(); + const fixture = fileURLToPath(new URL("../fixtures/componentize/node-net/", import.meta.url)); + const wit = join(root, "wit"); + await cp(join(fixture, backend === "quickjs" ? "wit" : "wit-starling"), wit, { recursive: true }); + const requirements = []; + // Exercise the actual adapter against the workspace build, independent of npm publication. + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { + netCoreModule: fileURLToPath( + new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/net/core.js", import.meta.url), + ), + wasiSocketsVersion: backend === "quickjs" ? "0.2.12" : "0.2.10", + onWitRequirement: (requirement) => requirements.push(requirement), + }, + ); + const source = await bundleComponentSource(join(fixture, "component.js"), { plugins: [plugin] }); + const entry = join(root, "component.js"); + await writeFile(entry, source); + await injectNodeWitImports(wit, undefined, requirements); + const componentPath = join(root, "component.wasm"); + await exec(jcoPath, "componentize", entry, "-w", wit, "-o", componentPath, "--backend", backend); + const { esModuleOutputPath, cleanup } = await setupAsyncTest({ + component: { name: `node-net-${backend}`, path: componentPath, skipInstantiation: true }, + jco: { transpile: { extraArgs: { asyncExports: ["*"] } } }, + }); + try { + const output = await exec(join(fixture, "run.js"), esModuleOutputPath); + expect(JSON.parse(output.stdout)).toEqual({ + surface: { exports: NET_EXPORTS, aliases: true, ipv6: 6, blocked: true }, + client: "host:client", + server: "guest:runner", + }); + } finally { + await cleanup(); + } + }, + 600_000, + ); +}); 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 f3937b177..6ac0c243c 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 a6503683a..106a9c9bc 100644 --- a/packages/preview3-shim/package.json +++ b/packages/preview3-shim/package.json @@ -56,7 +56,7 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "pretest": "pnpm run build", - "test": "vitest --run", + "test": "vitest --run -c test/vitest.ts", "prebench": "pnpm run build", "bench": "vitest bench --run", "prepack": "pnpm run build" diff --git a/packages/preview3-shim/test/cli.test.js b/packages/preview3-shim/test/cli.js similarity index 100% rename from packages/preview3-shim/test/cli.test.js rename to packages/preview3-shim/test/cli.js diff --git a/packages/preview3-shim/test/clocks.test.js b/packages/preview3-shim/test/clocks.js similarity index 100% rename from packages/preview3-shim/test/clocks.test.js rename to packages/preview3-shim/test/clocks.js diff --git a/packages/preview3-shim/test/filesystem.test.js b/packages/preview3-shim/test/filesystem.js similarity index 100% rename from packages/preview3-shim/test/filesystem.test.js rename to packages/preview3-shim/test/filesystem.js diff --git a/packages/preview3-shim/test/future.test.js b/packages/preview3-shim/test/future.js similarity index 100% rename from packages/preview3-shim/test/future.test.js rename to packages/preview3-shim/test/future.js diff --git a/packages/preview3-shim/test/http/client.test.js b/packages/preview3-shim/test/http/client.js similarity index 100% rename from packages/preview3-shim/test/http/client.test.js rename to packages/preview3-shim/test/http/client.js diff --git a/packages/preview3-shim/test/http/fields.test.js b/packages/preview3-shim/test/http/fields.js similarity index 100% rename from packages/preview3-shim/test/http/fields.test.js rename to packages/preview3-shim/test/http/fields.js diff --git a/packages/preview3-shim/test/http/request.test.js b/packages/preview3-shim/test/http/request.js similarity index 100% rename from packages/preview3-shim/test/http/request.test.js rename to packages/preview3-shim/test/http/request.js diff --git a/packages/preview3-shim/test/http/response.test.js b/packages/preview3-shim/test/http/response.js similarity index 100% rename from packages/preview3-shim/test/http/response.test.js rename to packages/preview3-shim/test/http/response.js diff --git a/packages/preview3-shim/test/http/server.test.js b/packages/preview3-shim/test/http/server.js similarity index 100% rename from packages/preview3-shim/test/http/server.test.js rename to packages/preview3-shim/test/http/server.js diff --git a/packages/preview3-shim/test/random.test.js b/packages/preview3-shim/test/random.js similarity index 100% rename from packages/preview3-shim/test/random.test.js rename to packages/preview3-shim/test/random.js diff --git a/packages/preview3-shim/test/resource-worker.test.js b/packages/preview3-shim/test/resource-worker.js similarity index 100% rename from packages/preview3-shim/test/resource-worker.test.js rename to packages/preview3-shim/test/resource-worker.js diff --git a/packages/preview3-shim/test/stream.test.js b/packages/preview3-shim/test/stream.js similarity index 100% rename from packages/preview3-shim/test/stream.test.js rename to packages/preview3-shim/test/stream.js diff --git a/packages/preview3-shim/test/tcp.test.js b/packages/preview3-shim/test/tcp.js similarity index 100% rename from packages/preview3-shim/test/tcp.test.js rename to packages/preview3-shim/test/tcp.js diff --git a/packages/preview3-shim/test/udp.test.js b/packages/preview3-shim/test/udp.js similarity index 100% rename from packages/preview3-shim/test/udp.test.js rename to packages/preview3-shim/test/udp.js diff --git a/packages/preview3-shim/test/vitest.ts b/packages/preview3-shim/test/vitest.ts new file mode 100644 index 000000000..b4627ec02 --- /dev/null +++ b/packages/preview3-shim/test/vitest.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.js"], + exclude: ["test/helpers.js", "test/nop-worker.js", "test/**/*.bench.js"], + }, +}); 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();"