Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/interop/nodejs-builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
83 changes: 83 additions & 0 deletions docs/src/interop/nodejs-builtins/supported-modules/dgram.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# `node:dgram`

| Imports | Implementation |
| --- | --- |
| `node:dgram` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram` |

Application code keeps ordinary Node imports:

```js
import { createSocket } from 'node:dgram';

let socket;
export function start() {
socket = createSocket('udp4');
socket.on('message', (message, remote) => {
socket.send(message, remote.port, remote.address);
});
return socket.bindSync({ address: '127.0.0.1', port: 0 }).port;
}
export function stop() { socket.close(); }
```

Use a world exporting `start: func() -> u16` and `stop: func()`, then build
with `jco componentize source.js --bundle --backend starlingmonkey -w wit -o app.wasm`.
Jco installs `jco:node/dgram@0.1.0` and the guest-exported
`jco:node/dgram-callbacks@0.1.0` interface. UDP adds no unrelated WASI imports.
The default provider returns a catchable `ERR_JCO_DGRAM_ADAPTER_REQUIRED` error
on capability use; importing, constructing, ref/unref, and closing an unused
socket need no host access.

To grant UDP access, transpile for explicit instantiation:

```console
jco transpile app.wasm -o out --instantiation async \
--async-mode jspi --async-exports '*' \
--map 'jco:node/dgram@0.1.0=jco:node/dgram@0.1.0'
```

This mapping preserves the WIT interface name in the instantiation imports.
Wire a separate Node provider to each instance:

```js
import { instantiate } from './out/app.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import { createDgramHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host/node';

const imports = new WASIShim().getImportObject();
let instance;
imports['jco:node/dgram@0.1.0'] = createDgramHost(() => instance.dgramCallbacks);
instance = await instantiate(undefined, imports);
const port = await instance.start();
// Send datagrams to 127.0.0.1:port, then call await instance.stop().
```

The guest implements Node v24.20.0's socket state, overloads, Buffer messages,
lookup customization, block lists, AbortSignal, events, and disposal. IPv4/IPv6,
bind/connect (including their synchronous forms), sends, address queries,
broadcast, multicast memberships, buffer options, and ref/unref use the typed UDP
provider. Host errors preserve codes, errno, address/port, syscall, and buffer
SystemError details. Native descriptors and shared cluster-handle adoption throw
`ERR_JCO_UNSUPPORTED_NODE_API`. The deprecated `_createSocketHandle`,
`_handle`, `_receiving`, `_bindState`, `_queue`, `_reuseAddr`,
`_healthCheck`, and `_stopReceiving` entries immediately throw
`ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`; legacy `sendto` remains functional.

The Node provider requires native `bindSync` and `connectSync` (available in
Node 24.20.0). It queues datagrams, DNS results, and send completions through the
component's callback exports. Return from exported guest tasks before waiting for
these events on the host; awaiting a future UDP event inside an active guest task
would require component re-entry. Guest-local microtasks replace Node's nextTick
scheduling, and native async-hooks IDs do not cross the boundary. QuickJS currently
traps on host-invoked exported resource methods, so its tests cover the module,
validation, and denial; full UDP component tests use StarlingMonkey. Multicast and
reuse-port availability depend on the host OS.

The implementation adapts MIT-licensed Node
[lib/dgram.js](https://github.com/nodejs/node/blob/71b8b174857e25106d39b61a9e6f30d927da8b01/lib/dgram.js)
and its internal handle/lookup flow at v24.20.0. Provenance and the license remain
in the source and emitted JavaScript. Audited unenv 2.0.0-rc.24 dgram is a mock
with no-op network methods and fixed addresses/buffer sizes, so Jco uses its own
adapter and reuses the already-supported Buffer/EventEmitter cores. Only
`node:dgram` is intercepted; bare `dgram` is unchanged. Direct jco-std
adapters can coexist with bundled Node builtins.
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
19 changes: 19 additions & 0 deletions packages/jco-std/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
262 changes: 262 additions & 0 deletions packages/jco-std/src/wasi/0.2.x/node/24.x.x/dgram-host-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* Opt-in Node UDP provider. Maps the typed WIT socket to node:dgram v24.20.0
* (nodejs/node 71b8b174857e25106d39b61a9e6f30d927da8b01, lib/dgram.js, MIT).
* Each provider has its own callback queue; no component's listener can be redeemed
* by another instance. Node objects and resolver callbacks stay on the host.
*/
import * as dgram from "node:dgram";
import { lookup } from "node:dns";
import {
CallbackResource,
createCallbackQueue,
retireCallbacks,
} from "./internal/callback-resource.js";
import { capture } from "./internal/host-error.js";
import { serializeError } from "./dgram/errors.js";
import type {
AddressInfo,
DgramCallbacks,
DgramHost,
HostOptions,
HostSocket,
Membership,
Result,
SocketEvent,
SocketListener,
SocketOption,
SocketQuery,
} from "./dgram/types.js";

// These APIs landed in Node 24.20.0 after the installed @types/node 24 declarations.
interface NativeSocket extends dgram.Socket {
bindSync(options: { address: string; port: number }): AddressInfo;

connectSync(port: number, address: string): void;
}

export function createDgramHost(getCallbacks: () => DgramCallbacks): DgramHost {
const enqueue = createCallbackQueue();

class Socket implements HostSocket {
#socket: NativeSocket;
#closed = false;
#listener: CallbackResource<SocketListener>;

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<AddressInfo> {
return capture(() => this.#socket.bindSync({ address, port }), serializeError);
}

connect(address: string, port: number): Result<void> {
return capture(() => this.#socket.connectSync(port, address), serializeError);
}

disconnect(): Result<void> {
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<void> {
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<AddressInfo> {
return capture(
() => (remote ? this.#socket.remoteAddress() : this.#socket.address()),
serializeError,
);
}

setOption(option: SocketOption): Result<void> {
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<number> {
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<void> {
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 };
Loading
Loading