diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 78971852e..128ba529d 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,6 +23,7 @@ - [`node:child_process`](./interop/nodejs-builtins/supported-modules/child-process.md) - [`node:cluster`](./interop/nodejs-builtins/supported-modules/cluster.md) - [`node:console`](./interop/nodejs-builtins/supported-modules/console.md) + - [`node:dgram`](./interop/nodejs-builtins/supported-modules/dgram.md) - [`node:diagnostics_channel`](./interop/nodejs-builtins/supported-modules/diagnostics-channel.md) - [`node:dns`](./interop/nodejs-builtins/supported-modules/dns.md) - [`node:domain`](./interop/nodejs-builtins/supported-modules/domain.md) diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index b17d2c05d..41f7a4deb 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -139,7 +139,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:crypto`, `node:http2`, `node:perf_hooks`, `node:repl`, `node:stream`, `node:stream/promises`, `node:stream/web`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, diff --git a/docs/src/interop/nodejs-builtins/supported-modules/dgram.md b/docs/src/interop/nodejs-builtins/supported-modules/dgram.md new file mode 100644 index 000000000..9b70ff51e --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/dgram.md @@ -0,0 +1,83 @@ +# `node:dgram` + +| Imports | Implementation | +| --- | --- | +| `node:dgram` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram` | + +Application code keeps ordinary Node imports: + +```js +import { createSocket } from 'node:dgram'; + +let socket; +export function start() { + socket = createSocket('udp4'); + socket.on('message', (message, remote) => { + socket.send(message, remote.port, remote.address); + }); + return socket.bindSync({ address: '127.0.0.1', port: 0 }).port; +} +export function stop() { socket.close(); } +``` + +Use a world exporting `start: func() -> u16` and `stop: func()`, then build +with `jco componentize source.js --bundle --backend starlingmonkey -w wit -o app.wasm`. +Jco installs `jco:node/dgram@0.1.0` and the guest-exported +`jco:node/dgram-callbacks@0.1.0` interface. UDP adds no unrelated WASI imports. +The default provider returns a catchable `ERR_JCO_DGRAM_ADAPTER_REQUIRED` error +on capability use; importing, constructing, ref/unref, and closing an unused +socket need no host access. + +To grant UDP access, transpile for explicit instantiation: + +```console +jco transpile app.wasm -o out --instantiation async \ + --async-mode jspi --async-exports '*' \ + --map 'jco:node/dgram@0.1.0=jco:node/dgram@0.1.0' +``` + +This mapping preserves the WIT interface name in the instantiation imports. +Wire a separate Node provider to each instance: + +```js +import { instantiate } from './out/app.js'; +import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; +import { createDgramHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host/node'; + +const imports = new WASIShim().getImportObject(); +let instance; +imports['jco:node/dgram@0.1.0'] = createDgramHost(() => instance.dgramCallbacks); +instance = await instantiate(undefined, imports); +const port = await instance.start(); +// Send datagrams to 127.0.0.1:port, then call await instance.stop(). +``` + +The guest implements Node v24.20.0's socket state, overloads, Buffer messages, +lookup customization, block lists, AbortSignal, events, and disposal. IPv4/IPv6, +bind/connect (including their synchronous forms), sends, address queries, +broadcast, multicast memberships, buffer options, and ref/unref use the typed UDP +provider. Host errors preserve codes, errno, address/port, syscall, and buffer +SystemError details. Native descriptors and shared cluster-handle adoption throw +`ERR_JCO_UNSUPPORTED_NODE_API`. The deprecated `_createSocketHandle`, +`_handle`, `_receiving`, `_bindState`, `_queue`, `_reuseAddr`, +`_healthCheck`, and `_stopReceiving` entries immediately throw +`ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`; legacy `sendto` remains functional. + +The Node provider requires native `bindSync` and `connectSync` (available in +Node 24.20.0). It queues datagrams, DNS results, and send completions through the +component's callback exports. Return from exported guest tasks before waiting for +these events on the host; awaiting a future UDP event inside an active guest task +would require component re-entry. Guest-local microtasks replace Node's nextTick +scheduling, and native async-hooks IDs do not cross the boundary. QuickJS currently +traps on host-invoked exported resource methods, so its tests cover the module, +validation, and denial; full UDP component tests use StarlingMonkey. Multicast and +reuse-port availability depend on the host OS. + +The implementation adapts MIT-licensed Node +[lib/dgram.js](https://github.com/nodejs/node/blob/71b8b174857e25106d39b61a9e6f30d927da8b01/lib/dgram.js) +and its internal handle/lookup flow at v24.20.0. Provenance and the license remain +in the source and emitted JavaScript. Audited unenv 2.0.0-rc.24 dgram is a mock +with no-op network methods and fixed addresses/buffer sizes, so Jco uses its own +adapter and reuses the already-supported Buffer/EventEmitter cores. Only +`node:dgram` is intercepted; bare `dgram` is unchanged. Direct jco-std +adapters can coexist with bundled Node builtins. diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index 602048241..23d653482 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -39,6 +39,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:child_process`](./child-process.md) | Synchronous APIs over an explicit application-provided host capability; denied by default. | | [`node:cluster`](./cluster.md) | Primary/worker control over an explicit host capability. Partly unsupported. | | [`node:console`](./console.md) | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. | +| [`node:dgram`](./dgram.md) | UDP sockets over an explicit host capability; denied by default. StarlingMonkey supports the Node passthrough. | | [`node:diagnostics_channel`](./diagnostics-channel.md) | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. | | [`node:dns`](./dns.md), [`node:dns/promises`](./dns.md) | Name resolution over an explicit host capability; denied by default. | | [`node:domain`](./domain.md) | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 4670319e4..a4e4d7fee 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -516,6 +516,25 @@ "./wasi/0.2.x/node/24.x.x/tls/wasi": { "types": "./dist/wasi/0.2.x/node/24.x.x/tls/wasi.d.ts", "default": "./dist/wasi/0.2.x/node/24.x.x/tls/wasi.js" + }, + "./wasi/0.2.x/node/24.x.x/dgram": { + "types": "./dist/wasi/0.2.x/node/24.x.x/dgram.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/dgram.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/dgram.js" + }, + "./wasi/0.2.x/node/24.x.x/dgram/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/dgram/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/dgram/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/dgram/core.js" + }, + "./wasi/0.2.x/node/24.x.x/dgram/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/dgram-host.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/dgram-host.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/dgram-host.js" + }, + "./wasi/0.2.x/node/24.x.x/dgram/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/dgram-host-node.d.ts", + "default": "./dist/wasi/0.2.x/node/24.x.x/dgram-host-node.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts new file mode 100644 index 000000000..acf3aa823 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts @@ -0,0 +1,262 @@ +/** + * Opt-in Node UDP provider. Maps the typed WIT socket to node:dgram v24.20.0 + * (nodejs/node 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/dgram.js, MIT). + * Each provider has its own callback queue; no component's listener can be redeemed + * by another instance. Node objects and resolver callbacks stay on the host. + */ +import * as dgram from "node:dgram"; +import { lookup } from "node:dns"; +import { + CallbackResource, + createCallbackQueue, + retireCallbacks, +} from "./internal/callback-resource.js"; +import { capture } from "./internal/host-error.js"; +import { serializeError } from "./dgram/errors.js"; +import type { + AddressInfo, + DgramCallbacks, + DgramHost, + HostOptions, + HostSocket, + Membership, + Result, + SocketEvent, + SocketListener, + SocketOption, + SocketQuery, +} from "./dgram/types.js"; + +// These APIs landed in Node 24.20.0 after the installed @types/node 24 declarations. +interface NativeSocket extends dgram.Socket { + bindSync(options: { address: string; port: number }): AddressInfo; + + connectSync(port: number, address: string): void; +} + +export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { + const enqueue = createCallbackQueue(); + + class Socket implements HostSocket { + #socket: NativeSocket; + #closed = false; + #listener: CallbackResource; + + constructor( + readonly options: HostOptions, + listener: number, + ) { + this.#socket = dgram.createSocket(options) as NativeSocket; + if ( + typeof this.#socket.bindSync !== "function" || + typeof this.#socket.connectSync !== "function" + ) { + this.#socket.close(); + throw Object.assign( + new Error( + "The node:dgram host provider requires Node with bindSync/connectSync (Node 24.20.0 or newer on the 24.x line)", + ), + { code: "ERR_JCO_DGRAM_HOST_VERSION" }, + ); + } + this.#listener = new CallbackResource( + () => getCallbacks().takeSocketListener(listener), + "ERR_JCO_DGRAM_CALLBACK_REQUIRED", + ); + this.#socket.on("message", (data, remote) => + this.#deliver({ tag: "message", val: { data: new Uint8Array(data), remote } }), + ); + this.#socket.on("error", (error) => + this.#deliver({ tag: "error", val: serializeError(error) }), + ); + } + + #deliver(event: SocketEvent): void { + void enqueue(async () => { + if (!this.#closed || event.tag === "sent") { + await (await this.#listener.get()).event(event); + } + }).catch((error: unknown) => { + // A trapped component cannot safely receive more events. Release the OS + // socket and surface the trap to the embedding application's event loop. + this.close(); + queueMicrotask(() => { + throw error; + }); + }); + } + + bind(address: string, port: number): Result { + return capture(() => this.#socket.bindSync({ address, port }), serializeError); + } + + connect(address: string, port: number): Result { + return capture(() => this.#socket.connectSync(port, address), serializeError); + } + + disconnect(): Result { + return capture(() => this.#socket.disconnect(), serializeError); + } + + resolve(address: string, id: number): void { + lookup(address, this.options.type === "udp4" ? 4 : 6, (error, ip) => { + this.#deliver({ + tag: "resolved", + val: { + id, + result: error ? { tag: "err", val: serializeError(error) } : { tag: "ok", val: ip }, + }, + }); + }); + } + + send( + data: Uint8Array, + port: number | undefined, + address: string | undefined, + callback: number | undefined, + ): Result { + return capture(() => { + const sent = (error: Error | null, bytes: number): void => { + if (callback !== undefined) { + this.#deliver({ + tag: "sent", + val: { + id: callback, + result: error + ? { tag: "err", val: serializeError(error) } + : { tag: "ok", val: bytes }, + }, + }); + } + }; + if (port === undefined) { + this.#socket.send(data, sent); + } else { + this.#socket.send(data, port, address, sent); + } + }, serializeError); + } + + address(remote: boolean): Result { + return capture( + () => (remote ? this.#socket.remoteAddress() : this.#socket.address()), + serializeError, + ); + } + + setOption(option: SocketOption): Result { + return capture(() => { + switch (option.tag) { + case "broadcast": + this.#socket.setBroadcast(option.val); + break; + case "multicast-loopback": + this.#socket.setMulticastLoopback(option.val); + break; + case "ttl": + this.#socket.setTTL(option.val); + break; + case "multicast-ttl": + this.#socket.setMulticastTTL(option.val); + break; + case "recv-buffer": + this.#socket.setRecvBufferSize(option.val); + break; + case "send-buffer": + this.#socket.setSendBufferSize(option.val); + break; + case "multicast-interface": + this.#socket.setMulticastInterface(option.val); + break; + } + }, serializeError); + } + + query(query: SocketQuery): Result { + return capture(() => { + switch (query) { + case "recv-buffer": + return this.#socket.getRecvBufferSize(); + case "send-buffer": + return this.#socket.getSendBufferSize(); + case "send-queue-size": + return this.#socket.getSendQueueSize(); + case "send-queue-count": + return this.#socket.getSendQueueCount(); + } + }, serializeError); + } + + membership( + action: Membership, + group: string, + source: string | undefined, + iface: string | undefined, + ): Result { + return capture(() => { + switch (action) { + case "add": + this.#socket.addMembership(group, iface); + break; + case "drop": + this.#socket.dropMembership(group, iface); + break; + case "add-source": + this.#socket.addSourceSpecificMembership(source!, group, iface); + break; + case "drop-source": + this.#socket.dropSourceSpecificMembership(source!, group, iface); + break; + } + }, serializeError); + } + + close(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#socket.close(() => { + // Node completes outstanding sends before close. Retire only after their + // queued guest callbacks (including an in-flight redemption) have run. + void enqueue(() => retireCallbacks(enqueue, this.#listener)); + }); + } + + setRef(ref: boolean): void { + if (ref) { + this.#socket.ref(); + } else { + this.#socket.unref(); + } + } + + [Symbol.dispose](): void { + this.close(); + } + } + + return { + Socket, + + createSocket: (options, listener) => + capture(() => new Socket(options, listener), serializeError), + }; +} + +const callbackRequired = "UDP sockets require createDgramHost(() => instance.dgramCallbacks)"; + +/** Static mappings retain the WIT module shape but require instance-bound callbacks. */ +export const createSocket: DgramHost["createSocket"] = () => ({ + tag: "err", + val: { name: "Error", code: "ERR_JCO_DGRAM_CALLBACK_REQUIRED", message: callbackRequired }, +}); + +export const Socket: DgramHost["Socket"] = class Socket { + constructor() { + throw new Error(callbackRequired); + } +} as unknown as DgramHost["Socket"]; + +export default { Socket, createSocket, createDgramHost }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host.ts new file mode 100644 index 000000000..7e8fb1427 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host.ts @@ -0,0 +1,21 @@ +import { adapterRequiredMessage } from "./internal/deny-host.js"; +import type { DgramHost } from "./dgram/types.js"; + +/** Typed denial is catchable inside the guest; a throwing WIT constructor would trap. */ +export const createSocket: DgramHost["createSocket"] = () => ({ + tag: "err", + val: { + name: "Error", + code: "ERR_JCO_DGRAM_ADAPTER_REQUIRED", + message: adapterRequiredMessage("node:dgram"), + }, +}); + +// Bindings require the resource prototype even though denial never creates one. +export const Socket: DgramHost["Socket"] = class Socket { + constructor() { + throw new Error(adapterRequiredMessage("node:dgram")); + } +} as unknown as DgramHost["Socket"]; + +export default { createSocket, Socket }; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-interface.d.ts new file mode 100644 index 000000000..6254abe05 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-interface.d.ts @@ -0,0 +1,4 @@ +declare module "jco:node/dgram@0.1.0" { + export const Socket: import("./dgram/types.js").DgramHost["Socket"]; + export const createSocket: import("./dgram/types.js").DgramHost["createSocket"]; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts new file mode 100644 index 000000000..270dbc4bd --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts @@ -0,0 +1,21 @@ +import * as host from "jco:node/dgram@0.1.0"; +import { createDgram } from "./dgram/core.js"; + +const { dgram, dgramCallbacks } = createDgram(host); +export const { Socket, createSocket, _createSocketHandle } = dgram; + +export type Socket = InstanceType; + +export { dgramCallbacks }; + +export type { + SocketOptions, + SocketType, + BindOptions, + AddressInfo, + RemoteInfo, + SendCallback, + LookupFunction, +} from "./dgram/types.js"; + +export default dgram; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/core.ts new file mode 100644 index 000000000..6d48c998d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/core.ts @@ -0,0 +1,854 @@ +/* +Copyright Joyent, Inc. and other Node contributors. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +/** + * Socket lifecycle adapted from nodejs/node lib/dgram.js and lib/internal/dgram.js, + * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT; license above). + * Local adaptations: typed WIT handles replace udp_wrap, callback resources replace + * native callbacks, microtasks replace nextTick, and deprecated internals fail fast. + * Native descriptors/cluster handle adoption are explicitly unsupported. + */ +import { Buffer } from "node:buffer"; +import { EventEmitter } from "node:events"; +import { channel } from "../diagnostics-channel.js"; +import { BlockList } from "../net/block-list.js"; +import { isIP } from "../net/ip.js"; +import { callHost } from "../internal/host-error.js"; +import { normalizeSend } from "./send.js"; +import { + alreadyBound, + connected, + deprecated, + fromHost, + invalidArgType, + invalidArgValue, + notConnected, + notRunning, + outOfRange, + socketError, + unsupported, + validateNumber, + validatePort, + validateString, +} from "./errors.js"; +import type { + AddressInfo, + BindOptions, + ConnectCallback, + DgramHost, + HostSocket, + LookupCallback, + Membership, + Message, + SendCallback, + SocketEvent, + SocketListener, + SocketOption, + SocketOptions, + SocketQuery, + SocketType, +} from "./types.js"; + +export function createDgram(host: DgramHost): import("./types.js").DgramImplementation { + const listeners = new Map(); + let nextListener = 1; + + class Listener implements SocketListener { + constructor(readonly deliver: (event: SocketEvent) => void) {} + + event(event: SocketEvent): void { + this.deliver(event); + } + + [Symbol.dispose](): void {} + } + + const dgramCallbacks = { + SocketListener: Listener, + + takeSocketListener(id: number): Listener | undefined { + const listener = listeners.get(id); + listeners.delete(id); + return listener; + }, + }; + + // Node types expose listeners() as Function[]. The runtime is the same emitter; + // give its inherited API the portable, callable listener declarations. + const Emitter = EventEmitter as unknown as new () => import("./types.js").SocketEvents; + + class Socket extends Emitter { + declare _handle: never; + declare _receiving: never; + declare _bindState: never; + declare _queue: never; + declare _reuseAddr: never; + readonly type: SocketType; + #options: SocketOptions; + #handle?: HostSocket; + #listener?: number; + #closed = false; + #bindState: "unbound" | "binding" | "bound" = "unbound"; + #connectState: "disconnected" | "connecting" | "connected" = "disconnected"; + #queue?: (() => void)[]; + #ref = true; + #nextCallback = 1; + #lookups = new Map(); + #sends = new Map(); + #removeAbort?: () => void; + + constructor(type: SocketType | SocketOptions, listener?: import("./types.js").MessageListener) { + super(); + const options = typeof type === "object" && type !== null ? type : { type }; + for (const name of ["recvBufferSize", "sendBufferSize"] as const) { + const value = options[name]; + if (value) { + validateNumber(value, `options.${name}`); + if (!Number.isInteger(value)) { + throw outOfRange(`options.${name}`, "an integer", value); + } + if (value !== value >>> 0) { + throw outOfRange(`options.${name}`, ">= 0 && <= 4294967295", value); + } + } + } + for (const name of ["receiveBlockList", "sendBlockList"] as const) { + if (options[name] && !BlockList.isBlockList(options[name])) { + throw invalidArgType(`options.${name}`, "net.BlockList", options[name]); + } + } + if (options.lookup !== undefined && typeof options.lookup !== "function") { + throw invalidArgType("lookup", "Function", options.lookup); + } + if (options.type !== "udp4" && options.type !== "udp6") { + throw socketError( + "ERR_SOCKET_BAD_TYPE", + "Bad socket type specified. Valid types are: udp4, udp6", + ); + } + this.type = options.type; + this.#options = { + type: options.type, + lookup: options.lookup, + recvBufferSize: options.recvBufferSize, + sendBufferSize: options.sendBufferSize, + receiveBlockList: options.receiveBlockList, + sendBlockList: options.sendBlockList, + reuseAddr: options.reuseAddr, + reusePort: options.reusePort, + ipv6Only: options.ipv6Only, + }; + if (typeof listener === "function") { + this.on("message", listener); + } + if (options.signal !== undefined) { + const signal = options.signal; + if ( + !signal || + typeof signal.aborted !== "boolean" || + typeof signal.addEventListener !== "function" + ) { + throw invalidArgType("options.signal", "AbortSignal", signal); + } + const abort = (): void => { + if (!this.#closed) { + this.close(); + } + }; + if (signal.aborted) { + abort(); + } else { + signal.addEventListener("abort", abort, { once: true }); + this.#removeAbort = () => signal.removeEventListener("abort", abort); + } + } + const diagnostic = channel("udp.socket"); + if (diagnostic.hasSubscribers) { + diagnostic.publish({ socket: this }); + } + } + + #health(): void { + if (this.#closed) { + throw notRunning(); + } + } + + #ensure(): HostSocket { + this.#health(); + if (!this.#handle) { + if (nextListener > 0xffff_ffff) { + throw socketError("ERR_JCO_DGRAM_CALLBACK_LIMIT", "UDP callback registrations exhausted"); + } + const id = nextListener++; + listeners.set(id, new Listener((event) => this.#event(event))); + try { + this.#handle = callHost( + () => + host.createSocket( + { + type: this.type, + reuseAddr: !!this.#options.reuseAddr, + reusePort: !!this.#options.reusePort, + ipv6Only: !!this.#options.ipv6Only, + }, + id, + ), + fromHost, + ); + this.#listener = id; + if (!this.#ref) { + this.#handle.setRef(false); + } + } catch (error) { + listeners.delete(id); + throw error; + } + } + return this.#handle; + } + + #event(event: SocketEvent): void { + if (this.#closed && event.tag !== "sent") { + return; + } + switch (event.tag) { + case "message": { + const remote = event.val.remote; + if ( + this.#options.receiveBlockList?.check( + remote.address, + remote.family === "IPv6" ? "ipv6" : "ipv4", + ) + ) { + return; + } + const message = Buffer.from(event.val.data); + this.emit("message", message, { ...remote, size: message.length }); + break; + } + case "error": + this.emit("error", fromHost(event.val)); + break; + case "resolved": { + const callback = this.#lookups.get(event.val.id); + this.#lookups.delete(event.val.id); + const result = event.val.result; + callback?.( + result.tag === "err" ? fromHost(result.val) : null, + result.tag === "ok" ? result.val : "", + this.type === "udp4" ? 4 : 6, + ); + break; + } + case "sent": { + const callback = this.#sends.get(event.val.id); + if (!callback) { + return; + } + this.#sends.delete(event.val.id); + const result = event.val.result; + try { + callback?.( + result.tag === "err" ? fromHost(result.val) : null, + result.tag === "ok" ? result.val : 0, + ); + } finally { + if (this.#closed && this.#sends.size === 0) { + this.#finishClose(); + } + } + } + } + } + + #lookup(address: string | undefined, callback: LookupCallback): void { + const name = address || (this.type === "udp4" ? "127.0.0.1" : "::1"); + const family = this.type === "udp4" ? 4 : 6; + if (this.#options.lookup) { + this.#options.lookup(name, family, callback); + return; + } + if (isIP(name) === family) { + queueMicrotask(() => callback(null, name, family)); + return; + } + const id = this.#allocate(); + this.#lookups.set(id, callback); + try { + this.#ensure().resolve(name, id); + } catch (error) { + this.#lookups.delete(id); + throw error; + } + } + + #allocate(): number { + if (this.#nextCallback > 0xffff_ffff) { + throw socketError("ERR_JCO_DGRAM_CALLBACK_LIMIT", "UDP callbacks exhausted"); + } + return this.#nextCallback++; + } + + #enqueue(operation: () => void): void { + if (!this.#queue) { + this.#queue = []; + const failed = (): void => { + this.#queue = undefined; + this.removeListener("listening", ready); + }; + const ready = (): void => { + this.removeListener(EventEmitter.errorMonitor, failed); + const pending = this.#queue; + this.#queue = undefined; + for (const operation of pending ?? []) { + operation(); + } + }; + this.once(EventEmitter.errorMonitor, failed); + this.once("listening", ready); + } + this.#queue.push(operation); + } + + #bound(): void { + this.#bindState = "bound"; + if (this.#options.recvBufferSize) { + this.setRecvBufferSize(this.#options.recvBufferSize); + } + if (this.#options.sendBufferSize) { + this.setSendBufferSize(this.#options.sendBufferSize); + } + } + + bind(port?: number, address?: string, callback?: () => void): this; + bind(port?: number, callback?: () => void): this; + bind(callback?: () => void): this; + bind(options: BindOptions, callback?: () => void): this; + bind( + port?: number | BindOptions | (() => void), + address?: string | (() => void), + callback?: () => void, + ): this { + this.#health(); + if (this.#bindState !== "unbound") { + throw alreadyBound(); + } + if (port !== null && typeof port === "object" && ("fd" in port || "recvStart" in port)) { + unsupported("dgram.Socket.bind(handle/fd)"); + } + const handle = this.#ensure(); + this.#bindState = "binding"; + const cb = + typeof callback === "function" + ? callback + : typeof address === "function" + ? address + : typeof port === "function" + ? port + : undefined; + if (cb) { + const remove = (): void => { + this.removeListener("error", remove); + this.removeListener("listening", listening); + }; + const listening = (): void => { + remove(); + cb.call(this); + }; + this.on("error", remove); + this.on("listening", listening); + } + const options = + typeof port === "object" && port !== null + ? port + : { + port: typeof port === "function" ? undefined : port, + address: typeof address === "string" ? address : undefined, + }; + this.#lookup(options.address || (this.type === "udp4" ? "0.0.0.0" : "::"), (error, ip) => { + if (this.#closed) { + return; + } + if (!error) { + try { + callHost( + () => handle.bind(ip, validatePort(options.port || 0, "Port", true)), + fromHost, + ); + this.#bound(); + } catch (failure) { + error = failure as Error; + } + } + if (error) { + this.#bindState = "unbound"; + this.emit("error", error); + return; + } + this.emit("listening"); + }); + return this; + } + + bindSync(options: BindOptions = {}): AddressInfo { + this.#health(); + if (options === null || typeof options !== "object" || Array.isArray(options)) { + throw invalidArgType("options", "Object", options); + } + if (this.#bindState !== "unbound") { + throw alreadyBound(); + } + if ("fd" in options) { + unsupported("dgram.Socket.bindSync(fd)"); + } + const port = validatePort(options.port ?? 0, "options.port", true); + const address = options.address || (this.type === "udp4" ? "0.0.0.0" : "::"); + validateString(address, "options.address"); + if (!isIP(address)) { + throw invalidArgValue( + "options.address", + address, + "must be a numeric IP address; bindSync does not perform DNS resolution", + ); + } + const result = callHost(() => this.#ensure().bind(address, port), fromHost); + this.#bound(); + queueMicrotask(() => { + if (!this.#closed) { + this.emit("listening"); + } + }); + return result; + } + + connect(port: number, address?: string, callback?: ConnectCallback): void; + connect(port: number, callback?: ConnectCallback): void; + connect(port: number, address?: string | ConnectCallback, callback?: ConnectCallback): void { + port = validatePort(port); + if (typeof address === "function") { + callback = address; + address = ""; + } + if (address === undefined) { + address = ""; + } + validateString(address, "address"); + if (this.#connectState !== "disconnected") { + throw connected(); + } + this.#ensure(); + this.#connectState = "connecting"; + if (this.#bindState === "unbound") { + this.bind({ port: 0, exclusive: true }); + } + const connect = (): void => { + if (callback) { + this.once("connect", callback); + } + this.#lookup(address as string, (error, ip) => { + if (this.#closed) { + return; + } + try { + if (error) { + throw error; + } + this.#checkBlocked(ip); + callHost(() => this.#ensure().connect(ip, port), fromHost); + this.#connectState = "connected"; + } catch (failure) { + this.#connectState = "disconnected"; + queueMicrotask(() => { + if (callback) { + this.removeListener("connect", callback); + callback(failure as Error); + } else { + this.emit("error", failure); + } + }); + return; + } + queueMicrotask(() => { + if (!this.#closed) { + this.emit("connect"); + } + }); + }); + }; + if (this.#bindState !== "bound") { + this.#enqueue(connect); + } else { + connect(); + } + } + + connectSync(port: number, address?: string): void { + this.#health(); + port = validatePort(port); + if (this.#connectState !== "disconnected") { + throw connected(); + } + address = address || (this.type === "udp4" ? "127.0.0.1" : "::1"); + validateString(address, "address"); + if (!isIP(address)) { + throw invalidArgValue( + "address", + address, + "must be a numeric IP address; connectSync does not perform DNS resolution", + ); + } + if (this.#bindState === "unbound") { + this.bindSync(); + } else if (this.#bindState !== "bound") { + throw alreadyBound(); + } + this.#checkBlocked(address); + callHost(() => this.#ensure().connect(address, port), fromHost); + this.#connectState = "connected"; + queueMicrotask(() => { + if (!this.#closed) { + this.emit("connect"); + } + }); + } + + #checkBlocked(address: string): void { + if (this.#options.sendBlockList?.check(address, isIP(address) === 6 ? "ipv6" : "ipv4")) { + throw socketError("ERR_IP_BLOCKED", `IP ${address} is blocked`); + } + } + + disconnect(): void { + if (this.#connectState !== "connected") { + throw notConnected(); + } + callHost(() => this.#ensure().disconnect(), fromHost); + this.#connectState = "disconnected"; + } + + send(message: Message, callback?: SendCallback): void; + send(message: Message, port: number, callback?: SendCallback): void; + send(message: Message, port: number, address?: string, callback?: SendCallback): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + callback?: SendCallback, + ): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + port: number, + callback?: SendCallback, + ): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + port: number, + address?: string, + callback?: SendCallback, + ): void; + send( + buffer: unknown, + offset?: unknown, + length?: unknown, + port?: unknown, + address?: unknown, + callback?: unknown, + ): void { + const connected = this.#connectState === "connected"; + const packet = normalizeSend(connected, buffer, offset, length, port, address, callback); + this.#health(); + this.#ensure(); + if (this.#bindState === "unbound") { + this.bind({ port: 0, exclusive: true }); + } + const send = (): void => { + const afterLookup: LookupCallback = (error, ip) => { + if (this.#closed) { + return; + } + if (error) { + queueMicrotask(() => { + if (packet.callback) { + packet.callback(error, 0); + } else { + this.emit("error", error); + } + }); + return; + } + let id: number | undefined; + try { + if (!connected) { + this.#checkBlocked(ip); + } + if (packet.callback) { + id = this.#allocate(); + this.#sends.set(id, packet.callback); + } + callHost( + () => + this.#ensure().send( + packet.data, + connected ? undefined : packet.port, + connected ? undefined : ip, + id, + ), + fromHost, + ); + } catch (failure) { + if (id !== undefined) { + this.#sends.delete(id); + } + if (packet.callback) { + queueMicrotask(() => packet.callback!(failure as Error, 0)); + } + } + }; + if (connected) { + afterLookup(null, ""); + } else { + this.#lookup(packet.address, afterLookup); + } + }; + if (this.#bindState !== "bound") { + this.#enqueue(send); + } else { + send(); + } + } + + sendto( + buffer: string | ArrayBufferView, + offset: number, + length: number, + port: number, + address: string, + callback?: SendCallback, + ): void { + validateNumber(offset, "offset"); + validateNumber(length, "length"); + validateNumber(port, "port"); + validateString(address, "address"); + this.send(buffer, offset, length, port, address, callback); + } + + close(callback?: () => void): this { + if (typeof callback === "function") { + this.on("close", callback); + } + if (this.#queue) { + this.#queue.push(() => this.close()); + return this; + } + this.#health(); + this.#handle?.close(); + this.#handle?.[Symbol.dispose](); + this.#handle = undefined; + this.#closed = true; + this.#lookups.clear(); + this.#removeAbort?.(); + if (this.#sends.size === 0) { + this.#finishClose(); + } + return this; + } + + #finishClose(): void { + if (this.#listener !== undefined) { + listeners.delete(this.#listener); + } + queueMicrotask(() => this.emit("close")); + } + + async [Symbol.asyncDispose](): Promise { + if (this.#closed) { + return; + } + await new Promise((resolve) => this.close(resolve)); + } + + address(): AddressInfo { + this.#health(); + return callHost(() => this.#ensure().address(false), fromHost); + } + + remoteAddress(): AddressInfo { + this.#health(); + if (this.#connectState !== "connected") { + throw notConnected(); + } + return callHost(() => this.#ensure().address(true), fromHost); + } + + #option(option: SocketOption): void { + callHost(() => this.#ensure().setOption(option), fromHost); + } + + #query(query: SocketQuery): number { + return callHost(() => this.#ensure().query(query), fromHost); + } + + setBroadcast(flag: boolean): void { + this.#option({ tag: "broadcast", val: !!flag }); + } + + setTTL(ttl: number): number { + validateNumber(ttl, "ttl"); + this.#option({ tag: "ttl", val: ttl }); + return ttl; + } + + setMulticastTTL(ttl: number): number { + validateNumber(ttl, "ttl"); + this.#option({ tag: "multicast-ttl", val: ttl }); + return ttl; + } + + setMulticastLoopback(flag: boolean): boolean { + this.#option({ tag: "multicast-loopback", val: !!flag }); + return flag; + } + + setMulticastInterface(address: string): void { + this.#health(); + validateString(address, "interfaceAddress"); + this.#option({ tag: "multicast-interface", val: address }); + } + + #membership(action: Membership, group: string, source?: string, iface?: string): void { + this.#health(); + if (source !== undefined) { + validateString(source, "sourceAddress"); + validateString(group, "groupAddress"); + } else if (!group) { + throw socketError("ERR_MISSING_ARGS", 'The "multicastAddress" argument must be specified'); + } + validateString(group, "multicastAddress"); + if (iface !== undefined) { + validateString(iface, "interfaceAddress"); + } + callHost(() => this.#ensure().membership(action, group, source, iface), fromHost); + } + + addMembership(group: string, iface?: string): void { + this.#membership("add", group, undefined, iface); + } + + dropMembership(group: string, iface?: string): void { + this.#membership("drop", group, undefined, iface); + } + + addSourceSpecificMembership(source: string, group: string, iface?: string): void { + validateString(source, "sourceAddress"); + this.#membership("add-source", group, source, iface); + } + + dropSourceSpecificMembership(source: string, group: string, iface?: string): void { + validateString(source, "sourceAddress"); + this.#membership("drop-source", group, source, iface); + } + + #buffer(size: number, tag: "recv-buffer" | "send-buffer"): void { + if (size !== size >>> 0) { + throw socketError("ERR_SOCKET_BAD_BUFFER_SIZE", "Buffer size must be a positive integer"); + } + this.#option({ tag, val: size }); + } + + setRecvBufferSize(size: number): void { + this.#buffer(size, "recv-buffer"); + } + + setSendBufferSize(size: number): void { + this.#buffer(size, "send-buffer"); + } + + getRecvBufferSize(): number { + return this.#query("recv-buffer"); + } + + getSendBufferSize(): number { + return this.#query("send-buffer"); + } + + getSendQueueSize(): number { + return this.#query("send-queue-size"); + } + + getSendQueueCount(): number { + return this.#query("send-queue-count"); + } + + ref(): this { + this.#ref = true; + this.#handle?.setRef(true); + return this; + } + + unref(): this { + this.#ref = false; + this.#handle?.setRef(false); + return this; + } + + _healthCheck(): never { + return deprecated("dgram.Socket._healthCheck()"); + } + + _stopReceiving(): never { + return deprecated("dgram.Socket._stopReceiving()"); + } + } + + // Node's prototype assignments are enumerable; class syntax defaults otherwise. + for (const name of Reflect.ownKeys(Socket.prototype)) { + if (name !== "constructor") { + Object.defineProperty(Socket.prototype, name, { enumerable: true }); + } + } + for (const name of ["_handle", "_receiving", "_bindState", "_queue", "_reuseAddr"]) { + Object.defineProperty(Socket.prototype, name, { + get: () => deprecated(`dgram.Socket.${name}`), + + set: () => deprecated(`dgram.Socket.${name}`), + }); + } + + function createSocket( + type: SocketType | SocketOptions, + listener?: import("./types.js").MessageListener, + ): Socket { + return new Socket(type, listener); + } + + function _createSocketHandle(): never { + return deprecated("dgram._createSocketHandle()"); + } + + const dgram = { _createSocketHandle, createSocket, Socket }; + return { dgram, dgramCallbacks }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/errors.ts new file mode 100644 index 000000000..60de2049c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/errors.ts @@ -0,0 +1,150 @@ +import { + codedError, + deprecatedNodeApi, + invalidArgType, + invalidArgValue, + outOfRange, + unsupportedNodeApi, +} from "../errors/core.js"; +import { + decodeErrno, + serializeHostError, + errorRecord, + stringField, +} from "../internal/host-error.js"; +import type { DgramError } from "./types.js"; + +export { invalidArgType, invalidArgValue, outOfRange }; + +export function socketError(code: string, message: string): Error & { code: string } { + return codedError( + code === "ERR_SOCKET_BAD_BUFFER_SIZE" || code === "ERR_SOCKET_BAD_TYPE" + ? new TypeError(message) + : new Error(message), + code, + ); +} + +export function notRunning(): Error { + return socketError("ERR_SOCKET_DGRAM_NOT_RUNNING", "Not running"); +} + +export function alreadyBound(): Error { + return socketError("ERR_SOCKET_ALREADY_BOUND", "Socket is already bound"); +} + +export function connected(): Error { + return socketError("ERR_SOCKET_DGRAM_IS_CONNECTED", "Already connected"); +} + +export function notConnected(): Error { + return socketError("ERR_SOCKET_DGRAM_NOT_CONNECTED", "Not connected"); +} + +export function deprecated(api: string): never { + throw deprecatedNodeApi(api, "dgram.createSocket() and the public Socket methods"); +} + +export function unsupported(api: string): never { + throw unsupportedNodeApi( + api, + "Native file descriptors and shared cluster handles cannot cross a component boundary", + ); +} + +export function serializeError(error: unknown): DgramError { + const record = errorRecord(error); + const info = errorRecord(record.info); + const bufferInfo = + typeof info.errno === "number" && + typeof info.code === "string" && + typeof info.message === "string" && + typeof info.syscall === "string" + ? { errno: info.errno, code: info.code, message: info.message, syscall: info.syscall } + : undefined; + return { + ...serializeHostError(error), + info: bufferInfo, + address: stringField(record.address), + port: typeof record.port === "number" ? record.port : undefined, + }; +} + +export function fromHost(error: DgramError): Error { + const result = + error.name === "TypeError" + ? new TypeError(error.message) + : error.name === "RangeError" + ? new RangeError(error.message) + : new Error(error.message); + for (const [name, value] of Object.entries({ + code: error.code, + errno: decodeErrno(error.errno), + syscall: error.syscall, + address: error.address, + port: error.port, + })) { + if (value !== undefined) { + Object.defineProperty(result, name, { + value, + configurable: true, + writable: true, + enumerable: true, + }); + } + } + if (error.name !== result.name) { + Object.defineProperty(result, "name", { + value: error.name, + configurable: true, + writable: true, + }); + } + if (error.info) { + const info: Record = { ...error.info }; + Object.defineProperty(result, "info", { value: info, configurable: true, enumerable: true }); + for (const key of ["errno", "syscall"] as const) { + Object.defineProperty(result, key, { + get: () => info[key], + + set: (value: unknown) => { + info[key] = value; + }, + + configurable: true, + enumerable: true, + }); + } + } + return result; +} + +export function validatePort(value: unknown, name = "Port", zero = false): number { + if ( + (typeof value !== "number" && typeof value !== "string") || + (typeof value === "string" && !value.trim()) || + +value !== +value >>> 0 || + +value > 65535 || + (!zero && +value === 0) + ) { + throw codedError( + new RangeError( + `${name} should be >${zero ? "=" : ""} 0 and < 65536. Received type ${typeof value} (${String(value)}).`, + ), + "ERR_SOCKET_BAD_PORT", + ); + } + return +value; +} + +export function validateString(value: unknown, name: string): asserts value is string { + if (typeof value !== "string") { + throw invalidArgType(name, "string", value); + } +} + +export function validateNumber(value: unknown, name: string): asserts value is number { + if (typeof value !== "number") { + throw invalidArgType(name, "number", value); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts new file mode 100644 index 000000000..db1e5fa22 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts @@ -0,0 +1,128 @@ +/* +Copyright Joyent, Inc. and other Node contributors. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +/** + * Adapted from nodejs/node lib/dgram.js (sliceBuffer, fixBufferList, send), + * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT. + * License above. Local changes: typed unknown input and a single + * copied WIT byte list after Node's overload and byte-offset normalization. + */ +import { Buffer } from "node:buffer"; +import { codedError } from "../errors/core.js"; +import { connected, invalidArgType, validatePort, validateString } from "./errors.js"; +import type { SendCallback } from "./types.js"; + +function bytes(value: unknown): Uint8Array { + if (typeof value === "string") { + return Buffer.from(value); + } + if (!ArrayBuffer.isView(value)) { + throw invalidArgType("buffer", ["Buffer", "TypedArray", "DataView", "string"], value); + } + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +function slice(value: unknown, offset: unknown, length: unknown): Uint8Array { + const buffer = bytes(value); + const start = (offset as number) >>> 0; + const size = (length as number) >>> 0; + if (start > buffer.byteLength || start + size > buffer.byteLength) { + throw codedError( + new RangeError( + `"${start > buffer.byteLength ? "offset" : "length"}" is outside of buffer bounds`, + ), + "ERR_BUFFER_OUT_OF_BOUNDS", + ); + } + return buffer.subarray(start, start + size); +} + +export function normalizeSend( + isConnected: boolean, + buffer: unknown, + offset?: unknown, + length?: unknown, + port?: unknown, + address?: unknown, + callback?: unknown, +): { data: Uint8Array; port?: number; address?: string; callback?: SendCallback } { + if (!isConnected) { + if (address || (port && typeof port !== "function")) { + buffer = slice(buffer, offset, length); + } else { + callback = port; + port = offset; + address = length; + } + } else { + if (typeof length === "number") { + buffer = slice(buffer, offset, length); + if (typeof port === "function") { + callback = port; + port = undefined; + } + } else { + callback = offset; + } + if (port || address) { + throw connected(); + } + } + const list: Uint8Array[] = []; + if (Array.isArray(buffer)) { + for (let index = 0; index < buffer.length; index++) { + const value: unknown = buffer[index]; + if (typeof value !== "string" && !ArrayBuffer.isView(value)) { + throw invalidArgType( + "buffer list arguments", + ["Buffer", "TypedArray", "DataView", "string"], + buffer, + ); + } + list.push(bytes(value)); + } + } else { + list.push(bytes(buffer)); + } + const targetPort = isConnected ? undefined : validatePort(port); + if (typeof callback !== "function") { + callback = undefined; + } + if (typeof address === "function") { + callback = address; + address = undefined; + } else if (address != null) { + validateString(address, "address"); + } + const data = new Uint8Array(list.reduce((size, part) => size + part.byteLength, 0)); + let position = 0; + for (const part of list) { + data.set(part, position); + position += part.byteLength; + } + return { + data, + port: targetPort, + address: address as string | undefined, + callback: callback as SendCallback | undefined, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts new file mode 100644 index 000000000..1c97578a4 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts @@ -0,0 +1,336 @@ +import type { BlockList } from "../net/block-list.js"; +import type { HostErrorBase, HostResult } from "../internal/wit-types.js"; + +export type SocketType = "udp4" | "udp6"; + +export interface AddressInfo { + address: string; + family: string; + port: number; +} + +export interface RemoteInfo extends AddressInfo { + family: "IPv4" | "IPv6"; + size: number; +} + +export interface SocketError extends Error { + code?: string; + errno?: number | string; + syscall?: string; + address?: string; + port?: number; + info?: BufferErrorInfo; +} + +export type LookupCallback = (error: SocketError | null, address: string, family?: number) => void; + +/** Node 24.20 internal/dgram.js passes a numeric family, despite older @types/node declarations. */ +export type LookupFunction = (hostname: string, family: number, callback: LookupCallback) => void; + +export interface SocketOptions { + type: SocketType; + reuseAddr?: boolean; + reusePort?: boolean; + ipv6Only?: boolean; + recvBufferSize?: number; + sendBufferSize?: number; + lookup?: LookupFunction; + signal?: AbortSignal; + receiveBlockList?: BlockList; + sendBlockList?: BlockList; +} + +export interface BindOptions { + port?: number; + address?: string; + exclusive?: boolean; + fd?: number; +} + +export type SendCallback = (error: SocketError | null, bytes: number) => void; + +export type ConnectCallback = (error?: SocketError) => void; + +export type Message = string | ArrayBufferView | readonly (string | ArrayBufferView)[]; + +export interface BufferErrorInfo { + errno: number; + code: string; + message: string; + syscall: string; +} + +export interface DgramError extends HostErrorBase { + address?: string; + port?: number; + info?: BufferErrorInfo; +} + +export type Result = HostResult; + +export interface HostOptions { + type: SocketType; + reuseAddr: boolean; + reusePort: boolean; + ipv6Only: boolean; +} + +export type SocketOption = + | { tag: "broadcast" | "multicast-loopback"; val: boolean } + | { tag: "ttl" | "multicast-ttl" | "recv-buffer" | "send-buffer"; val: number } + | { tag: "multicast-interface"; val: string }; + +export type SocketQuery = "recv-buffer" | "send-buffer" | "send-queue-size" | "send-queue-count"; + +export type Membership = "add" | "drop" | "add-source" | "drop-source"; + +export type SocketEvent = + | { tag: "message"; val: { data: Uint8Array; remote: AddressInfo } } + | { tag: "error"; val: DgramError } + | { tag: "resolved"; val: { id: number; result: Result } } + | { tag: "sent"; val: { id: number; result: Result } }; + +export interface SocketListener extends Disposable { + event(event: SocketEvent): void | Promise; +} + +export interface DgramCallbacks { + takeSocketListener(id: number): SocketListener | undefined | Promise; +} + +export interface HostSocket extends Disposable { + bind(address: string, port: number): AddressInfo | Result; + + connect(address: string, port: number): void | Result; + + disconnect(): void | Result; + + resolve(address: string, id: number): void; + + send( + data: Uint8Array, + port: number | undefined, + address: string | undefined, + callback: number | undefined, + ): void | Result; + + address(remote: boolean): AddressInfo | Result; + + setOption(option: SocketOption): void | Result; + + query(query: SocketQuery): number | Result; + + membership( + action: Membership, + group: string, + source: string | undefined, + iface: string | undefined, + ): void | Result; + + close(): void; + + setRef(ref: boolean): void; +} + +export interface DgramHost { + Socket: new (options: HostOptions, listener: number) => HostSocket; + + createSocket(options: HostOptions, listener: number): HostSocket | Result; +} + +/** Datagram bytes are runtime Buffers; this portable view exposes byte/text operations. */ +export interface DatagramBuffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + + equals(other: Uint8Array): boolean; +} + +export type MessageListener = (message: DatagramBuffer, remote: RemoteInfo) => void; + +/** EventEmitter's portable public contract, shared with the classic stream declarations. */ +type Listener = (...args: unknown[]) => void; + +export interface SocketEvents { + on(event: "message", listener: MessageListener): this; + on(event: "error", listener: (error: SocketError) => void): this; + on(event: "close" | "connect" | "listening", listener: () => void): this; + on(event: string | symbol, listener: (...args: T) => void): this; + + once(event: "message", listener: MessageListener): this; + once(event: "error", listener: (error: SocketError) => void): this; + once(event: "close" | "connect" | "listening", listener: () => void): this; + once(event: string | symbol, listener: (...args: T) => void): this; + + off(event: "message", listener: MessageListener): this; + off(event: "error", listener: (error: SocketError) => void): this; + off(event: "close" | "connect" | "listening", listener: () => void): this; + off(event: string | symbol, listener: (...args: T) => void): this; + + addListener(event: "message", listener: MessageListener): this; + addListener(event: "error", listener: (error: SocketError) => void): this; + addListener(event: "close" | "connect" | "listening", listener: () => void): this; + addListener(event: string | symbol, listener: (...args: T) => void): this; + + removeListener(event: "message", listener: MessageListener): this; + removeListener(event: "error", listener: (error: SocketError) => void): this; + removeListener(event: "close" | "connect" | "listening", listener: () => void): this; + removeListener(event: string | symbol, listener: (...args: T) => void): this; + + removeAllListeners(event?: string | symbol): this; + + prependListener(event: "message", listener: MessageListener): this; + prependListener(event: "error", listener: (error: SocketError) => void): this; + prependListener(event: "close" | "connect" | "listening", listener: () => void): this; + prependListener( + event: string | symbol, + listener: (...args: T) => void, + ): this; + + prependOnceListener(event: "message", listener: MessageListener): this; + prependOnceListener(event: "error", listener: (error: SocketError) => void): this; + prependOnceListener(event: "close" | "connect" | "listening", listener: () => void): this; + prependOnceListener( + event: string | symbol, + listener: (...args: T) => void, + ): this; + + emit(event: string | symbol, ...args: unknown[]): boolean; + + eventNames(): Array; + + listeners(event: string | symbol): Listener[]; + + rawListeners(event: string | symbol): Listener[]; + + listenerCount(event: string | symbol, listener?: Listener): number; + + getMaxListeners(): number; + + setMaxListeners(count: number): this; +} + +export interface Socket extends SocketEvents, AsyncDisposable { + [Symbol.asyncDispose](): Promise; + + readonly type: SocketType; + + bind(port?: number, address?: string, callback?: () => void): this; + bind(port?: number, callback?: () => void): this; + bind(callback?: () => void): this; + bind(options: BindOptions, callback?: () => void): this; + + bindSync(options?: BindOptions): AddressInfo; + + connect(port: number, address?: string, callback?: ConnectCallback): void; + connect(port: number, callback?: ConnectCallback): void; + + connectSync(port: number, address?: string): void; + + disconnect(): void; + + send(message: Message, callback?: SendCallback): void; + send(message: Message, port: number, callback?: SendCallback): void; + send(message: Message, port: number, address?: string, callback?: SendCallback): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + callback?: SendCallback, + ): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + port: number, + callback?: SendCallback, + ): void; + send( + message: string | ArrayBufferView, + offset: number, + length: number, + port: number, + address?: string, + callback?: SendCallback, + ): void; + + sendto( + message: string | ArrayBufferView, + offset: number, + length: number, + port: number, + address: string, + callback?: SendCallback, + ): void; + + close(callback?: () => void): this; + + address(): AddressInfo; + + remoteAddress(): AddressInfo; + + setBroadcast(flag: boolean): void; + + setTTL(ttl: number): number; + + setMulticastTTL(ttl: number): number; + + setMulticastLoopback(flag: boolean): boolean; + + setMulticastInterface(address: string): void; + + addMembership(group: string, iface?: string): void; + + dropMembership(group: string, iface?: string): void; + + addSourceSpecificMembership(source: string, group: string, iface?: string): void; + + dropSourceSpecificMembership(source: string, group: string, iface?: string): void; + + setRecvBufferSize(size: number): void; + + setSendBufferSize(size: number): void; + + getRecvBufferSize(): number; + + getSendBufferSize(): number; + + getSendQueueSize(): number; + + getSendQueueCount(): number; + + ref(): this; + + unref(): this; + + _healthCheck(): never; + + _stopReceiving(): never; + + _handle: never; + _receiving: never; + _bindState: never; + _queue: never; + _reuseAddr: never; +} + +export interface SocketConstructor { + new (type: SocketType | SocketOptions, listener?: MessageListener): Socket; + + prototype: Socket; +} + +export interface DgramModule { + Socket: SocketConstructor; + + createSocket(type: SocketType | SocketOptions, listener?: MessageListener): Socket; + + _createSocketHandle(...args: unknown[]): never; +} + +export interface DgramImplementation { + dgram: DgramModule; + dgramCallbacks: DgramCallbacks & { + SocketListener: new (deliver: (event: SocketEvent) => void) => SocketListener; + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts index 3e5108f9c..cd1c7f194 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/errors/core.ts @@ -29,6 +29,10 @@ export type ErrorCode = | "ERR_JCO_CLUSTER_ADAPTER_REQUIRED" | "ERR_JCO_CONSOLE_ADAPTER_REQUIRED" | "ERR_JCO_DNS_ADAPTER_REQUIRED" + | "ERR_JCO_DGRAM_ADAPTER_REQUIRED" + | "ERR_JCO_DGRAM_CALLBACK_LIMIT" + | "ERR_JCO_DGRAM_CALLBACK_REQUIRED" + | "ERR_JCO_DGRAM_HOST_VERSION" | "ERR_JCO_FFI_ADAPTER_REQUIRED" | "ERR_JCO_FS_ADAPTER_REQUIRED" | "ERR_JCO_HTTP_ADAPTER_REQUIRED" diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/bind.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/bind.ts new file mode 100644 index 000000000..0ef22faae --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/bind.ts @@ -0,0 +1,41 @@ +import { expect, test } from "vitest"; +import { setup, event } from "./helpers/setup.js"; + +test.each(["udp4", "udp6"] as const)("bind defaults and overloads for %s", async (type) => { + const dgram = setup(); + for (const bind of [ + (socket: ReturnType) => socket.bind(), + (socket: ReturnType) => socket.bind(0), + (socket: ReturnType) => socket.bind({ port: 0 }), + (socket: ReturnType) => socket.bind(() => {}), + ]) { + const socket = dgram.createSocket(type); + try { + const listening = event(socket, "listening"); + bind(socket); + await listening; + expect(socket.address()).toMatchObject({ + family: type === "udp4" ? "IPv4" : "IPv6", + address: type === "udp4" ? "0.0.0.0" : "::", + }); + expect(socket.address().port).toBeGreaterThan(0); + } finally { + socket.close(); + } + } +}); + +test("bindSync validates before state mutation", () => { + const socket = setup().createSocket("udp4"); + try { + expect(() => socket.bindSync({ address: "localhost" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); + expect(() => socket.bindSync({ port: -1 })).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_BAD_PORT" }), + ); + expect(socket.bindSync({ address: "127.0.0.1" }).port).toBeGreaterThan(0); + } finally { + socket.close(); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/close.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/close.ts new file mode 100644 index 000000000..c4e4dc6dc --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/close.ts @@ -0,0 +1,47 @@ +import { expect, test } from "vitest"; +import { setup, event } from "./helpers/setup.js"; + +test("close drains accepted send callbacks before emitting close", async () => { + const socket = setup().createSocket("udp4"); + socket.connectSync(12345, "127.0.0.1"); + const order: string[] = []; + socket.send("x", (error, count) => { + expect(error).toBeNull(); + expect(count).toBe(1); + order.push("send"); + }); + const closed = event(socket, "close"); + socket.close(() => order.push("close")); + await closed; + expect(order).toEqual(["send", "close"]); +}); + +test("closing during a custom bind lookup suppresses late listening", async () => { + let complete: (() => void) | undefined; + const socket = setup().createSocket({ + type: "udp4", + + lookup(_name, _family, callback) { + complete = () => callback(null, "127.0.0.1", 4); + }, + }); + let listening = false; + socket.bind(0, () => { + listening = true; + }); + await socket[Symbol.asyncDispose](); + complete!(); + await Promise.resolve(); + expect(listening).toBe(false); +}); + +test("close after an implicit bind flushes the queued operation once", async () => { + const socket = setup().createSocket("udp4"); + const closed = event(socket, "close"); + socket.send("x", 12345, "127.0.0.1"); + socket.close(); + await closed; + expect(() => socket.bind(0)).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_NOT_RUNNING" }), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/connect.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/connect.ts new file mode 100644 index 000000000..07e911986 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/connect.ts @@ -0,0 +1,38 @@ +import { expect, test } from "vitest"; +import { setup, event } from "./helpers/setup.js"; + +test("connecting state rejects repeated calls; lookup failure is retryable", async () => { + let fail = false; + const socket = setup().createSocket({ + type: "udp4", + + lookup(name, _family, callback) { + queueMicrotask(() => + fail && name === "bad" + ? callback(Object.assign(new Error("no host"), { code: "ENOTFOUND" }), "") + : callback(null, "127.0.0.1"), + ); + }, + }); + try { + socket.bindSync(); + fail = true; + const failed = new Promise((resolve) => + socket.connect(12345, "bad", resolve), + ); + expect(() => socket.connect(12345)).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_IS_CONNECTED" }), + ); + expect(await failed).toMatchObject({ code: "ENOTFOUND" }); + const connected = event(socket, "connect"); + socket.connect(12345); + await connected; + expect(socket.remoteAddress().port).toBe(12345); + socket.disconnect(); + expect(() => socket.disconnect()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_NOT_CONNECTED" }), + ); + } finally { + socket.close(); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/create-socket.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/create-socket.ts new file mode 100644 index 000000000..c80fff252 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/create-socket.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import nodeDgram from "node:dgram"; +import { setup, errorShape } from "./helpers/setup.js"; + +test.skipIf(!process.versions.node.startsWith("24."))( + "createSocket validates options against Node 24", + () => { + const dgram = setup(); + const options: unknown[] = [ + null, + undefined, + "tcp", + {}, + { type: "udp" }, + { type: "udp4", lookup: 1 }, + ]; + for (const field of ["recvBufferSize", "sendBufferSize"]) { + for (const value of [-1, 1.5, Infinity, 2 ** 32, "1", true]) { + options.push({ type: "udp4", [field]: value }); + } + } + for (const option of options) { + expect(errorShape(() => Reflect.apply(dgram.createSocket, dgram, [option]))).toEqual( + errorShape(() => Reflect.apply(nodeDgram.createSocket, nodeDgram, [option])), + ); + } + }, +); + +test("unknown option getters are not evaluated", () => { + const socket = setup().createSocket({ + type: "udp4", + + get unused() { + throw Error("unknown option read"); + }, + } as { type: "udp4" }); + socket.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/deprecated.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/deprecated.ts new file mode 100644 index 000000000..b6a6c2aca --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/deprecated.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { setup } from "./helpers/setup.js"; + +test("all DEP0112 entries fail immediately without argument side effects", () => { + const dgram = setup(); + const socket = dgram.createSocket("udp4"); + const poison = new Proxy( + {}, + { + get() { + throw new Error("touched"); + }, + }, + ); + const expected = { code: "ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API" }; + expect(() => dgram._createSocketHandle(poison)).toThrow(expect.objectContaining(expected)); + expect(() => socket._healthCheck()).toThrow(expect.objectContaining(expected)); + expect(() => socket._stopReceiving()).toThrow(expect.objectContaining(expected)); + for (const key of ["_handle", "_receiving", "_bindState", "_queue", "_reuseAddr"]) { + expect(() => Reflect.get(socket, key)).toThrow(expect.objectContaining(expected)); + expect(() => Reflect.set(socket, key, poison)).toThrow(expect.objectContaining(expected)); + } + socket.close(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/helpers/setup.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/helpers/setup.ts new file mode 100644 index 000000000..f854b87f5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/helpers/setup.ts @@ -0,0 +1,28 @@ +import { createDgram } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/dgram/core.js"; +import { createDgramHost } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/dgram-host-node.js"; +import type { Socket } from "../../../../../../../src/wasi/0.2.x/node/24.x.x/dgram/types.js"; + +export function setup() { + const result = createDgram(createDgramHost(() => result.dgramCallbacks)); + return result.dgram; +} + +export function event(socket: Socket, name: string): Promise { + return new Promise((resolve, reject) => { + socket.once(name, (...args: unknown[]) => { + socket.off("error", reject); + resolve(args); + }); + socket.once("error", reject); + }); +} + +export function errorShape(call: () => unknown) { + try { + call(); + return undefined; + } catch (error) { + const value = error as Error & { code?: string }; + return { name: value.name, code: value.code, message: value.message }; + } +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts new file mode 100644 index 000000000..10db3b1a3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts @@ -0,0 +1,111 @@ +import { expect, test } from "vitest"; +import nodeDgram from "node:dgram"; +import { setup, event, errorShape } from "./helpers/setup.js"; + +test("bind, connect, disconnect, close, disposal and error lifecycle", async () => { + const dgram = setup(); + const server = dgram.createSocket("udp4"); + const client = dgram.createSocket("udp4"); + try { + const listening = event(server, "listening"); + expect(server.bind({ port: 0, address: "127.0.0.1" })).toBe(server); + await listening; + expect(server.address()).toMatchObject({ address: "127.0.0.1", family: "IPv4" }); + expect(() => server.bind(0)).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_ALREADY_BOUND" }), + ); + const connected = event(client, "connect"); + expect(client.connect(server.address().port, "localhost")).toBeUndefined(); + await connected; + expect(client.remoteAddress()).toEqual(server.address()); + client.disconnect(); + expect(() => client.remoteAddress()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_NOT_CONNECTED" }), + ); + client.connectSync(server.address().port, "127.0.0.1"); + expect(client.remoteAddress()).toEqual(server.address()); + } finally { + await client[Symbol.asyncDispose](); + await server[Symbol.asyncDispose](); + } + await client[Symbol.asyncDispose](); + expect(() => client.close()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_NOT_RUNNING" }), + ); +}); + +test("custom lookup and AbortSignal", async () => { + const calls: unknown[] = []; + const controller = new AbortController(); + const socket = setup().createSocket({ + type: "udp4", + signal: controller.signal, + + lookup(name, family, callback) { + calls.push([name, family]); + queueMicrotask(() => callback(null, "127.0.0.1", 4)); + }, + }); + const listening = event(socket, "listening"); + socket.bind(0, "example.invalid"); + await listening; + expect(calls).toEqual([["example.invalid", 4]]); + const closed = event(socket, "close"); + controller.abort(); + await closed; + expect(() => socket.address()).toThrow( + expect.objectContaining({ code: "ERR_SOCKET_DGRAM_NOT_RUNNING" }), + ); +}); + +test("bind failures leave the socket reusable and remove bind callbacks", async () => { + const dgram = setup(); + const first = dgram.createSocket("udp4"); + const second = dgram.createSocket("udp4"); + try { + const address = first.bindSync({ port: 0, address: "127.0.0.1" }); + const failed = new Promise((resolve) => second.once("error", resolve)); + let called = false; + second.bind(address.port, address.address, () => { + called = true; + }); + expect(await failed).toMatchObject({ + code: "EADDRINUSE", + address: "127.0.0.1", + port: address.port, + }); + expect(called).toBe(false); + second.bindSync({ address: "127.0.0.1" }); + expect(second.address().port).toBeGreaterThan(0); + } finally { + first.close(); + second.close(); + } +}); + +test.skipIf(!process.versions.node.startsWith("24."))( + "validation agrees with Node 24 for synchronous public failures", + () => { + const dgram = setup(); + const guest = dgram.createSocket("udp4"); + const native = nodeDgram.createSocket("udp4"); + try { + const cases = [ + (socket: typeof guest) => socket.connect(0), + (socket: typeof guest) => socket.connect(65536), + (socket: typeof guest) => socket.disconnect(), + (socket: typeof guest) => socket.remoteAddress(), + (socket: typeof guest) => socket.send("x", -1), + (socket: typeof guest) => socket.setRecvBufferSize(-1), + ]; + for (const call of cases) { + expect(errorShape(() => call(guest))).toEqual( + errorShape(() => call(native as unknown as typeof guest)), + ); + } + } finally { + guest.close(); + native.close(); + } + }, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/membership.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/membership.ts new file mode 100644 index 000000000..fb0a943c6 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/membership.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { setup } from "./helpers/setup.js"; + +test("joins and leaves multicast memberships on loopback", () => { + const socket = setup().createSocket({ type: "udp4", reuseAddr: true }); + try { + socket.bindSync({ address: "0.0.0.0" }); + expect(socket.addMembership("239.255.42.1", "127.0.0.1")).toBeUndefined(); + expect(socket.dropMembership("239.255.42.1", "127.0.0.1")).toBeUndefined(); + expect(socket.setMulticastInterface("127.0.0.1")).toBeUndefined(); + // Source-specific memberships are supported on this Node/Linux test host. + if (process.platform === "linux") { + expect( + socket.addSourceSpecificMembership("127.0.0.1", "239.255.42.2", "127.0.0.1"), + ).toBeUndefined(); + expect( + socket.dropSourceSpecificMembership("127.0.0.1", "239.255.42.2", "127.0.0.1"), + ).toBeUndefined(); + } + } finally { + socket.close(); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/module.ts new file mode 100644 index 000000000..8d0e2dae8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/module.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import nodeDgram from "node:dgram"; +import { EventEmitter } from "node:events"; +import { setup } from "./helpers/setup.js"; +import { createDgram } from "../../../../../../src/wasi/0.2.x/node/24.x.x/dgram/core.js"; +import denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/dgram-host.js"; + +describe("node:dgram module", () => { + test.skipIf(!process.versions.node.startsWith("24."))( + "matches pinned Node module/prototype shape and identities", + () => { + expect(process.versions.node.split(".")[0]).toBe("24"); + const dgram = setup(); + expect(Object.keys(dgram).sort()).toEqual(Object.keys(nodeDgram).sort()); + expect(Reflect.ownKeys(dgram.Socket.prototype).map(String).sort()).toEqual( + Reflect.ownKeys(nodeDgram.Socket.prototype).map(String).sort(), + ); + for (const key of Reflect.ownKeys(nodeDgram.Socket.prototype)) { + const expected = Object.getOwnPropertyDescriptor(nodeDgram.Socket.prototype, key)!; + const actual = Object.getOwnPropertyDescriptor(dgram.Socket.prototype, key)!; + expect({ + enumerable: actual.enumerable, + configurable: actual.configurable, + writable: actual.writable, + }).toEqual({ + enumerable: expected.enumerable, + configurable: expected.configurable, + writable: expected.writable, + }); + } + const socket = dgram.createSocket("udp4"); + expect(socket).toBeInstanceOf(dgram.Socket); + expect(socket).toBeInstanceOf(EventEmitter); + expect(socket.type).toBe("udp4"); + socket.close(); + }, + ); + test("imports, constructs, refs and closes without granting networking", () => { + const { dgram } = createDgram(denied); + const socket = dgram.createSocket("udp4"); + expect(socket.ref().unref()).toBe(socket); + expect(() => socket.bind(0)).toThrow(/node:dgram/); + expect(() => socket.bind(0)).toThrow( + expect.objectContaining({ code: "ERR_JCO_DGRAM_ADAPTER_REQUIRED" }), + ); + socket.close(); + }); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/options.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/options.ts new file mode 100644 index 000000000..bae0b09d8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/options.ts @@ -0,0 +1,77 @@ +import { expect, test } from "vitest"; +import nodeDgram from "node:dgram"; +import { setup, errorShape } from "./helpers/setup.js"; + +test.skipIf(!process.versions.node.startsWith("24."))( + "socket options and multicast validation follow Node", + () => { + const socket = setup().createSocket("udp4"); + const native = nodeDgram.createSocket("udp4"); + socket.bindSync({ address: "127.0.0.1" }); + (native as unknown as typeof socket).bindSync({ address: "127.0.0.1" }); + try { + expect(socket.setTTL(32)).toBe(32); + expect(socket.setMulticastTTL(8)).toBe(8); + expect(socket.setMulticastLoopback(false)).toBe(false); + expect(socket.setBroadcast(true)).toBeUndefined(); + socket.setRecvBufferSize(65536); + socket.setSendBufferSize(65536); + expect(socket.getRecvBufferSize()).toBeGreaterThanOrEqual(65536); + expect(socket.getSendBufferSize()).toBeGreaterThanOrEqual(65536); + expect(socket.getSendQueueSize()).toBe(0); + expect(socket.getSendQueueCount()).toBe(0); + expect(socket.ref().unref().ref()).toBe(socket); + for (const operation of [ + (s: typeof socket) => s.setTTL(0), + (s: typeof socket) => s.setTTL(256), + (s: typeof socket) => s.setMulticastInterface("invalid"), + (s: typeof socket) => s.addMembership("invalid"), + (s: typeof socket) => s.dropMembership("invalid"), + (s: typeof socket) => s.addSourceSpecificMembership("invalid", "239.1.1.1"), + (s: typeof socket) => s.dropSourceSpecificMembership("invalid", "239.1.1.1"), + ]) { + expect(errorShape(() => operation(socket))).toEqual( + errorShape(() => operation(native as unknown as typeof socket)), + ); + } + } finally { + socket.close(); + native.close(); + } + }, +); + +test.skipIf(!process.versions.node.startsWith("24."))( + "buffer failures retain SystemError fields and linked errno/syscall", + () => { + const socket = setup().createSocket("udp4"); + const native = nodeDgram.createSocket("udp4"); + try { + for (const operation of [ + (s: typeof socket) => s.address(), + (s: typeof socket) => s.getRecvBufferSize(), + (s: typeof socket) => s.getSendBufferSize(), + ]) { + expect(errorShape(() => operation(socket))).toEqual( + errorShape(() => operation(native as unknown as typeof socket)), + ); + } + let error: unknown; + try { + socket.getRecvBufferSize(); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ + name: "SystemError", + code: "ERR_SOCKET_BUFFER_SIZE", + info: { code: "EBADF", syscall: "uv_recv_buffer_size" }, + }); + Reflect.set(error as object, "errno", 100); + expect(Reflect.get(error as object, "info").errno).toBe(100); + } finally { + socket.close(); + native.close(); + } + }, +); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/send.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/send.ts new file mode 100644 index 000000000..1ca5df8df --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/send.ts @@ -0,0 +1,122 @@ +import { expect, test } from "vitest"; +import { setup, event } from "./helpers/setup.js"; +import { BlockList } from "../../../../../../src/wasi/0.2.x/node/24.x.x/net/block-list.js"; + +test("datagrams preserve empty, vector, binary and sliced payloads over real UDP", async () => { + const dgram = setup(); + const server = dgram.createSocket("udp4"); + const client = dgram.createSocket("udp4"); + const address = server.bindSync({ address: "127.0.0.1" }); + try { + const packets = [ + Buffer.from("hello"), + new Uint8Array([0, 255, 1]), + ["a", new DataView(new Uint8Array([0, 98]).buffer, 1)], + [], + ]; + for (const packet of packets) { + const received = event(server, "message"); + const count = await new Promise((resolve, reject) => + client.send(packet, address.port, address.address, (error, count) => + error ? reject(error) : resolve(count), + ), + ); + const [message, remote] = await received; + const expected = Array.isArray(packet) + ? Buffer.from(packet.length ? "ab" : "") + : Buffer.from(packet); + expect(message).toEqual(expected); + expect(remote).toMatchObject({ + family: "IPv4", + address: "127.0.0.1", + size: expected.length, + port: client.address().port, + }); + expect(count).toBe(expected.length); + } + client.connectSync(address.port, address.address); + const received = event(server, "message"); + client.send("xyz", 1, 1); + expect((await received)[0]).toEqual(Buffer.from("y")); + client.disconnect(); + const alias = event(server, "message"); + client.sendto(Buffer.from("abc"), 1, 1, address.port, address.address); + expect((await alias)[0]).toEqual(Buffer.from("b")); + } finally { + client.close(); + server.close(); + } +}); + +test("send block lists fail callbacks", async () => { + const dgram = setup(); + const block = new BlockList(); + block.addAddress("127.0.0.1"); + const socket = dgram.createSocket({ type: "udp4", sendBlockList: block }); + try { + const error = await new Promise((resolve) => + socket.send("blocked", 12345, "127.0.0.1", resolve), + ); + expect(error).toMatchObject({ code: "ERR_IP_BLOCKED" }); + } finally { + socket.close(); + } +}); + +test.skipIf(!process.versions.node.startsWith("24."))( + "send validates lists, byte offsets and connected overloads against Node 24", + async () => { + const { normalizeSend } = + await import("../../../../../../src/wasi/0.2.x/node/24.x.x/dgram/send.js"); + const node = await import("node:dgram"); + const { errorShape } = await import("./helpers/setup.js"); + const socket = node.createSocket("udp4"); + try { + const invalid: unknown[][] = [ + [], + [23, 12345], + [["ok", 23], 12345], + [new Array(1), 12345], + ["abc", 4, 0, 12345, "127.0.0.1"], + ["abc", 0, 4, 12345, "127.0.0.1"], + ["abc", 1n, 1, 12345, "127.0.0.1"], + ["abc", 12345, false], + ]; + for (const args of invalid) { + expect(errorShape(() => Reflect.apply(normalizeSend, undefined, [false, ...args]))).toEqual( + errorShape(() => Reflect.apply(socket.send, socket, args)), + ); + } + } finally { + socket.close(); + } + }, +); + +test("receive block lists discard blocked source addresses", async () => { + const dgram = setup(); + const block = new BlockList(); + block.addAddress("127.0.0.2"); + const server = dgram.createSocket({ type: "udp4", receiveBlockList: block }); + const blocked = dgram.createSocket("udp4"); + const allowed = dgram.createSocket("udp4"); + const messages: string[] = []; + server.on("message", (message) => messages.push(message.toString())); + try { + const address = server.bindSync({ address: "127.0.0.1" }); + blocked.bindSync({ address: "127.0.0.2" }); + const received = event(server, "message"); + await new Promise((resolve, reject) => + blocked.send("blocked", address.port, address.address, (error) => + error ? reject(error) : resolve(), + ), + ); + allowed.send("allowed", address.port, address.address); + await received; + expect(messages).toEqual(["allowed"]); + } finally { + server.close(); + blocked.close(); + allowed.close(); + } +}); diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index e375419f4..87d517224 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -14,6 +14,7 @@ "whatwg-url": ["./src/wasi/0.2.x/node/24.x.x/url/whatwg-types.d.ts"], "punycode/punycode.js": ["./src/wasi/0.2.x/node/24.x.x/url/punycode-types.d.ts"], "jco:node/tls@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/tls-interface.d.ts"], + "jco:node/dgram@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/dgram-interface.d.ts"], "readable-stream/lib/stream.js": ["./src/wasi/0.2.x/node/24.x.x/stream/vendor-types.d.ts"], "jco:node/sqlite@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/sqlite-interface.d.ts"], "wasi:sockets/instance-network@0.2.12": ["./src/wasi/0.2.x/node/24.x.x/net-interface.d.ts"], diff --git a/packages/jco-std/wit/node-0.1.0/dgram.wit b/packages/jco-std/wit/node-0.1.0/dgram.wit new file mode 100644 index 000000000..33f6cafc0 --- /dev/null +++ b/packages/jco-std/wit/node-0.1.0/dgram.wit @@ -0,0 +1,142 @@ +package jco:node@0.1.0; + +/// Explicit UDP capability. Guest-owned callbacks deliver DNS results, send +/// completions and datagrams between component entry tasks. No Node objects, +/// descriptors, or implicit networking authority cross this interface. +interface dgram { + variant errno { + number(s64), + symbolic(string), + } + + record buffer-error-info { + errno: f64, + code: string, + message: string, + syscall: string, + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + address: option, + port: option, + info: option, + } + + enum socket-type { + udp4, + udp6, + } + + record options { + %type: socket-type, + reuse-addr: bool, + reuse-port: bool, + ipv6-only: bool, + } + + record address-info { + address: string, + family: string, + port: u16, + } + + variant socket-option { + broadcast(bool), + multicast-loopback(bool), + ttl(f64), + multicast-ttl(f64), + recv-buffer(u32), + send-buffer(u32), + multicast-interface(string), + } + + enum socket-query { + recv-buffer, + send-buffer, + send-queue-size, + send-queue-count, + } + + enum membership-action { + add, + drop, + add-source, + drop-source, + } + + create-socket: func(options: options, listener: u32) -> result; + + resource socket { + /// Bind/connect only accept resolved addresses and complete synchronously. + bind: func(address: string, port: u16) -> result; + + connect: func(address: string, port: u16) -> result<_, error>; + + disconnect: func() -> result<_, error>; + + resolve: func(address: string, id: u32); + + send: func( + data: list, + port: option, + address: option, + callback: option, + ) -> result<_, error>; + + address: func(remote: bool) -> result; + + set-option: func(%option: socket-option) -> result<_, error>; + + query: func(query: socket-query) -> result; + + membership: func( + action: membership-action, + group: string, + source: option, + iface: option, + ) -> result<_, error>; + + close: func(); + + set-ref: func(%ref: bool); + } +} + +/// Each component instance owns and redeems its listeners. Providers must serialize +/// callback entry and drops, never re-enter while a guest import is on the stack. +interface dgram-callbacks { + use dgram.{error, address-info}; + + record message { + data: list, + remote: address-info, + } + + record resolved { + id: u32, + %result: result, + } + + record sent { + id: u32, + %result: result, + } + + variant socket-event { + message(message), + error(error), + resolved(resolved), + sent(sent), + } + + resource socket-listener { + event: func(event: socket-event); + } + + take-socket-listener: func(id: u32) -> option; +} diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/dgram.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/dgram.wit new file mode 100644 index 000000000..33f6cafc0 --- /dev/null +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/dgram.wit @@ -0,0 +1,142 @@ +package jco:node@0.1.0; + +/// Explicit UDP capability. Guest-owned callbacks deliver DNS results, send +/// completions and datagrams between component entry tasks. No Node objects, +/// descriptors, or implicit networking authority cross this interface. +interface dgram { + variant errno { + number(s64), + symbolic(string), + } + + record buffer-error-info { + errno: f64, + code: string, + message: string, + syscall: string, + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + address: option, + port: option, + info: option, + } + + enum socket-type { + udp4, + udp6, + } + + record options { + %type: socket-type, + reuse-addr: bool, + reuse-port: bool, + ipv6-only: bool, + } + + record address-info { + address: string, + family: string, + port: u16, + } + + variant socket-option { + broadcast(bool), + multicast-loopback(bool), + ttl(f64), + multicast-ttl(f64), + recv-buffer(u32), + send-buffer(u32), + multicast-interface(string), + } + + enum socket-query { + recv-buffer, + send-buffer, + send-queue-size, + send-queue-count, + } + + enum membership-action { + add, + drop, + add-source, + drop-source, + } + + create-socket: func(options: options, listener: u32) -> result; + + resource socket { + /// Bind/connect only accept resolved addresses and complete synchronously. + bind: func(address: string, port: u16) -> result; + + connect: func(address: string, port: u16) -> result<_, error>; + + disconnect: func() -> result<_, error>; + + resolve: func(address: string, id: u32); + + send: func( + data: list, + port: option, + address: option, + callback: option, + ) -> result<_, error>; + + address: func(remote: bool) -> result; + + set-option: func(%option: socket-option) -> result<_, error>; + + query: func(query: socket-query) -> result; + + membership: func( + action: membership-action, + group: string, + source: option, + iface: option, + ) -> result<_, error>; + + close: func(); + + set-ref: func(%ref: bool); + } +} + +/// Each component instance owns and redeems its listeners. Providers must serialize +/// callback entry and drops, never re-enter while a guest import is on the stack. +interface dgram-callbacks { + use dgram.{error, address-info}; + + record message { + data: list, + remote: address-info, + } + + record resolved { + id: u32, + %result: result, + } + + record sent { + id: u32, + %result: result, + } + + variant socket-event { + message(message), + error(error), + resolved(resolved), + sent(sent), + } + + resource socket-listener { + event: func(event: socket-event); + } + + take-socket-listener: func(id: u32) -> option; +} diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index 2d923b5a3..7cfc4d806 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -46,6 +46,7 @@ const HTTP2_ASYNC_IMPORTS = [ ]; const DEFAULT_NODE_CAPABILITY_MAP = { "jco:node/tls@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host", + "jco:node/dgram@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host", "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", "jco:node/child-process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host", "jco:node/cluster@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host", diff --git a/packages/jco/src/node-builtins/dgram.ts b/packages/jco/src/node-builtins/dgram.ts new file mode 100644 index 000000000..5d277ade6 --- /dev/null +++ b/packages/jco/src/node-builtins/dgram.ts @@ -0,0 +1,30 @@ +import { + type BuiltinContext, + type BuiltinAdapter, + builtin, + composeBuiltins, + stdModule, + virtualBuiltin, + VIRTUAL_PREFIX, +} from "./shared.js"; +import { DGRAM_WIT_REQUIREMENT } from "../node-wit.js"; + +export const DGRAM_CALLBACKS_SPECIFIER = "jco:node-dgram-callbacks"; + +export function createDgramBuiltin({ options }: BuiltinContext): BuiltinAdapter { + return composeBuiltins([ + builtin( + "node:dgram", + () => { + const module = JSON.stringify(stdModule(options.dgramModule, "dgram")); + return `export { default, Socket, createSocket, _createSocketHandle } from ${module};`; + }, + () => options.onWitRequirement?.(DGRAM_WIT_REQUIREMENT), + ), + virtualBuiltin( + DGRAM_CALLBACKS_SPECIFIER, + `${VIRTUAL_PREFIX}dgram-callbacks`, + () => `export { dgramCallbacks } from ${JSON.stringify(stdModule(options.dgramModule, "dgram"))};`, + ), + ]); +} diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 0eeb82b23..c087c9a5f 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -23,6 +23,7 @@ import { createStreamBuiltin } from "./stream.js"; import { createClusterBuiltin } from "./cluster.js"; import { createChildProcessBuiltin } from "./child-process.js"; import { createConsoleBuiltin } from "./console.js"; +import { createDgramBuiltin } from "./dgram.js"; import { createDnsBuiltin } from "./dns.js"; import { createFsBuiltin } from "./fs.js"; import { createNetBuiltin } from "./net.js"; @@ -80,6 +81,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createClusterBuiltin, createChildProcessBuiltin, createConsoleBuiltin, + createDgramBuiltin, createDnsBuiltin, createFsBuiltin, createNetBuiltin, diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index bbc74940f..7652f369c 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -82,6 +82,8 @@ export interface NodeBuiltinOptions { /** Paths to jco-std's versioned DNS modules (overridable for tests) */ dnsModule?: string; dnsPromisesModule?: string; + /** Path to the versioned node:dgram guest module. */ + dgramModule?: string; /** Implementation used for `node:http` host operations. */ nodejsHttpVia?: NodejsHttpVia; /** Paths to jco-std's HTTP modules (overridable for tests). */ diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 2b96c727b..603d310df 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -100,6 +100,16 @@ export const CLUSTER_WIT_REQUIREMENT = nodeRequirement("node:cluster", "cluster" export const CONSOLE_WIT_REQUIREMENT = nodeRequirement("node:console", "console"); +export const DGRAM_WIT_REQUIREMENT = nodeRequirement("node:dgram", "dgram", { + guestExports: [ + { + witExport: "jco:node/dgram-callbacks@0.1.0", + jsExport: "dgramCallbacks", + moduleSpecifier: "jco:node-dgram-callbacks", + }, + ], +}); + export const DNS_WIT_REQUIREMENT = nodeRequirement("node:dns", "dns"); export const DNS_PROMISES_WIT_REQUIREMENT: NodeWitRequirement = { diff --git a/packages/jco/test/fixtures/componentize/node-dgram/component.js b/packages/jco/test/fixtures/componentize/node-dgram/component.js new file mode 100644 index 000000000..16c4840e6 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/component.js @@ -0,0 +1,180 @@ +import dgram, { Socket, createSocket } from "node:dgram"; +import * as namespace from "node:dgram"; +import { Buffer } from "node:buffer"; +import { EventEmitter } from "node:events"; + +let sockets = []; +let messages = []; +let sent = []; +let errors = []; +let listening = 0; +let connected = 0; +let closed = 0; + +export function shape() { + const socket = createSocket("udp4"); + const result = { + keys: Object.keys(dgram).sort(), + named: Object.keys(namespace).sort(), + identity: dgram === namespace.default && dgram.Socket === Socket && dgram.createSocket === createSocket, + socket: socket instanceof Socket && socket instanceof EventEmitter, + ref: socket.ref().unref() === socket, + type: socket.type, + }; + socket.close(); + return JSON.stringify(result); +} + +export function denied() { + const socket = createSocket("udp4"); + try { + socket.bind(0); + return "unexpected success"; + } catch (error) { + return error.code + ":" + error.message; + } finally { + socket.close(); + } +} + +function watch(socket) { + sockets.push(socket); + socket.on("error", (error) => errors.push({ code: error.code, message: error.message })); + socket.on("close", () => closed++); + socket.on("listening", () => listening++); + socket.on("connect", () => connected++); + return socket; +} + +export function start(ipv6) { + const server = watch(new Socket({ type: ipv6 ? "udp6" : "udp4", ipv6Only: ipv6 })); + server.on("message", (message, remote) => { + messages.push({ bytes: Array.from(message), buffer: Buffer.isBuffer(message), remote }); + server.send(["echo:", message], remote.port, remote.address, (error, size) => { + if (error) { + errors.push({ code: error.code }); + } else { + sent.push(size); + } + }); + }); + const address = server.bindSync({ address: ipv6 ? "::1" : "127.0.0.1" }); + server.setTTL(32); + server.setBroadcast(true); + server.setMulticastTTL(8); + server.setMulticastLoopback(false); + server.setRecvBufferSize(65536); + server.setSendBufferSize(65536); + if (server.getRecvBufferSize() < 65536 || server.getSendBufferSize() < 65536) { + throw Error("buffer sizes"); + } + if (server.getSendQueueCount() !== 0 || server.getSendQueueSize() !== 0) { + throw Error("queue sizes"); + } + return address.port; +} + +export function client(port) { + // Hostname resolution, implicit bind, connect, vector/empty/sliced sends and + // byte accounting all run inside the component with ordinary Node imports. + let received = 0; + const done = (error, size) => (error ? errors.push({ code: error.code }) : sent.push(size)); + const socket = watch( + createSocket("udp4", (message, remote) => { + messages.push({ bytes: Array.from(message), buffer: Buffer.isBuffer(message), remote }); + if (++received === 4) { + socket.disconnect(); + socket.sendto(Buffer.from("alias"), 1, 3, port, "127.0.0.1", done); + } + }), + ); + socket.connect(port, "localhost", () => { + if (socket.remoteAddress().port !== port) { + throw Error("remote address"); + } + socket.send("hello", done); + socket.send(["a", new DataView(new Uint8Array([99, 0, 255]).buffer, 1)], done); + socket.send([], done); + socket.send(Buffer.from("slice"), 1, 3, done); + }); +} + +export function status() { + return JSON.stringify({ messages, sent, errors, listening, connected, closed }); +} + +export function stop() { + for (const socket of sockets.splice(0)) { + socket.close(); + } +} + +export function contract() { + let checked = 0; + const throws = (code, operation) => { + try { + operation(); + } catch (error) { + if (error.code !== code) { + throw Error("Expected " + code + ", got " + error.code + ": " + error.message); + } + checked++; + return; + } + throw Error("Expected " + code); + }; + for (const type of [undefined, null, "tcp", {}, { type: "udp" }]) { + throws("ERR_SOCKET_BAD_TYPE", () => createSocket(type)); + } + throws("ERR_INVALID_ARG_TYPE", () => createSocket({ type: "udp4", lookup: 1 })); + const socket = createSocket("udp4"); + for (const port of [0, -1, 65536, null, undefined, NaN]) { + throws("ERR_SOCKET_BAD_PORT", () => socket.connect(port)); + } + throws("ERR_SOCKET_DGRAM_NOT_CONNECTED", () => socket.disconnect()); + throws("ERR_SOCKET_DGRAM_NOT_CONNECTED", () => socket.remoteAddress()); + throws("ERR_INVALID_ARG_TYPE", () => socket.send()); + throws("ERR_INVALID_ARG_TYPE", () => socket.send(["ok", 23], 12345)); + throws("ERR_BUFFER_OUT_OF_BOUNDS", () => + socket.send(new DataView(new ArrayBuffer(5), 1), 5, 0, 12345, "127.0.0.1"), + ); + throws("ERR_BUFFER_OUT_OF_BOUNDS", () => socket.send("abc", 0, 4, 12345, "127.0.0.1")); + throws("ERR_SOCKET_BAD_BUFFER_SIZE", () => socket.setRecvBufferSize(-1)); + throws("ERR_SOCKET_BAD_BUFFER_SIZE", () => socket.setSendBufferSize(1.5)); + const poison = new Proxy( + {}, + { + get() { + throw Error("deprecated argument touched"); + }, + }, + ); + throws("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", () => dgram._createSocketHandle(poison)); + for (const key of ["_handle", "_receiving", "_bindState", "_queue", "_reuseAddr"]) { + throws("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", () => socket[key]); + throws("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", () => { + socket[key] = poison; + }); + } + throws("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", () => socket._healthCheck()); + throws("ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API", () => socket._stopReceiving()); + socket.close(); + throws("ERR_SOCKET_DGRAM_NOT_RUNNING", () => socket.close()); + throws("ERR_SOCKET_DGRAM_NOT_RUNNING", () => socket.address()); + return checked; +} + +export function finish(port) { + const socket = createSocket("udp4"); + socket.on("error", (error) => errors.push({ code: error.code })); + socket.on("close", () => closed++); + socket.connectSync(port, "127.0.0.1"); + socket.send("finish", (error, size) => { + if (error) { + errors.push({ code: error.code }); + } else { + sent.push(size); + } + }); + socket.close(); +} diff --git a/packages/jco/test/fixtures/componentize/node-dgram/run.js b/packages/jco/test/fixtures/componentize/node-dgram/run.js new file mode 100644 index 000000000..b1a9cb588 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/run.js @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import dgram from "node:dgram"; +import { once } from "node:events"; +import { argv } from "node:process"; +import { pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const { instantiate } = await import(pathToFileURL(argv[2])); +const { createDgramHost } = await import(argv[3]); + +async function createInstance() { + const imports = new WASIShim().getImportObject(); + let instance; + imports["jco:node/dgram@0.1.0"] = createDgramHost(() => instance.dgramCallbacks); + instance = await instantiate(undefined, imports); + return instance; +} + +async function until(instance, condition) { + for (let attempt = 0; attempt < 500; attempt++) { + const state = JSON.parse(await instance.status()); + assert.deepEqual(state.errors, []); + if (condition(state)) { + return state; + } + await delay(10); + } + throw Error("UDP component timed out: " + (await instance.status())); +} + +const first = await createInstance(); +const second = await createInstance(); +const native = dgram.createSocket("udp4"); +const echo = dgram.createSocket("udp4"); +const native6 = dgram.createSocket("udp6"); + +try { + assert.deepEqual(JSON.parse(await first.shape()), { + keys: ["Socket", "_createSocketHandle", "createSocket"], + named: ["Socket", "_createSocketHandle", "createSocket", "default"], + identity: true, + socket: true, + ref: true, + type: "udp4", + }); + const firstPort = await first.start(false); + const secondPort = await second.start(false); + const payloads = [Buffer.from("hello"), Buffer.from([0, 255, 42]), Buffer.alloc(0)]; + for (const [port, payload] of [ + [firstPort, payloads[0]], + [firstPort, payloads[1]], + [secondPort, payloads[2]], + ]) { + const response = once(native, "message"); + native.send(payload, port, "127.0.0.1"); + const [message, remote] = await response; + assert.deepEqual(message, Buffer.concat([Buffer.from("echo:"), payload])); + assert.equal(remote.port, port); + } + const state = await until(first, (state) => state.messages.length === 2 && state.sent.length === 2); + assert.ok( + state.messages.every( + (message) => + message.buffer && message.remote.family === "IPv4" && message.remote.size === message.bytes.length, + ), + ); + assert.equal(JSON.parse(await second.status()).messages.length, 1); + echo.on("message", (message, remote) => echo.send(message, remote.port, remote.address)); + echo.bind(0, "127.0.0.1"); + await once(echo, "listening"); + await first.client(echo.address().port); + const client = await until(first, (state) => state.messages.length === 7 && state.sent.length === 7); + assert.deepEqual( + client.messages.slice(2).map((message) => message.bytes), + [ + Array.from(Buffer.from("hello")), + [97, 0, 255], + [], + Array.from(Buffer.from("lic")), + Array.from(Buffer.from("lia")), + ], + ); + assert.equal(client.connected, 1); + assert.equal(client.listening, 2); + assert.deepEqual(client.sent.slice(2), [5, 3, 0, 3, 3]); + const ipv6Port = await first.start(true); + const ipv6Response = once(native6, "message"); + native6.send("ipv6", ipv6Port, "::1"); + const [ipv6Message, ipv6Remote] = await ipv6Response; + assert.equal(ipv6Message.toString(), "echo:ipv6"); + assert.equal(ipv6Remote.family, "IPv6"); + await until(first, (state) => state.messages.length === 8 && state.sent.length === 8); + await first.finish(echo.address().port); + await until(first, (state) => state.sent.length === 9 && state.closed === 1); + await first.stop(); + await second.stop(); + assert.equal(JSON.parse(await first.status()).closed, 4); + assert.equal(await first.dgramCallbacks.takeSocketListener(1), undefined); + process.stdout.write("UDP component passthrough OK\n"); +} finally { + await first.stop(); + await second.stop(); + native.close(); + native6.close(); + echo.close(); +} diff --git a/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit b/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit new file mode 100644 index 000000000..b15b5fbf5 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit @@ -0,0 +1,19 @@ +package test:node-dgram; + +world component { + export contract: func() -> u32; + + export shape: func() -> string; + + export denied: func() -> string; + + export start: func(ipv6: bool) -> u16; + + export client: func(port: u16); + + export finish: func(port: u16); + + export status: func() -> string; + + export stop: func(); +} diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 4846c61ed..35f39ff47 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -44,6 +44,7 @@ describe("Node builtin adapters", () => { "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", "jco:node/console@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host", "jco:node/sqlite@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite/host", + "jco:node/dgram@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host", "jco:node/dns@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns/host", "jco:node/fs@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs/host", "jco:node/http@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host", @@ -68,6 +69,7 @@ describe("Node builtin adapters", () => { "jco:node/ffi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host", "jco:node/console@0.1.0": "/application/console-host.js", "jco:node/sqlite@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite/host", + "jco:node/dgram@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host", "jco:node/dns@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns/host", "jco:node/fs@0.1.0": "/application/fs-host.js", "jco:node/http@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host", diff --git a/packages/jco/test/node/dgram.js b/packages/jco/test/node/dgram.js new file mode 100644 index 000000000..a4e7106bd --- /dev/null +++ b/packages/jco/test/node/dgram.js @@ -0,0 +1,118 @@ +import nodeDgram from "node:dgram"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { DGRAM_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; +import { worldMetadataFor } from "../../src/cmd/componentize.js"; +import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers.js"; +import { hasJspi } from "../common.js"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import * as deniedHost from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host"; + +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host/node"); + +test("node:dgram requests only its UDP capability and callback export", async () => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { dgramModule: "/test/dgram.js", onWitRequirement }); + expect(plugin.resolveId("dgram")).toBeNull(); + expect(plugin.resolveId("node:dgram")).toBe("\0jco-node-builtin:node:dgram"); + expect(onWitRequirement).toHaveBeenCalledExactlyOnceWith(DGRAM_WIT_REQUIREMENT); + expect(plugin.load(plugin.resolveId("node:dgram"))).toContain("/test/dgram.js"); + expect(plugin.load(plugin.resolveId("jco:node-dgram-callbacks"))).toContain("dgramCallbacks"); + const root = await getTmpDir(); + await writeFile(join(root, "component.wit"), "package test:udp; world component {}\n"); + await injectNodeWitImports(root, undefined, [DGRAM_WIT_REQUIREMENT]); + expect(await injectNodeWitImports(root, undefined, [DGRAM_WIT_REQUIREMENT])).toBeUndefined(); + const metadata = await worldMetadataFor(root, "component"); + expect(metadata.imports).toHaveLength(1); + expect(metadata.exports).toHaveLength(1); + expect(await readFile(join(root, "deps/jco-node-0.1.0/dgram.wit"), "utf8")).toContain("resource socket"); +}); + +test("UDP defaults to denial and preserves explicit host mappings", () => { + expect(withDefaultNodeCapabilities({}).map["jco:node/dgram@0.1.0"]).toMatch(/dgram\/host$/); + expect( + withDefaultNodeCapabilities({ map: { "jco:node/dgram@0.1.0": "jco:node/dgram@0.1.0" } }).map[ + "jco:node/dgram@0.1.0" + ], + ).toBe("jco:node/dgram@0.1.0"); +}); + +const hasSyncUdp = + typeof nodeDgram.Socket.prototype.bindSync === "function" && + typeof nodeDgram.Socket.prototype.connectSync === "function"; + +describe.skipIf(!hasJspi)("node:dgram components", () => { + for (const backend of ["starlingmonkey", "quickjs"]) { + describe(backend, () => { + let componentPath; + beforeAll(async () => { + ({ componentPath } = await componentizeFixture({ + fixture: "node-dgram", + bundle: true, + copy: true, + extraArgs: ["--backend", backend], + })); + }, 600_000); + test("module shape, 35 guest contract checks and catchable denial", async () => { + const denied = await setupAsyncTest({ + component: { + name: "node-dgram-denied", + path: componentPath, + imports: { + ...new WASIShim().getImportObject(), + "jco:node/dgram@0.1.0": deniedHost, + }, + }, + jco: { + transpile: { + extraArgs: { + map: { "jco:node/dgram@0.1.0": "jco:node/dgram@0.1.0" }, + }, + }, + }, + }); + try { + expect(JSON.parse(await denied.instance.shape()).identity).toBe(true); + expect(await denied.instance.contract()).toBe(35); + expect(await denied.instance.denied()).toMatch(/^ERR_JCO_DGRAM_ADAPTER_REQUIRED:/); + } finally { + await denied.cleanup(); + } + }); + // TODO(quickjs): host-invoked exported resource methods trap (also + // documented by node:inspector). Node 22 lacks bindSync/connectSync. + test.skipIf(backend === "quickjs" || !hasSyncUdp)( + "real IPv4/IPv6 UDP client/server with isolated Node providers", + async () => { + const granted = await setupAsyncTest({ + component: { name: "node-dgram", path: componentPath, skipInstantiation: true }, + jco: { + transpile: { + extraArgs: { + asyncMode: "jspi", + asyncExports: ["*"], + map: { "jco:node/dgram@0.1.0": "jco:node/dgram@0.1.0" }, + }, + }, + }, + }); + try { + const runner = fileURLToPath( + new URL("../fixtures/componentize/node-dgram/run.js", import.meta.url), + ); + expect((await exec(runner, granted.esModuleOutputPath, NODE_HOST)).stdout.trim()).toBe( + "UDP component passthrough OK", + ); + } finally { + await granted.cleanup(); + } + }, + 600_000, + ); + }); + } +});