@polyengine/wasi 0.1.0-pre.g633468a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,992 @@
1
+ // `wasi:sockets@0.2` — network, instance-network, tcp, tcp-create-socket,
2
+ // udp, udp-create-socket, ip-name-lookup: the poll-shaped 0.2 surface,
3
+ // served over the same node-builtins backend as the 0.3 track
4
+ // (sockets_platform.ts) and registered by `sockets()` alongside it.
5
+ //
6
+ // THE DRIVING PATTERN (pinned empirically from the net-probe fixture's
7
+ // leaf imports — what wasi-libc actually calls): every potentially-slow
8
+ // operation is split into non-blocking halves plus a pollable —
9
+ // `start-connect`/`finish-connect` looping on `would-block`,
10
+ // `accept` returning `would-block` until `subscribe`'s pollable is ready,
11
+ // datagram streams with `receive(max)`/`check-send`+`send` batches — and
12
+ // wasi-libc emulates POSIX blocking by `pollable.block()` (the io.ts
13
+ // parking kernel, A14). Socket byte I/O rides `wasi:io/streams@0.2`:
14
+ // wasi-libc links the NON-blocking `input-stream.read` + `subscribe`
15
+ // (never `blocking-read`) and `check-write`/`write`/`blocking-flush` —
16
+ // exactly the surfaces of io.ts's async-backed `FedInputStream` /
17
+ // `SinkOutputStream`, which this module mints over connections. The
18
+ // PARKING therefore happens in `Pollable.block`/`poll` and
19
+ // `blocking-flush`, all already A14-marked: 0.2 socket guests need JSPI
20
+ // on V8 engines, like the 0.3 track's `listen`.
21
+ //
22
+ // 0.2's `error-code` is an ENUM (bare strings — the A10 rule), with a
23
+ // different vocabulary than 0.3's variant: it grew `unknown`,
24
+ // `would-block`, `not-in-progress`, `concurrency-conflict`,
25
+ // `new-socket-limit` and the name-lookup codes, and it lacks
26
+ // `connection-broken` (mapped to `connection-reset` here) and `other`
27
+ // (mapped to `unknown`). `network-error-code(borrow<error>)` downcasts
28
+ // exactly the io errors these socket streams minted (`SocketIoError`,
29
+ // the filesystem-error-code pattern).
30
+ //
31
+ // Recorded divergences (the sockets.ts stances carried over, plus 0.2's
32
+ // own):
33
+ //
34
+ // * tcp `bind` records the address; the OS bind is DEFERRED to listen/
35
+ // connect (node cannot bind an unconnected socket) — so finish-bind
36
+ // always succeeds and bind errors surface at the deferred call, with
37
+ // their real codes.
38
+ // * udp bind IS synchronous (the dgram sync-lookup trick), so
39
+ // start-bind performs it and finish-bind never returns would-block.
40
+ // * outgoing-datagram-stream.send hands datagrams to node and counts
41
+ // them sent — the WIT's "sent (or queued for sending)" latitude; an
42
+ // async send failure surfaces on the NEXT check-send/send call.
43
+ // * udp connected mode uses the OS connect when the backend has it,
44
+ // with the provider-side source filter as a BACKSTOP (Deno's dgram
45
+ // compat treats connect() as default-destination only).
46
+ // * socket options: identical honesty to the 0.3 track (module header
47
+ // of sockets.ts) — tcp keep-alive enabled/idle-time and udp
48
+ // hop-limit/buffer-sizes are real where node has API, cached-getter
49
+ // where it has only a setter, `not-supported` where it has neither.
50
+ import { ComponentException } from "@polyengine/runtime/embedder";
51
+ import { FedInputStream, IoError, Pollable, SinkOutputStream } from "../io.js";
52
+ import { dnsLookup, listenDatagram, tcpConnect, tcpListen, } from "./sockets_platform.js";
53
+ import { ipHostname, isUnspecified, isValidAddressFamily, mapPlatformError, MAX_UDP_DATAGRAM_SIZE, parseNetAddr, sameSocketAddress, } from "./sockets_shared.js";
54
+ function err02(code, detail) {
55
+ return new ComponentException(code, `wasi:sockets@0.2: ${detail}`);
56
+ }
57
+ /** The 0.3 variant kinds this backend produces, onto the 0.2 enum. */
58
+ const KIND_TO_02 = {
59
+ "access-denied": "access-denied",
60
+ "not-supported": "not-supported",
61
+ "invalid-argument": "invalid-argument",
62
+ "out-of-memory": "out-of-memory",
63
+ "timeout": "timeout",
64
+ "invalid-state": "invalid-state",
65
+ "address-not-bindable": "address-not-bindable",
66
+ "address-in-use": "address-in-use",
67
+ "remote-unreachable": "remote-unreachable",
68
+ "connection-refused": "connection-refused",
69
+ "connection-reset": "connection-reset",
70
+ "connection-aborted": "connection-aborted",
71
+ "datagram-too-large": "datagram-too-large",
72
+ // 0.2 has no connection-broken (EPIPE): reset is the nearest truth.
73
+ "connection-broken": "connection-reset",
74
+ };
75
+ /** Map a platform failure to the 0.2 enum (via the shared 0.3 mapper). */
76
+ function toCode02(e, what) {
77
+ if (e instanceof ComponentException) {
78
+ const payload = e.payload;
79
+ if (typeof payload === "string")
80
+ return payload; // already 0.2
81
+ const kind = payload?.kind;
82
+ if (typeof kind === "string")
83
+ return KIND_TO_02[kind] ?? "unknown";
84
+ return "unknown";
85
+ }
86
+ return KIND_TO_02[mapPlatformError(e, what).payload.kind] ?? "unknown";
87
+ }
88
+ function raise02(e, what) {
89
+ const message = e instanceof Error ? e.message : String(e);
90
+ throw err02(toCode02(e, what), `${what}: ${message}`);
91
+ }
92
+ /**
93
+ * The io `error` resource minted by 0.2 socket STREAM failures, carrying
94
+ * the error-code so `network-error-code(borrow<error>)` can downcast it
95
+ * (the filesystem-error-code pattern; SinkOutputStream and
96
+ * FedInputStream preserve IoError subclasses).
97
+ */
98
+ export class SocketIoError extends IoError {
99
+ code;
100
+ constructor(code, message) {
101
+ super(message);
102
+ this.code = code;
103
+ }
104
+ }
105
+ /** `wasi:sockets/network@0.2`'s opaque capability resource. */
106
+ export class Network {
107
+ }
108
+ const TCP_RECEIVE_CHUNK = 16384;
109
+ /** check-send's fixed permit (the WIT wants a positive count; sends are
110
+ * handed to node immediately, so the permit never genuinely shrinks). */
111
+ const CHECK_SEND_PERMIT = 64n;
112
+ /**
113
+ * Build the `wasi:sockets@0.2` interfaces (module header). `onCall` is
114
+ * the same fragment-scoped observer the 0.3 track takes.
115
+ */
116
+ export function sockets02(onCall) {
117
+ // One ambient network per fragment: `instance-network` returns it and
118
+ // the `network` parameters below are accepted without inspection (this
119
+ // host models exactly one network namespace).
120
+ const theNetwork = new Network();
121
+ const validateRemote = (family, remote, what) => {
122
+ if (!isValidAddressFamily(family, remote) || isUnspecified(remote) ||
123
+ remote.value.port === 0) {
124
+ throw err02("invalid-argument", `${what}: the remote address must be a specific address and non-zero ` +
125
+ `port in the socket's family (${family})`);
126
+ }
127
+ if (remote.kind === "ipv6" && remote.value.scopeId !== 0) {
128
+ throw err02("not-supported", `${what}: non-zero scope-id`);
129
+ }
130
+ };
131
+ const validateLocal = (family, local, what) => {
132
+ if (!isValidAddressFamily(family, local)) {
133
+ throw err02("invalid-argument", `${what}: address family mismatch (an ${family} socket)`);
134
+ }
135
+ if (local.kind === "ipv6" && local.value.scopeId !== 0) {
136
+ throw err02("not-supported", `${what}: non-zero scope-id`);
137
+ }
138
+ };
139
+ /** Mint the wasi:io stream pair over a connection (module header). */
140
+ const connStreams = (conn) => {
141
+ const input = new FedInputStream((async function* () {
142
+ for (;;) {
143
+ let chunk;
144
+ try {
145
+ chunk = await conn.read(TCP_RECEIVE_CHUNK);
146
+ }
147
+ catch (e) {
148
+ throw new SocketIoError(toCode02(e, "input-stream (socket read)"), e instanceof Error ? e.message : String(e));
149
+ }
150
+ if (chunk === null)
151
+ return; // peer FIN: clean stream close
152
+ if (chunk.length > 0)
153
+ yield chunk;
154
+ }
155
+ })());
156
+ const output = new SinkOutputStream(async (chunk) => {
157
+ try {
158
+ let at = 0;
159
+ while (at < chunk.length)
160
+ at += await conn.write(chunk.subarray(at));
161
+ }
162
+ catch (e) {
163
+ throw new SocketIoError(toCode02(e, "output-stream (socket write)"), e instanceof Error ? e.message : String(e));
164
+ }
165
+ });
166
+ return [input, output];
167
+ };
168
+ class TcpSocket02 {
169
+ #family;
170
+ #state = "unbound";
171
+ #localRequest;
172
+ #dial;
173
+ #listen;
174
+ #conn;
175
+ #input;
176
+ #output;
177
+ /** All accepted writes so far; shutdown(send) FINs after them. */
178
+ #lastWrite = Promise.resolve();
179
+ // The options cache — the sockets.ts honesty stances.
180
+ #backlog;
181
+ #keepAliveEnabled = false;
182
+ #keepAliveIdleNs = 7200000000000n;
183
+ constructor(family) {
184
+ this.#family = family;
185
+ }
186
+ /** An accepted connection, already `connected`. */
187
+ static accepted(family, conn) {
188
+ const socket = new TcpSocket02(family);
189
+ socket.#conn = conn;
190
+ socket.#state = "connected";
191
+ return socket;
192
+ }
193
+ /** `bind` records; the OS bind is deferred (module header). */
194
+ startBind(_network, localAddress) {
195
+ onCall("tcp-socket.start-bind");
196
+ if (this.#state !== "unbound") {
197
+ throw err02("invalid-state", `tcp-socket.start-bind: not bindable from '${this.#state}'`);
198
+ }
199
+ validateLocal(this.#family, localAddress, "tcp-socket.start-bind");
200
+ this.#localRequest = localAddress;
201
+ this.#state = "bind-in-progress";
202
+ }
203
+ finishBind() {
204
+ onCall("tcp-socket.finish-bind");
205
+ if (this.#state !== "bind-in-progress") {
206
+ throw err02("not-in-progress", "tcp-socket.finish-bind: no bind in progress");
207
+ }
208
+ this.#state = "bound"; // recorded; deferred to listen/connect (header)
209
+ }
210
+ startConnect(_network, remoteAddress) {
211
+ onCall("tcp-socket.start-connect");
212
+ if (this.#state !== "unbound" && this.#state !== "bound") {
213
+ throw err02("invalid-state", `tcp-socket.start-connect: not connectable from '${this.#state}'`);
214
+ }
215
+ validateRemote(this.#family, remoteAddress, "tcp-socket.start-connect");
216
+ const connect = tcpConnect();
217
+ if (connect === undefined) {
218
+ throw err02("not-supported", "tcp-socket.start-connect: no TCP backend (no node:net)");
219
+ }
220
+ const local = this.#localRequest;
221
+ const dial = { done: false, wait: Promise.resolve() };
222
+ dial.wait = connect({
223
+ transport: "tcp",
224
+ hostname: ipHostname(remoteAddress),
225
+ port: remoteAddress.value.port,
226
+ ...(local === undefined ? {} : {
227
+ localHostname: ipHostname(local),
228
+ localPort: local.value.port,
229
+ }),
230
+ }).then((conn) => {
231
+ dial.conn = conn;
232
+ dial.done = true;
233
+ // Dropped mid-dial: nothing will ever take this conn.
234
+ if (this.#state !== "connect-in-progress")
235
+ conn.close();
236
+ }, (e) => {
237
+ dial.error = e;
238
+ dial.done = true;
239
+ });
240
+ this.#dial = dial;
241
+ this.#state = "connect-in-progress";
242
+ }
243
+ finishConnect() {
244
+ onCall("tcp-socket.finish-connect");
245
+ const dial = this.#dial;
246
+ if (this.#state !== "connect-in-progress" || dial === undefined) {
247
+ throw err02("not-in-progress", "tcp-socket.finish-connect: no connect in progress");
248
+ }
249
+ if (!dial.done) {
250
+ throw err02("would-block", "tcp-socket.finish-connect: the dial has not settled");
251
+ }
252
+ if (dial.error !== undefined || dial.conn === undefined) {
253
+ // "After a failed connection attempt ... the only valid action
254
+ // left is to drop".
255
+ this.#state = "closed";
256
+ this.#dial = undefined;
257
+ raise02(dial.error, "tcp-socket.finish-connect");
258
+ }
259
+ this.#conn = dial.conn;
260
+ this.#dial = undefined;
261
+ this.#state = "connected";
262
+ this.#applyKeepAlive();
263
+ const [input, output] = this.#mintStreams(this.#conn);
264
+ return [input, output];
265
+ }
266
+ startListen() {
267
+ onCall("tcp-socket.start-listen");
268
+ if (this.#state !== "unbound" && this.#state !== "bound") {
269
+ throw err02("invalid-state", `tcp-socket.start-listen: not listenable from '${this.#state}'`);
270
+ }
271
+ const listen = tcpListen();
272
+ if (listen === undefined) {
273
+ throw err02("not-supported", "tcp-socket.start-listen: no TCP backend (no node:net)");
274
+ }
275
+ const local = this.#localRequest ?? wildcard(this.#family);
276
+ const listener = listen({
277
+ transport: "tcp",
278
+ hostname: ipHostname(local),
279
+ port: local.value.port,
280
+ ...(this.#backlog === undefined ? {} : { backlog: this.#backlog }),
281
+ });
282
+ const state = { listener, settled: false, wait: Promise.resolve() };
283
+ state.wait = listener.settled().then(() => {
284
+ state.settled = true;
285
+ }, (e) => {
286
+ state.error = e;
287
+ state.settled = true;
288
+ });
289
+ this.#listen = state;
290
+ this.#state = "listen-in-progress";
291
+ }
292
+ finishListen() {
293
+ onCall("tcp-socket.finish-listen");
294
+ const state = this.#listen;
295
+ if (this.#state !== "listen-in-progress" || state === undefined) {
296
+ throw err02("not-in-progress", "tcp-socket.finish-listen: no listen in progress");
297
+ }
298
+ if (!state.settled) {
299
+ throw err02("would-block", "tcp-socket.finish-listen: the OS bind has not settled");
300
+ }
301
+ if (state.error !== undefined) {
302
+ state.listener.close();
303
+ this.#listen = undefined;
304
+ this.#state = "closed";
305
+ raise02(state.error, "tcp-socket.finish-listen");
306
+ }
307
+ this.#state = "listening";
308
+ }
309
+ accept() {
310
+ onCall("tcp-socket.accept");
311
+ const state = this.#listen;
312
+ if (this.#state !== "listening" || state === undefined) {
313
+ throw err02("invalid-state", "tcp-socket.accept: the socket is not listening");
314
+ }
315
+ let conn;
316
+ try {
317
+ conn = state.listener.tryAccept === undefined
318
+ ? undefined
319
+ : state.listener.tryAccept();
320
+ }
321
+ catch (e) {
322
+ raise02(e, "tcp-socket.accept");
323
+ }
324
+ if (conn === undefined) {
325
+ throw err02("would-block", "tcp-socket.accept: no pending connections");
326
+ }
327
+ const socket = TcpSocket02.accepted(this.#family, conn);
328
+ const [input, output] = socket.#mintStreams(conn);
329
+ return [socket, input, output];
330
+ }
331
+ localAddress() {
332
+ onCall("tcp-socket.local-address");
333
+ if (this.#state === "listening" && this.#listen !== undefined) {
334
+ const addr = this.#listen.listener.addr;
335
+ if (addr !== null)
336
+ return parseNetAddr(addr);
337
+ }
338
+ if (this.#conn !== undefined)
339
+ return parseNetAddr(this.#conn.localAddr);
340
+ // 0.2 pins bound-but-not-yet-realized: the deferred bind means the
341
+ // recorded address (port possibly still 0) is the honest answer.
342
+ if (this.#state === "bound" && this.#localRequest !== undefined) {
343
+ return this.#localRequest;
344
+ }
345
+ throw err02("invalid-state", "tcp-socket.local-address: the socket is not bound");
346
+ }
347
+ remoteAddress() {
348
+ onCall("tcp-socket.remote-address");
349
+ if (this.#state !== "connected" || this.#conn === undefined) {
350
+ throw err02("invalid-state", "tcp-socket.remote-address: not connected");
351
+ }
352
+ return parseNetAddr(this.#conn.remoteAddr);
353
+ }
354
+ isListening() {
355
+ onCall("tcp-socket.is-listening");
356
+ return this.#state === "listening";
357
+ }
358
+ addressFamily() {
359
+ onCall("tcp-socket.address-family");
360
+ return this.#family;
361
+ }
362
+ setListenBacklogSize(value) {
363
+ onCall("tcp-socket.set-listen-backlog-size");
364
+ if (value === 0n) {
365
+ throw err02("invalid-argument", "tcp-socket.set-listen-backlog-size: zero");
366
+ }
367
+ if (this.#state === "listening" || this.#state === "listen-in-progress") {
368
+ throw err02("not-supported", "tcp-socket.set-listen-backlog-size: node cannot re-listen");
369
+ }
370
+ if (this.#state !== "unbound" && this.#state !== "bound") {
371
+ throw err02("invalid-state", `tcp-socket.set-listen-backlog-size: not settable in '${this.#state}'`);
372
+ }
373
+ this.#backlog = Number(value > 0x7fffffffn ? 0x7fffffffn : value);
374
+ }
375
+ keepAliveEnabled() {
376
+ onCall("tcp-socket.keep-alive-enabled");
377
+ return this.#keepAliveEnabled;
378
+ }
379
+ setKeepAliveEnabled(value) {
380
+ onCall("tcp-socket.set-keep-alive-enabled");
381
+ this.#keepAliveEnabled = value;
382
+ this.#applyKeepAlive();
383
+ }
384
+ keepAliveIdleTime() {
385
+ onCall("tcp-socket.keep-alive-idle-time");
386
+ return this.#keepAliveIdleNs;
387
+ }
388
+ setKeepAliveIdleTime(value) {
389
+ onCall("tcp-socket.set-keep-alive-idle-time");
390
+ if (value < 1n) {
391
+ throw err02("invalid-argument", "tcp-socket.set-keep-alive-idle-time: zero");
392
+ }
393
+ this.#keepAliveIdleNs = value;
394
+ this.#applyKeepAlive();
395
+ }
396
+ // No node:net API (the sockets.ts honesty stance):
397
+ keepAliveInterval() {
398
+ onCall("tcp-socket.keep-alive-interval");
399
+ throw this.#noOption("keep-alive-interval (TCP_KEEPINTVL)");
400
+ }
401
+ setKeepAliveInterval(_v) {
402
+ onCall("tcp-socket.set-keep-alive-interval");
403
+ throw this.#noOption("keep-alive-interval (TCP_KEEPINTVL)");
404
+ }
405
+ keepAliveCount() {
406
+ onCall("tcp-socket.keep-alive-count");
407
+ throw this.#noOption("keep-alive-count (TCP_KEEPCNT)");
408
+ }
409
+ setKeepAliveCount(_v) {
410
+ onCall("tcp-socket.set-keep-alive-count");
411
+ throw this.#noOption("keep-alive-count (TCP_KEEPCNT)");
412
+ }
413
+ hopLimit() {
414
+ onCall("tcp-socket.hop-limit");
415
+ throw this.#noOption("hop-limit (IP_TTL)");
416
+ }
417
+ setHopLimit(_v) {
418
+ onCall("tcp-socket.set-hop-limit");
419
+ throw this.#noOption("hop-limit (IP_TTL)");
420
+ }
421
+ receiveBufferSize() {
422
+ onCall("tcp-socket.receive-buffer-size");
423
+ throw this.#noOption("receive-buffer-size (SO_RCVBUF)");
424
+ }
425
+ setReceiveBufferSize(_v) {
426
+ onCall("tcp-socket.set-receive-buffer-size");
427
+ throw this.#noOption("receive-buffer-size (SO_RCVBUF)");
428
+ }
429
+ sendBufferSize() {
430
+ onCall("tcp-socket.send-buffer-size");
431
+ throw this.#noOption("send-buffer-size (SO_SNDBUF)");
432
+ }
433
+ setSendBufferSize(_v) {
434
+ onCall("tcp-socket.set-send-buffer-size");
435
+ throw this.#noOption("send-buffer-size (SO_SNDBUF)");
436
+ }
437
+ /** Readiness for the CURRENT pending operation (module header). */
438
+ subscribe() {
439
+ onCall("tcp-socket.subscribe");
440
+ const dial = this.#dial;
441
+ if (this.#state === "connect-in-progress" && dial !== undefined) {
442
+ return new Pollable(() => dial.done, () => dial.wait);
443
+ }
444
+ const listen = this.#listen;
445
+ if (this.#state === "listen-in-progress" && listen !== undefined) {
446
+ return new Pollable(() => listen.settled, () => listen.wait);
447
+ }
448
+ if (this.#state === "listening" && listen !== undefined) {
449
+ const l = listen.listener;
450
+ return new Pollable(() => l.acceptReady === undefined ? true : l.acceptReady(), () => l.waitAccept === undefined ? Promise.resolve() : l.waitAccept());
451
+ }
452
+ return new Pollable(); // no pending operation: ready
453
+ }
454
+ shutdown(shutdownType) {
455
+ onCall("tcp-socket.shutdown");
456
+ if (this.#state !== "connected" || this.#conn === undefined) {
457
+ throw err02("invalid-state", "tcp-socket.shutdown: the socket is not connected");
458
+ }
459
+ const conn = this.#conn;
460
+ if (shutdownType === "receive" || shutdownType === "both") {
461
+ this.#input?.[Symbol.dispose]();
462
+ }
463
+ if (shutdownType === "send" || shutdownType === "both") {
464
+ // FIN after everything the output stream already accepted (the
465
+ // kernel analogue: shutdown(SHUT_WR) follows queued data out).
466
+ void this.#lastWrite.then(() => conn.closeWrite()).catch(() => {
467
+ // The FIN lost a race with a reset; the streams report it.
468
+ });
469
+ }
470
+ }
471
+ #mintStreams(conn) {
472
+ const [input, rawOutput] = connStreams(conn);
473
+ // Interpose on the sink serialization so shutdown(send) can chain
474
+ // the FIN after the last accepted write.
475
+ const output = new SinkOutputStream((chunk) => {
476
+ const p = (async () => {
477
+ try {
478
+ let at = 0;
479
+ while (at < chunk.length)
480
+ at += await conn.write(chunk.subarray(at));
481
+ }
482
+ catch (e) {
483
+ throw new SocketIoError(toCode02(e, "output-stream (socket write)"), e instanceof Error ? e.message : String(e));
484
+ }
485
+ })();
486
+ this.#lastWrite = p.catch(() => { });
487
+ return p;
488
+ });
489
+ rawOutput[Symbol.dispose]();
490
+ this.#input = input;
491
+ this.#output = output;
492
+ return [input, output];
493
+ }
494
+ #noOption(what) {
495
+ return err02("not-supported", `tcp-socket: node:net exposes no ${what}`);
496
+ }
497
+ #applyKeepAlive() {
498
+ const conn = this.#conn;
499
+ if (this.#state !== "connected" || conn === undefined)
500
+ return;
501
+ if (conn.setKeepAlive === undefined) {
502
+ throw err02("not-supported", "tcp-socket: no keep-alive control on this backend");
503
+ }
504
+ try {
505
+ conn.setKeepAlive(this.#keepAliveEnabled, Number(this.#keepAliveIdleNs / 1000000n));
506
+ }
507
+ catch (e) {
508
+ raise02(e, "tcp-socket (applying keep-alive)");
509
+ }
510
+ }
511
+ [Symbol.dispose]() {
512
+ // The wasi:io streams hold their own references to the conn's byte
513
+ // channels in wasi-libc's hands; 0.2 sockets close with the handle
514
+ // (the streams then observe reset/closed) — matching wasmtime,
515
+ // where dropping the socket closes the fd.
516
+ this.#state = "closed";
517
+ this.#input?.[Symbol.dispose]();
518
+ this.#output?.[Symbol.dispose]();
519
+ try {
520
+ this.#conn?.close();
521
+ }
522
+ catch {
523
+ // Already closed.
524
+ }
525
+ this.#conn = undefined;
526
+ const listener = this.#listen?.listener;
527
+ this.#listen = undefined;
528
+ if (listener !== undefined) {
529
+ try {
530
+ listener.close();
531
+ }
532
+ catch {
533
+ // Already closed.
534
+ }
535
+ }
536
+ }
537
+ }
538
+ const createTcpSocket = (addressFamily) => {
539
+ onCall("tcp-create-socket.create-tcp-socket");
540
+ if (tcpConnect() === undefined) {
541
+ throw err02("not-supported", "create-tcp-socket: no TCP backend (no node:net)");
542
+ }
543
+ return new TcpSocket02(addressFamily);
544
+ };
545
+ class UdpSocket02 {
546
+ #family;
547
+ #state = "unbound";
548
+ #conn;
549
+ #streams;
550
+ #generation = 0;
551
+ #hopLimit;
552
+ #recvBuffer;
553
+ #sendBuffer;
554
+ constructor(family) {
555
+ this.#family = family;
556
+ }
557
+ /** udp bind is synchronous here (module header): start performs it. */
558
+ startBind(_network, localAddress) {
559
+ onCall("udp-socket.start-bind");
560
+ if (this.#state !== "unbound") {
561
+ throw err02("invalid-state", `udp-socket.start-bind: not bindable from '${this.#state}'`);
562
+ }
563
+ validateLocal(this.#family, localAddress, "udp-socket.start-bind");
564
+ const listen = listenDatagram();
565
+ if (listen === undefined) {
566
+ throw err02("not-supported", "udp-socket.start-bind: no datagram backend (no node:dgram)");
567
+ }
568
+ try {
569
+ this.#conn = listen({
570
+ transport: "udp",
571
+ hostname: ipHostname(localAddress),
572
+ port: localAddress.value.port,
573
+ });
574
+ }
575
+ catch (e) {
576
+ raise02(e, "udp-socket.start-bind");
577
+ }
578
+ this.#applyCachedOptions();
579
+ this.#state = "bind-in-progress";
580
+ }
581
+ finishBind() {
582
+ onCall("udp-socket.finish-bind");
583
+ if (this.#state !== "bind-in-progress") {
584
+ throw err02("not-in-progress", "udp-socket.finish-bind: no bind in progress");
585
+ }
586
+ this.#state = "bound";
587
+ }
588
+ /**
589
+ * `stream(remote?)` — replaces any previous stream pair (they turn
590
+ * `invalid-state`); `some` = connected mode (OS connect when the
591
+ * backend has it, filter backstop either way — module header).
592
+ * SUSPENDING via the registered mark relay is NOT needed: node's
593
+ * dgram connect settles via callback, so this parks one tick through
594
+ * the runtime's thenable acceptance on... no — 0.2 `stream` is a
595
+ * sync WIT func, so the connect is fired and the streams are handed
596
+ * out immediately; a connect failure surfaces on the first
597
+ * send/receive (recorded divergence — POSIX UDP connect failures are
598
+ * mostly lazy anyway).
599
+ */
600
+ stream(remoteAddress) {
601
+ onCall("udp-socket.stream");
602
+ if (this.#state !== "bound" || this.#conn === undefined) {
603
+ throw err02("invalid-state", "udp-socket.stream: the socket is not bound");
604
+ }
605
+ const conn = this.#conn;
606
+ if (remoteAddress !== undefined) {
607
+ validateRemote(this.#family, remoteAddress, "udp-socket.stream");
608
+ }
609
+ const wasConnected = this.#streams?.remote !== undefined;
610
+ this.#generation++;
611
+ const streams = { generation: this.#generation, remote: remoteAddress };
612
+ this.#streams = streams;
613
+ // OS-level (dis)connect, fire-and-forget: failures surface on the
614
+ // first datagram op (doc comment above).
615
+ if (remoteAddress !== undefined && conn.connect !== undefined) {
616
+ void conn.connect({
617
+ transport: "udp",
618
+ hostname: ipHostname(remoteAddress),
619
+ port: remoteAddress.value.port,
620
+ }).catch(() => {
621
+ // The filter backstop still enforces connected-mode semantics.
622
+ });
623
+ }
624
+ else if (remoteAddress === undefined && wasConnected) {
625
+ try {
626
+ conn.disconnect?.();
627
+ }
628
+ catch {
629
+ // Not connected at the OS level (compat backends).
630
+ }
631
+ }
632
+ const live = (gen) => this.#streams?.generation === gen && this.#state === "bound";
633
+ return [
634
+ new IncomingDatagramStream02(conn, streams, live),
635
+ new OutgoingDatagramStream02(conn, streams, live, this.#family),
636
+ ];
637
+ }
638
+ localAddress() {
639
+ onCall("udp-socket.local-address");
640
+ if (this.#conn === undefined) {
641
+ throw err02("invalid-state", "udp-socket.local-address: the socket is not bound");
642
+ }
643
+ return parseNetAddr(this.#conn.addr);
644
+ }
645
+ remoteAddress() {
646
+ onCall("udp-socket.remote-address");
647
+ const remote = this.#streams?.remote;
648
+ if (remote === undefined) {
649
+ throw err02("invalid-state", "udp-socket.remote-address: the socket is not connected");
650
+ }
651
+ return remote;
652
+ }
653
+ addressFamily() {
654
+ onCall("udp-socket.address-family");
655
+ return this.#family;
656
+ }
657
+ unicastHopLimit() {
658
+ onCall("udp-socket.unicast-hop-limit");
659
+ return this.#hopLimit ?? 64; // the documented default (sockets.ts stance)
660
+ }
661
+ setUnicastHopLimit(value) {
662
+ onCall("udp-socket.set-unicast-hop-limit");
663
+ if (value < 1) {
664
+ throw err02("invalid-argument", "udp-socket.set-unicast-hop-limit: below 1");
665
+ }
666
+ this.#hopLimit = value;
667
+ this.#applyCachedOptions();
668
+ }
669
+ receiveBufferSize() {
670
+ onCall("udp-socket.receive-buffer-size");
671
+ return this.#bufferSize("receive", this.#recvBuffer, this.#conn?.getRecvBufferSize);
672
+ }
673
+ setReceiveBufferSize(value) {
674
+ onCall("udp-socket.set-receive-buffer-size");
675
+ if (value === 0n) {
676
+ throw err02("invalid-argument", "udp-socket.set-receive-buffer-size: zero");
677
+ }
678
+ this.#recvBuffer = value;
679
+ this.#applyCachedOptions();
680
+ }
681
+ sendBufferSize() {
682
+ onCall("udp-socket.send-buffer-size");
683
+ return this.#bufferSize("send", this.#sendBuffer, this.#conn?.getSendBufferSize);
684
+ }
685
+ setSendBufferSize(value) {
686
+ onCall("udp-socket.set-send-buffer-size");
687
+ if (value === 0n) {
688
+ throw err02("invalid-argument", "udp-socket.set-send-buffer-size: zero");
689
+ }
690
+ this.#sendBuffer = value;
691
+ this.#applyCachedOptions();
692
+ }
693
+ subscribe() {
694
+ onCall("udp-socket.subscribe");
695
+ return new Pollable(); // binds are synchronous: never mid-operation
696
+ }
697
+ #bufferSize(which, cached, live) {
698
+ if (this.#conn !== undefined && live !== undefined) {
699
+ try {
700
+ return BigInt(live.call(this.#conn));
701
+ }
702
+ catch (e) {
703
+ raise02(e, `udp-socket.${which}-buffer-size`);
704
+ }
705
+ }
706
+ if (cached !== undefined)
707
+ return cached;
708
+ throw err02("not-supported", `udp-socket.${which}-buffer-size: unknowable before bind on this host`);
709
+ }
710
+ #applyCachedOptions() {
711
+ const conn = this.#conn;
712
+ if (conn === undefined)
713
+ return;
714
+ try {
715
+ if (this.#hopLimit !== undefined)
716
+ conn.setTtl?.(this.#hopLimit);
717
+ if (this.#recvBuffer !== undefined)
718
+ conn.setRecvBufferSize?.(Number(this.#recvBuffer));
719
+ if (this.#sendBuffer !== undefined)
720
+ conn.setSendBufferSize?.(Number(this.#sendBuffer));
721
+ }
722
+ catch (e) {
723
+ raise02(e, "udp-socket (applying cached options)");
724
+ }
725
+ }
726
+ [Symbol.dispose]() {
727
+ this.#state = "closed";
728
+ this.#streams = undefined;
729
+ const conn = this.#conn;
730
+ this.#conn = undefined;
731
+ if (conn !== undefined) {
732
+ try {
733
+ conn.close();
734
+ }
735
+ catch {
736
+ // Already closed.
737
+ }
738
+ }
739
+ }
740
+ }
741
+ class IncomingDatagramStream02 {
742
+ #conn;
743
+ #streams;
744
+ #live;
745
+ constructor(conn, streams, live) {
746
+ this.#conn = conn;
747
+ this.#streams = streams;
748
+ this.#live = live;
749
+ }
750
+ /** Non-blocking batch; empty list = nothing pending (never would-block). */
751
+ receive(maxResults) {
752
+ onCall("incoming-datagram-stream.receive");
753
+ if (!this.#live(this.#streams.generation)) {
754
+ throw err02("invalid-state", "incoming-datagram-stream.receive: stale stream");
755
+ }
756
+ const out = [];
757
+ const max = Number(maxResults);
758
+ while (out.length < max) {
759
+ let item;
760
+ try {
761
+ item = this.#conn.tryReceive === undefined ? undefined : this.#conn.tryReceive();
762
+ }
763
+ catch (e) {
764
+ raise02(e, "incoming-datagram-stream.receive");
765
+ }
766
+ if (item === undefined)
767
+ break;
768
+ const source = parseNetAddr(item[1]);
769
+ // The connected-mode filter backstop (module header).
770
+ const remote = this.#streams.remote;
771
+ if (remote !== undefined && !sameSocketAddress(source, remote))
772
+ continue;
773
+ out.push({ data: item[0], remoteAddress: source });
774
+ }
775
+ return out;
776
+ }
777
+ subscribe() {
778
+ onCall("incoming-datagram-stream.subscribe");
779
+ const conn = this.#conn;
780
+ return new Pollable(() => conn.receiveReady === undefined ? true : conn.receiveReady(), () => conn.waitReceive === undefined ? Promise.resolve() : conn.waitReceive());
781
+ }
782
+ [Symbol.dispose]() {
783
+ // The socket owns the OS resources; streams are views.
784
+ }
785
+ }
786
+ class OutgoingDatagramStream02 {
787
+ #conn;
788
+ #streams;
789
+ #live;
790
+ #family;
791
+ /** An async send failure, surfaced on the NEXT call (module header). */
792
+ #failure;
793
+ constructor(conn, streams, live, family) {
794
+ this.#conn = conn;
795
+ this.#streams = streams;
796
+ this.#live = live;
797
+ this.#family = family;
798
+ }
799
+ checkSend() {
800
+ onCall("outgoing-datagram-stream.check-send");
801
+ this.#checkLive("check-send");
802
+ return CHECK_SEND_PERMIT;
803
+ }
804
+ /** Hands each datagram to node and counts it sent — the WIT's "sent
805
+ * (or queued for sending)" latitude (module header). */
806
+ send(datagrams) {
807
+ onCall("outgoing-datagram-stream.send");
808
+ this.#checkLive("send");
809
+ if (BigInt(datagrams.length) > CHECK_SEND_PERMIT) {
810
+ // "Implementations must trap if ... more items than check-send
811
+ // permitted": the unbranded throw IS the trap.
812
+ throw new Error(`outgoing-datagram-stream.send: ${datagrams.length} datagrams exceed the check-send permit`);
813
+ }
814
+ const connected = this.#streams.remote;
815
+ for (const datagram of datagrams) {
816
+ if (datagram.data.length > MAX_UDP_DATAGRAM_SIZE) {
817
+ throw err02("datagram-too-large", `outgoing-datagram-stream.send: ${datagram.data.length} bytes`);
818
+ }
819
+ const remote = datagram.remoteAddress;
820
+ if (connected !== undefined) {
821
+ if (remote !== undefined && !sameSocketAddress(remote, connected)) {
822
+ throw err02("invalid-argument", "outgoing-datagram-stream.send: a remote-address that does not match the connected remote");
823
+ }
824
+ }
825
+ else {
826
+ if (remote === undefined) {
827
+ throw err02("invalid-argument", "outgoing-datagram-stream.send: no remote-address on an unconnected stream");
828
+ }
829
+ validateRemote(this.#family, remote, "outgoing-datagram-stream.send");
830
+ }
831
+ }
832
+ for (const datagram of datagrams) {
833
+ const dest = connected !== undefined ? undefined : {
834
+ transport: "udp",
835
+ hostname: ipHostname(datagram.remoteAddress),
836
+ port: datagram.remoteAddress.value.port,
837
+ };
838
+ void this.#conn.send(datagram.data, dest).catch((e) => {
839
+ this.#failure ??= toCode02(e, "outgoing-datagram-stream.send");
840
+ });
841
+ }
842
+ return BigInt(datagrams.length);
843
+ }
844
+ subscribe() {
845
+ onCall("outgoing-datagram-stream.subscribe");
846
+ return new Pollable(); // the permit is always available
847
+ }
848
+ #checkLive(what) {
849
+ if (!this.#live(this.#streams.generation)) {
850
+ throw err02("invalid-state", `outgoing-datagram-stream.${what}: stale stream`);
851
+ }
852
+ if (this.#failure !== undefined) {
853
+ const code = this.#failure;
854
+ this.#failure = undefined;
855
+ throw err02(code, `outgoing-datagram-stream.${what}: an earlier send failed`);
856
+ }
857
+ }
858
+ [Symbol.dispose]() {
859
+ // The socket owns the OS resources; streams are views.
860
+ }
861
+ }
862
+ const createUdpSocket = (addressFamily) => {
863
+ onCall("udp-create-socket.create-udp-socket");
864
+ if (listenDatagram() === undefined) {
865
+ throw err02("not-supported", "create-udp-socket: no datagram backend (no node:dgram)");
866
+ }
867
+ return new UdpSocket02(addressFamily);
868
+ };
869
+ // --- ip-name-lookup ------------------------------------------------------------
870
+ class ResolveAddressStream02 {
871
+ #settled = false;
872
+ #answers = [];
873
+ #error;
874
+ #wait;
875
+ constructor(query) {
876
+ if (typeof query !== "string") {
877
+ // IP literals: already answered, no resolver involved.
878
+ this.#settled = true;
879
+ this.#answers = query.literal;
880
+ this.#wait = Promise.resolve();
881
+ return;
882
+ }
883
+ const name = query;
884
+ const lookup = dnsLookup();
885
+ if (lookup === undefined) {
886
+ this.#settled = true;
887
+ this.#error = "permanent-resolver-failure";
888
+ this.#wait = Promise.resolve();
889
+ return;
890
+ }
891
+ this.#wait = lookup(name).then((answers) => {
892
+ try {
893
+ this.#answers = answers.map((a) => {
894
+ const parsed = parseNetAddr({ hostname: a.address, port: 0 });
895
+ return parsed.kind === "ipv4"
896
+ ? { kind: "ipv4", value: parsed.value.address }
897
+ : { kind: "ipv6", value: parsed.value.address };
898
+ });
899
+ }
900
+ catch {
901
+ this.#error = "unknown";
902
+ }
903
+ this.#settled = true;
904
+ }, (e) => {
905
+ const code = e?.code;
906
+ this.#error = code === "ENOTFOUND" || code === "EAI_NONAME" || code === "ENODATA"
907
+ ? "name-unresolvable"
908
+ : code === "EAI_AGAIN" || code === "ETIMEOUT" || code === "ETIMEDOUT"
909
+ ? "temporary-resolver-failure"
910
+ : code === "EACCES" || code === "EPERM"
911
+ ? "access-denied"
912
+ : toCode02(e, "resolve-addresses") === "access-denied"
913
+ ? "access-denied"
914
+ : "unknown";
915
+ this.#settled = true;
916
+ });
917
+ }
918
+ /** An already-answered stream (IP literals). */
919
+ static literal(answers) {
920
+ return new ResolveAddressStream02({ literal: answers });
921
+ }
922
+ resolveNextAddress() {
923
+ onCall("resolve-address-stream.resolve-next-address");
924
+ if (!this.#settled) {
925
+ throw err02("would-block", "resolve-next-address: the resolver has not answered");
926
+ }
927
+ if (this.#error !== undefined) {
928
+ throw err02(this.#error, "resolve-next-address: resolution failed");
929
+ }
930
+ return this.#answers.shift(); // undefined = none = end of stream
931
+ }
932
+ subscribe() {
933
+ onCall("resolve-address-stream.subscribe");
934
+ return new Pollable(() => this.#settled, () => this.#wait);
935
+ }
936
+ [Symbol.dispose]() { }
937
+ }
938
+ const resolveAddresses = (_network, name) => {
939
+ onCall("ip-name-lookup.resolve-addresses");
940
+ if (name.length === 0) {
941
+ throw err02("invalid-argument", "resolve-addresses: empty name");
942
+ }
943
+ // IP literals answer locally (wasmtime parity; no resolver involved).
944
+ try {
945
+ const bare = name.startsWith("[") ? name.slice(1, -1) : name;
946
+ const parsed = parseNetAddr({ hostname: bare, port: 0 });
947
+ return ResolveAddressStream02.literal([
948
+ parsed.kind === "ipv4"
949
+ ? { kind: "ipv4", value: parsed.value.address }
950
+ : { kind: "ipv6", value: parsed.value.address },
951
+ ]);
952
+ }
953
+ catch {
954
+ // Not a literal: a real name for the resolver.
955
+ }
956
+ return new ResolveAddressStream02(name);
957
+ };
958
+ return {
959
+ imports: {
960
+ "wasi:sockets/network@0.2": {
961
+ Network,
962
+ // `network-error-code(err: borrow<error>) -> option<error-code>`:
963
+ // downcast succeeds exactly for the io errors OUR streams minted.
964
+ networkErrorCode: (err) => err instanceof SocketIoError ? err.code : undefined,
965
+ },
966
+ "wasi:sockets/instance-network@0.2": {
967
+ instanceNetwork: () => theNetwork,
968
+ },
969
+ "wasi:sockets/tcp@0.2": { TcpSocket: TcpSocket02 },
970
+ "wasi:sockets/tcp-create-socket@0.2": { createTcpSocket },
971
+ "wasi:sockets/udp@0.2": {
972
+ UdpSocket: UdpSocket02,
973
+ IncomingDatagramStream: IncomingDatagramStream02,
974
+ OutgoingDatagramStream: OutgoingDatagramStream02,
975
+ },
976
+ "wasi:sockets/udp-create-socket@0.2": { createUdpSocket },
977
+ "wasi:sockets/ip-name-lookup@0.2": {
978
+ resolveAddresses,
979
+ ResolveAddressStream: ResolveAddressStream02,
980
+ },
981
+ },
982
+ };
983
+ }
984
+ /** The family's wildcard address, port 0 (tcp listen's implicit bind). */
985
+ function wildcard(family) {
986
+ return family === "ipv4"
987
+ ? { kind: "ipv4", value: { port: 0, address: [0, 0, 0, 0] } }
988
+ : {
989
+ kind: "ipv6",
990
+ value: { port: 0, flowInfo: 0, address: [0, 0, 0, 0, 0, 0, 0, 0], scopeId: 0 },
991
+ };
992
+ }