From e988aa5d33fa7694dee3e4106197771a851404d9 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:27:42 +0000 Subject: [PATCH 01/12] feat(std): define UDP socket contracts and errors --- .../wasi/0.2.x/node/24.x.x/dgram/errors.ts | 135 ++++++++++ .../src/wasi/0.2.x/node/24.x.x/dgram/types.ts | 247 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/errors/core.ts | 4 + packages/jco-std/wit/node-0.1.0/dgram.wit | 60 +++++ 4 files changed, 446 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/errors.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts create mode 100644 packages/jco-std/wit/node-0.1.0/dgram.wit 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..74f8ade76 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/errors.ts @@ -0,0 +1,135 @@ +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/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts new file mode 100644 index 000000000..969431c9b --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/types.ts @@ -0,0 +1,247 @@ +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/wit/node-0.1.0/dgram.wit b/packages/jco-std/wit/node-0.1.0/dgram.wit new file mode 100644 index 000000000..990f02f52 --- /dev/null +++ b/packages/jco-std/wit/node-0.1.0/dgram.wit @@ -0,0 +1,60 @@ +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; +} From f59de03093a869280ebc71ed338d9ec219173e96 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:27:51 +0000 Subject: [PATCH 02/12] feat(std): implement UDP socket lifecycle and send overloads --- .../src/wasi/0.2.x/node/24.x.x/dgram/LICENSE | 20 + .../src/wasi/0.2.x/node/24.x.x/dgram/core.ts | 799 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/dgram/send.ts | 125 +++ 3 files changed, 944 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/core.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE new file mode 100644 index 000000000..6de584a48 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE @@ -0,0 +1,20 @@ +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. 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..8a9d29e45 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/core.ts @@ -0,0 +1,799 @@ +/* +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; see LICENSE). + * 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/send.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts new file mode 100644 index 000000000..80ffb4402 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/send.ts @@ -0,0 +1,125 @@ +/* +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. + * See LICENSE in this directory. 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, + }; +} From e24a38882ed7241639e8f1294a57a525667a364b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:27:57 +0000 Subject: [PATCH 03/12] feat(std): expose UDP adapters and Node host provider --- packages/jco-std/package.json | 19 ++ .../wasi/0.2.x/node/24.x.x/dgram-host-node.ts | 240 ++++++++++++++++++ .../src/wasi/0.2.x/node/24.x.x/dgram-host.ts | 18 ++ .../0.2.x/node/24.x.x/dgram-interface.d.ts | 4 + .../src/wasi/0.2.x/node/24.x.x/dgram.ts | 16 ++ packages/jco-std/tsconfig.json | 1 + 6 files changed, 298 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-interface.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts 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..b3bc99faa --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts @@ -0,0 +1,240 @@ +/** + * 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..895640af6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host.ts @@ -0,0 +1,18 @@ +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..6895b32a0 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts @@ -0,0 +1,16 @@ +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/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"], From e6984ec799d567bef63ade1e0dd46a3cf72afeea Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:28:04 +0000 Subject: [PATCH 04/12] test(std): cover UDP conformance and socket lifecycle --- .../test/wasi/0.2.x/node/24.x.x/dgram/bind.ts | 41 ++++++ .../wasi/0.2.x/node/24.x.x/dgram/close.ts | 46 +++++++ .../wasi/0.2.x/node/24.x.x/dgram/connect.ts | 37 ++++++ .../0.2.x/node/24.x.x/dgram/create-socket.ts | 38 ++++++ .../0.2.x/node/24.x.x/dgram/deprecated.ts | 24 ++++ .../0.2.x/node/24.x.x/dgram/helpers/setup.ts | 25 ++++ .../wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts | 110 ++++++++++++++++ .../0.2.x/node/24.x.x/dgram/membership.ts | 23 ++++ .../wasi/0.2.x/node/24.x.x/dgram/module.ts | 48 +++++++ .../wasi/0.2.x/node/24.x.x/dgram/options.ts | 77 +++++++++++ .../test/wasi/0.2.x/node/24.x.x/dgram/send.ts | 122 ++++++++++++++++++ 11 files changed, 591 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/bind.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/close.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/connect.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/create-socket.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/deprecated.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/helpers/setup.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/membership.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/options.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/send.ts 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..c81e7ab66 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/close.ts @@ -0,0 +1,46 @@ +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..2ee1a48e6 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/connect.ts @@ -0,0 +1,37 @@ +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..7e61dbd01 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/create-socket.ts @@ -0,0 +1,38 @@ +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..380903b06 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/helpers/setup.ts @@ -0,0 +1,25 @@ +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..043545ffa --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts @@ -0,0 +1,110 @@ +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(); + } +}); From 6732bfcc31f8964e2e696f87d773b8b1473346c8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:28:24 +0000 Subject: [PATCH 05/12] feat(jco): wire node:dgram imports and UDP capabilities --- .../lib/wit/builtin/jco-node-0.1.0/dgram.wit | 60 +++++++++++++++++++ packages/jco/src/cmd/transpile.ts | 1 + packages/jco/src/node-builtins/dgram.ts | 28 +++++++++ packages/jco/src/node-builtins/index.ts | 2 + packages/jco/src/node-builtins/types.ts | 2 + packages/jco/src/node-wit.ts | 10 ++++ 6 files changed, 103 insertions(+) create mode 100644 packages/jco/lib/wit/builtin/jco-node-0.1.0/dgram.wit create mode 100644 packages/jco/src/node-builtins/dgram.ts 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..990f02f52 --- /dev/null +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/dgram.wit @@ -0,0 +1,60 @@ +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..48f04f6da --- /dev/null +++ b/packages/jco/src/node-builtins/dgram.ts @@ -0,0 +1,28 @@ +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 = { From b67e51badfd30689b9ac841a6eeab984b85a7db8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:28:29 +0000 Subject: [PATCH 06/12] test(jco): exercise UDP components with Node passthrough --- .../componentize/node-dgram/component.js | 174 ++++++++++++++++++ .../fixtures/componentize/node-dgram/run.js | 103 +++++++++++ .../componentize/node-dgram/wit/component.wit | 11 ++ packages/jco/test/node/builtins.js | 2 + packages/jco/test/node/dgram.js | 107 +++++++++++ 5 files changed, 397 insertions(+) create mode 100644 packages/jco/test/fixtures/componentize/node-dgram/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-dgram/run.js create mode 100644 packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit create mode 100644 packages/jco/test/node/dgram.js 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..14e27f9ab --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/component.js @@ -0,0 +1,174 @@ +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..5d4120c6a --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/run.js @@ -0,0 +1,103 @@ +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[argv[3]] = 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..a9e2753a2 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit @@ -0,0 +1,11 @@ +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..5d2b7968c --- /dev/null +++ b/packages/jco/test/node/dgram.js @@ -0,0 +1,107 @@ +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": NODE_HOST } }).map["jco:node/dgram@0.1.0"], + ).toBe(NODE_HOST); +}); + +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(), + "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host": deniedHost, + }, + }, + }); + 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": NODE_HOST }, + }, + }, + }, + }); + 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, + ); + }); + } +}); From d06f62ae6eb22e9023058bceb75bdd00fa80a7bc Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:28:45 +0000 Subject: [PATCH 07/12] docs(jco): document UDP host mapping and compatibility --- docs/src/SUMMARY.md | 1 + docs/src/interop/nodejs-builtins.md | 2 +- .../supported-modules/dgram.md | 82 +++++++++++++++++++ .../supported-modules/index.md | 1 + 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/src/interop/nodejs-builtins/supported-modules/dgram.md 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..0190a0b06 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/dgram.md @@ -0,0 +1,82 @@ +# `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=udp-host' +``` + +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['udp-host'] = 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`. | From 21b23d18a0c878c26090519df47a450ad00ecae2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:39:47 +0000 Subject: [PATCH 08/12] test(jco): instantiate UDP under its WIT interface name --- .../fixtures/componentize/node-dgram/run.js | 2 +- packages/jco/test/node/dgram.js | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/jco/test/fixtures/componentize/node-dgram/run.js b/packages/jco/test/fixtures/componentize/node-dgram/run.js index 5d4120c6a..2a1474665 100644 --- a/packages/jco/test/fixtures/componentize/node-dgram/run.js +++ b/packages/jco/test/fixtures/componentize/node-dgram/run.js @@ -11,7 +11,7 @@ const { createDgramHost } = await import(argv[3]); async function createInstance() { const imports = new WASIShim().getImportObject(); let instance; - imports[argv[3]] = createDgramHost(() => instance.dgramCallbacks); + imports["jco:node/dgram@0.1.0"] = createDgramHost(() => instance.dgramCallbacks); instance = await instantiate(undefined, imports); return instance; } diff --git a/packages/jco/test/node/dgram.js b/packages/jco/test/node/dgram.js index 5d2b7968c..2cbf3a6fd 100644 --- a/packages/jco/test/node/dgram.js +++ b/packages/jco/test/node/dgram.js @@ -34,8 +34,10 @@ test("node:dgram requests only its UDP capability and callback export", async () 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": NODE_HOST } }).map["jco:node/dgram@0.1.0"], - ).toBe(NODE_HOST); + 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 = @@ -60,7 +62,14 @@ describe.skipIf(!hasJspi)("node:dgram components", () => { path: componentPath, imports: { ...new WASIShim().getImportObject(), - "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host": deniedHost, + "jco:node/dgram@0.1.0": deniedHost, + }, + }, + jco: { + transpile: { + extraArgs: { + map: { "jco:node/dgram@0.1.0": "jco:node/dgram@0.1.0" }, + }, }, }, }); @@ -84,7 +93,7 @@ describe.skipIf(!hasJspi)("node:dgram components", () => { extraArgs: { asyncMode: "jspi", asyncExports: ["*"], - map: { "jco:node/dgram@0.1.0": NODE_HOST }, + map: { "jco:node/dgram@0.1.0": "jco:node/dgram@0.1.0" }, }, }, }, From 97770dec98e787d5f92fbe56eecbf821992b4036 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:41:22 +0000 Subject: [PATCH 09/12] docs(jco): preserve the UDP WIT import name in examples --- docs/src/interop/nodejs-builtins/supported-modules/dgram.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/dgram.md b/docs/src/interop/nodejs-builtins/supported-modules/dgram.md index 0190a0b06..9b70ff51e 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/dgram.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/dgram.md @@ -33,9 +33,10 @@ 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=udp-host' + --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 @@ -45,7 +46,7 @@ import { createDgramHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x. const imports = new WASIShim().getImportObject(); let instance; -imports['udp-host'] = createDgramHost(() => instance.dgramCallbacks); +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(). From 4f1a0c2025b0b52500d55a2ba7e5e5060ca4ef6c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:47:19 +0000 Subject: [PATCH 10/12] chore(std): remove duplicate UDP license file --- .../src/wasi/0.2.x/node/24.x.x/dgram/LICENSE | 20 ------------------- .../src/wasi/0.2.x/node/24.x.x/dgram/core.ts | 2 +- .../src/wasi/0.2.x/node/24.x.x/dgram/send.ts | 2 +- 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE deleted file mode 100644 index 6de584a48..000000000 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -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. 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 index 8a9d29e45..95592a1e0 100644 --- 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 @@ -22,7 +22,7 @@ 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; see LICENSE). + * 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. 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 index 80ffb4402..a37fd6a51 100644 --- 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 @@ -23,7 +23,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. /** * Adapted from nodejs/node lib/dgram.js (sliceBuffer, fixBufferList, send), * v24.20.0, 71b8b174857e25106d39b61a9e6f30d927da8b01, MIT. - * See LICENSE in this directory. Local changes: typed unknown input and a single + * 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"; From b20c207b016166008b55a6d74adb74385d9f9fcd Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:50:40 +0000 Subject: [PATCH 11/12] style(std): separate UDP declarations and methods --- .../wasi/0.2.x/node/24.x.x/dgram-host-node.ts | 22 ++++ .../src/wasi/0.2.x/node/24.x.x/dgram-host.ts | 3 + .../src/wasi/0.2.x/node/24.x.x/dgram.ts | 5 + .../src/wasi/0.2.x/node/24.x.x/dgram/core.ts | 55 +++++++++ .../wasi/0.2.x/node/24.x.x/dgram/errors.ts | 15 +++ .../src/wasi/0.2.x/node/24.x.x/dgram/send.ts | 3 + .../src/wasi/0.2.x/node/24.x.x/dgram/types.ts | 89 ++++++++++++++ .../wasi/0.2.x/node/24.x.x/dgram/close.ts | 1 + .../wasi/0.2.x/node/24.x.x/dgram/connect.ts | 1 + .../0.2.x/node/24.x.x/dgram/create-socket.ts | 1 + .../0.2.x/node/24.x.x/dgram/helpers/setup.ts | 3 + .../wasi/0.2.x/node/24.x.x/dgram/lifecycle.ts | 1 + packages/jco-std/wit/node-0.1.0/dgram.wit | 110 +++++++++++++++--- 13 files changed, 295 insertions(+), 14 deletions(-) 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 index b3bc99faa..acf3aa823 100644 --- 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 @@ -30,14 +30,18 @@ import type { // 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, @@ -66,6 +70,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { this.#deliver({ tag: "error", val: serializeError(error) }), ); } + #deliver(event: SocketEvent): void { void enqueue(async () => { if (!this.#closed || event.tag === "sent") { @@ -80,15 +85,19 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { }); }); } + 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({ @@ -100,6 +109,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { }); }); } + send( data: Uint8Array, port: number | undefined, @@ -127,12 +137,14 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { } }, serializeError); } + address(remote: boolean): Result { return capture( () => (remote ? this.#socket.remoteAddress() : this.#socket.address()), serializeError, ); } + setOption(option: SocketOption): Result { return capture(() => { switch (option.tag) { @@ -160,6 +172,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { } }, serializeError); } + query(query: SocketQuery): Result { return capture(() => { switch (query) { @@ -174,6 +187,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { } }, serializeError); } + membership( action: Membership, group: string, @@ -197,6 +211,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { } }, serializeError); } + close(): void { if (this.#closed) { return; @@ -208,6 +223,7 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { void enqueue(() => retireCallbacks(enqueue, this.#listener)); }); } + setRef(ref: boolean): void { if (ref) { this.#socket.ref(); @@ -215,26 +231,32 @@ export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost { 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 index 895640af6..7e8fb1427 100644 --- 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 @@ -1,5 +1,6 @@ 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", @@ -9,10 +10,12 @@ export const createSocket: DgramHost["createSocket"] = () => ({ 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.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram.ts index 6895b32a0..270dbc4bd 100644 --- 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 @@ -1,9 +1,13 @@ 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, @@ -13,4 +17,5 @@ export type { 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 index 95592a1e0..6d48c998d 100644 --- 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 @@ -71,24 +71,31 @@ import type { 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; @@ -179,11 +186,13 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen diagnostic.publish({ socket: this }); } } + #health(): void { if (this.#closed) { throw notRunning(); } } + #ensure(): HostSocket { this.#health(); if (!this.#handle) { @@ -217,6 +226,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } return this.#handle; } + #event(event: SocketEvent): void { if (this.#closed && event.tag !== "sent") { return; @@ -270,6 +280,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } } } + #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; @@ -290,12 +301,14 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen 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 = []; @@ -316,6 +329,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } this.#queue.push(operation); } + #bound(): void { this.#bindState = "bound"; if (this.#options.recvBufferSize) { @@ -325,6 +339,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen this.setSendBufferSize(this.#options.sendBufferSize); } } + bind(port?: number, address?: string, callback?: () => void): this; bind(port?: number, callback?: () => void): this; bind(callback?: () => void): this; @@ -394,6 +409,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen }); return this; } + bindSync(options: BindOptions = {}): AddressInfo { this.#health(); if (options === null || typeof options !== "object" || Array.isArray(options)) { @@ -424,6 +440,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen }); 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 { @@ -484,6 +501,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen connect(); } } + connectSync(port: number, address?: string): void { this.#health(); port = validatePort(port); @@ -513,11 +531,13 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } }); } + #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(); @@ -525,6 +545,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen 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; @@ -619,6 +640,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen send(); } } + sendto( buffer: string | ArrayBufferView, offset: number, @@ -633,6 +655,7 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen validateString(address, "address"); this.send(buffer, offset, length, port, address, callback); } + close(callback?: () => void): this { if (typeof callback === "function") { this.on("close", callback); @@ -653,22 +676,26 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } 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") { @@ -676,34 +703,42 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } 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) { @@ -718,61 +753,77 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen } 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") { @@ -782,18 +833,22 @@ export function createDgram(host: DgramHost): import("./types.js").DgramImplemen 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 index 74f8ade76..60de2049c 100644 --- 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 @@ -13,7 +13,9 @@ import { 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" @@ -22,27 +24,34 @@ export function socketError(code: string, message: string): Error & { code: stri 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); @@ -60,6 +69,7 @@ export function serializeError(error: unknown): DgramError { port: typeof record.port === "number" ? record.port : undefined, }; } + export function fromHost(error: DgramError): Error { const result = error.name === "TypeError" @@ -96,9 +106,11 @@ export function fromHost(error: DgramError): Error { 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, }); @@ -106,6 +118,7 @@ export function fromHost(error: DgramError): Error { } return result; } + export function validatePort(value: unknown, name = "Port", zero = false): number { if ( (typeof value !== "number" && typeof value !== "string") || @@ -123,11 +136,13 @@ export function validatePort(value: unknown, name = "Port", zero = false): numbe } 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 index a37fd6a51..db1e5fa22 100644 --- 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 @@ -30,6 +30,7 @@ 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); @@ -39,6 +40,7 @@ function bytes(value: unknown): Uint8Array { } 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; @@ -53,6 +55,7 @@ function slice(value: unknown, offset: unknown, length: unknown): Uint8Array { } return buffer.subarray(start, start + size); } + export function normalizeSend( isConnected: boolean, buffer: unknown, 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 index 969431c9b..1c97578a4 100644 --- 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 @@ -2,15 +2,18 @@ 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; @@ -19,9 +22,12 @@ export interface SocketError extends Error { 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; @@ -34,108 +40,145 @@ export interface SocketOptions { 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; @@ -143,6 +186,7 @@ export interface SocketEvents { 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; @@ -150,26 +194,41 @@ export interface SocketEvents { 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; @@ -194,6 +253,7 @@ export interface Socket extends SocketEvents, AsyncDisposable { address?: string, callback?: SendCallback, ): void; + sendto( message: string | ArrayBufferView, offset: number, @@ -202,43 +262,72 @@ export interface Socket extends SocketEvents, AsyncDisposable { 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 & { 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 index c81e7ab66..c4e4dc6dc 100644 --- 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 @@ -20,6 +20,7 @@ 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); }, 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 index 2ee1a48e6..07e911986 100644 --- 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 @@ -5,6 +5,7 @@ test("connecting state rejects repeated calls; lookup failure is retryable", asy let fail = false; const socket = setup().createSocket({ type: "udp4", + lookup(name, _family, callback) { queueMicrotask(() => fail && name === "bad" 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 index 7e61dbd01..c80fff252 100644 --- 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 @@ -30,6 +30,7 @@ test.skipIf(!process.versions.node.startsWith("24."))( test("unknown option getters are not evaluated", () => { const socket = setup().createSocket({ type: "udp4", + get unused() { throw Error("unknown option read"); }, 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 index 380903b06..f854b87f5 100644 --- 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 @@ -1,10 +1,12 @@ 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[]) => { @@ -14,6 +16,7 @@ export function event(socket: Socket, name: string): Promise { socket.once("error", reject); }); } + export function errorShape(call: () => unknown) { try { call(); 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 index 043545ffa..10db3b1a3 100644 --- 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 @@ -40,6 +40,7 @@ test("custom lookup and AbortSignal", async () => { 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)); diff --git a/packages/jco-std/wit/node-0.1.0/dgram.wit b/packages/jco-std/wit/node-0.1.0/dgram.wit index 990f02f52..33f6cafc0 100644 --- a/packages/jco-std/wit/node-0.1.0/dgram.wit +++ b/packages/jco-std/wit/node-0.1.0/dgram.wit @@ -4,8 +4,18 @@ package jco:node@0.1.0; /// 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 } + variant errno { + number(s64), + symbolic(string), + } + + record buffer-error-info { + errno: f64, + code: string, + message: string, + syscall: string, + } + record error { name: string, message: string, @@ -16,9 +26,25 @@ interface dgram { 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 } + + 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), @@ -28,21 +54,55 @@ interface dgram { 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 } + + 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>; + + 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>; + + membership: func( + action: membership-action, + group: string, + source: option, + iface: option, + ) -> result<_, error>; + close: func(); + set-ref: func(%ref: bool); } } @@ -51,10 +111,32 @@ interface dgram { /// 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); } + + 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; } From 84b832a971096828c5354def90b90b615dfcb634 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 10 Sep 2026 17:50:47 +0000 Subject: [PATCH 12/12] style(jco): space UDP interfaces and fixture declarations --- .../lib/wit/builtin/jco-node-0.1.0/dgram.wit | 110 +++++++++++++++--- packages/jco/src/node-builtins/dgram.ts | 2 + .../componentize/node-dgram/component.js | 6 + .../fixtures/componentize/node-dgram/run.js | 4 + .../componentize/node-dgram/wit/component.wit | 8 ++ packages/jco/test/node/dgram.js | 2 + 6 files changed, 118 insertions(+), 14 deletions(-) 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 index 990f02f52..33f6cafc0 100644 --- 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 @@ -4,8 +4,18 @@ package jco:node@0.1.0; /// 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 } + variant errno { + number(s64), + symbolic(string), + } + + record buffer-error-info { + errno: f64, + code: string, + message: string, + syscall: string, + } + record error { name: string, message: string, @@ -16,9 +26,25 @@ interface dgram { 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 } + + 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), @@ -28,21 +54,55 @@ interface dgram { 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 } + + 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>; + + 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>; + + membership: func( + action: membership-action, + group: string, + source: option, + iface: option, + ) -> result<_, error>; + close: func(); + set-ref: func(%ref: bool); } } @@ -51,10 +111,32 @@ interface dgram { /// 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); } + + 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/node-builtins/dgram.ts b/packages/jco/src/node-builtins/dgram.ts index 48f04f6da..5d277ade6 100644 --- a/packages/jco/src/node-builtins/dgram.ts +++ b/packages/jco/src/node-builtins/dgram.ts @@ -8,7 +8,9 @@ import { 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( diff --git a/packages/jco/test/fixtures/componentize/node-dgram/component.js b/packages/jco/test/fixtures/componentize/node-dgram/component.js index 14e27f9ab..16c4840e6 100644 --- a/packages/jco/test/fixtures/componentize/node-dgram/component.js +++ b/packages/jco/test/fixtures/componentize/node-dgram/component.js @@ -24,6 +24,7 @@ export function shape() { socket.close(); return JSON.stringify(result); } + export function denied() { const socket = createSocket("udp4"); try { @@ -35,6 +36,7 @@ export function denied() { socket.close(); } } + function watch(socket) { sockets.push(socket); socket.on("error", (error) => errors.push({ code: error.code, message: error.message })); @@ -43,6 +45,7 @@ function watch(socket) { 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) => { @@ -70,6 +73,7 @@ export function start(ipv6) { } 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. @@ -94,9 +98,11 @@ export function client(port) { 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(); diff --git a/packages/jco/test/fixtures/componentize/node-dgram/run.js b/packages/jco/test/fixtures/componentize/node-dgram/run.js index 2a1474665..b1a9cb588 100644 --- a/packages/jco/test/fixtures/componentize/node-dgram/run.js +++ b/packages/jco/test/fixtures/componentize/node-dgram/run.js @@ -8,6 +8,7 @@ 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; @@ -15,6 +16,7 @@ async function createInstance() { 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()); @@ -26,11 +28,13 @@ async function until(instance, condition) { } 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"], diff --git a/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit b/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit index a9e2753a2..b15b5fbf5 100644 --- a/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit +++ b/packages/jco/test/fixtures/componentize/node-dgram/wit/component.wit @@ -1,11 +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/dgram.js b/packages/jco/test/node/dgram.js index 2cbf3a6fd..a4e7106bd 100644 --- a/packages/jco/test/node/dgram.js +++ b/packages/jco/test/node/dgram.js @@ -11,6 +11,7 @@ import { componentizeFixture, exec, getTmpDir, setupAsyncTest } from "../helpers 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 () => { @@ -43,6 +44,7 @@ test("UDP defaults to denial and preserves explicit host mappings", () => { 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, () => {