@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,969 @@
1
+ // INTERNAL: the `@0.3` track of `wasi:sockets` — `types@0.3` (UDP + TCP,
2
+ // client + listener) and `ip-name-lookup@0.3`. Registered by the public
3
+ // `sockets()` (sockets.ts, where the module-level documentation lives)
4
+ // alongside the poll-shaped `@0.2` track (sockets_02.ts). Vocabulary
5
+ // (codec, validation, error mapping, WIT types): sockets_shared.ts.
6
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
7
+ var useValue = arguments.length > 2;
8
+ for (var i = 0; i < initializers.length; i++) {
9
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
10
+ }
11
+ return useValue ? value : void 0;
12
+ };
13
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
14
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
15
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
16
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
17
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
18
+ var _, done = false;
19
+ for (var i = decorators.length - 1; i >= 0; i--) {
20
+ var context = {};
21
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
22
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
23
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
24
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
25
+ if (kind === "accessor") {
26
+ if (result === void 0) continue;
27
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
28
+ if (_ = accept(result.get)) descriptor.get = _;
29
+ if (_ = accept(result.set)) descriptor.set = _;
30
+ if (_ = accept(result.init)) initializers.unshift(_);
31
+ }
32
+ else if (_ = accept(result)) {
33
+ if (kind === "field") initializers.unshift(_);
34
+ else descriptor[key] = _;
35
+ }
36
+ }
37
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
38
+ done = true;
39
+ };
40
+ import { ComponentException, Stream, suspending } from "@polyengine/runtime/embedder";
41
+ import { dnsLookup, listenDatagram, tcpConnect, tcpListen, } from "./sockets_platform.js";
42
+ import { componentError, ipHostname, isDenoError, isUnspecified, isValidAddressFamily, mapPlatformError, MAX_UDP_DATAGRAM_SIZE, parseNetAddr, RESULT_INVALID_STATE, RESULT_OK, resultErrOf, sameSocketAddress, wildcardAddress, } from "./sockets_shared.js";
43
+ /**
44
+ * Build the `@0.3` track: `wasi:sockets/types@0.3` (UDP + TCP resource
45
+ * classes, per-fragment so the `onCall` observer is scoped) and
46
+ * `wasi:sockets/ip-name-lookup@0.3`.
47
+ */
48
+ export function sockets03(onCall) {
49
+ let UdpSocket = (() => {
50
+ let _instanceExtraInitializers = [];
51
+ let _connect_decorators;
52
+ return class UdpSocket {
53
+ static {
54
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
55
+ __esDecorate(this, null, _connect_decorators, { kind: "method", name: "connect", static: false, private: false, access: { has: obj => "connect" in obj, get: obj => obj.connect }, metadata: _metadata }, null, _instanceExtraInitializers);
56
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
57
+ }
58
+ #family = __runInitializers(this, _instanceExtraInitializers);
59
+ #conn;
60
+ /** Connected-mode remote (`connect`/`disconnect`); OS-level (kernel
61
+ * filters and default destination), not adapter emulation. */
62
+ #remote;
63
+ /** Cached option values, applied at (implicit) bind. */
64
+ #hopLimit;
65
+ #recvBuffer;
66
+ #sendBuffer;
67
+ constructor(family) {
68
+ this.#family = family;
69
+ }
70
+ static create(addressFamily) {
71
+ onCall("udp-socket.create");
72
+ if (listenDatagram() === undefined) {
73
+ throw componentError({ kind: "not-supported" }, "udp-socket.create: this host provides no datagram sockets (no node:dgram)");
74
+ }
75
+ return new UdpSocket(addressFamily);
76
+ }
77
+ bind(localAddress) {
78
+ onCall("udp-socket.bind");
79
+ if (this.#conn !== undefined) {
80
+ throw componentError({ kind: "invalid-state" }, "udp-socket.bind: already bound");
81
+ }
82
+ if (!isValidAddressFamily(this.#family, localAddress)) {
83
+ throw componentError({ kind: "invalid-argument" }, `udp-socket.bind: address family mismatch (an ${this.#family} socket)`);
84
+ }
85
+ if (localAddress.kind === "ipv6" && localAddress.value.scopeId !== 0) {
86
+ throw componentError({ kind: "not-supported" }, "udp-socket.bind: non-zero scope-id (not expressible through Deno.listenDatagram)");
87
+ }
88
+ try {
89
+ this.#conn = this.#listen({
90
+ transport: "udp",
91
+ hostname: ipHostname(localAddress),
92
+ port: localAddress.value.port,
93
+ });
94
+ }
95
+ catch (e) {
96
+ throw mapPlatformError(e, "udp-socket.bind");
97
+ }
98
+ this.#applyCachedOptions();
99
+ }
100
+ /**
101
+ * WIT (0.3.1): `connect: func(remote-address) -> result<_, error-code>`
102
+ * — OS-level connected mode: the kernel filters inbound datagrams to
103
+ * the remote and `send` needs no explicit address. An unbound socket
104
+ * implicitly binds to the family wildcard first (wasmtime parity).
105
+ *
106
+ * SUSPENDING (A1/A2): node's `dgram.connect` settles via callback one
107
+ * tick later, so this sync WIT func parks the calling frame for that
108
+ * tick — the same shape as tcp `listen`.
109
+ */
110
+ async connect(remoteAddress) {
111
+ onCall("udp-socket.connect");
112
+ if (this.#remote !== undefined) {
113
+ throw componentError({ kind: "invalid-state" }, "udp-socket.connect: already connected (disconnect first)");
114
+ }
115
+ if (!isValidAddressFamily(this.#family, remoteAddress) ||
116
+ isUnspecified(remoteAddress) ||
117
+ remoteAddress.value.port === 0) {
118
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.connect: the remote address must be a specific address " +
119
+ `and non-zero port in the socket's family (${this.#family})`);
120
+ }
121
+ if (remoteAddress.kind === "ipv6" && remoteAddress.value.scopeId !== 0) {
122
+ throw componentError({ kind: "not-supported" }, "udp-socket.connect: non-zero scope-id (not expressible through node addresses)");
123
+ }
124
+ if (this.#conn === undefined) {
125
+ try {
126
+ this.#conn = this.#listen({
127
+ transport: "udp",
128
+ hostname: this.#family === "ipv4" ? "0.0.0.0" : "::",
129
+ port: 0,
130
+ });
131
+ }
132
+ catch (e) {
133
+ throw mapPlatformError(e, "udp-socket.connect (implicit bind)");
134
+ }
135
+ this.#applyCachedOptions();
136
+ }
137
+ if (this.#conn.connect === undefined) {
138
+ throw componentError({ kind: "not-supported" }, "udp-socket.connect: this host's datagram backend has no connected mode");
139
+ }
140
+ try {
141
+ await this.#conn.connect({
142
+ transport: "udp",
143
+ hostname: ipHostname(remoteAddress),
144
+ port: remoteAddress.value.port,
145
+ });
146
+ }
147
+ catch (e) {
148
+ throw mapPlatformError(e, "udp-socket.connect");
149
+ }
150
+ this.#remote = remoteAddress;
151
+ }
152
+ disconnect() {
153
+ onCall("udp-socket.disconnect");
154
+ if (this.#remote === undefined || this.#conn === undefined) {
155
+ throw componentError({ kind: "invalid-state" }, "udp-socket.disconnect: the socket is not connected");
156
+ }
157
+ try {
158
+ this.#conn.disconnect?.();
159
+ }
160
+ catch (e) {
161
+ throw mapPlatformError(e, "udp-socket.disconnect");
162
+ }
163
+ this.#remote = undefined;
164
+ }
165
+ async send(data, remoteAddress) {
166
+ onCall("udp-socket.send");
167
+ if (data.length > MAX_UDP_DATAGRAM_SIZE) {
168
+ throw componentError({ kind: "datagram-too-large" }, `udp-socket.send: ${data.length} bytes exceeds the ${MAX_UDP_DATAGRAM_SIZE}-byte ceiling`);
169
+ }
170
+ // Connected mode (0.3.1): an omitted remote sends to the connected
171
+ // address (the kernel's default destination); a PRESENT remote on a
172
+ // connected socket is invalid-argument (wasmtime parity — node's
173
+ // dgram would raise ERR_SOCKET_DGRAM_IS_CONNECTED anyway). On an
174
+ // unconnected socket an omitted remote has no destination (POSIX
175
+ // EDESTADDRREQ).
176
+ if (remoteAddress === undefined) {
177
+ if (this.#remote === undefined) {
178
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.send: no remote-address, and the socket is not connected");
179
+ }
180
+ if (this.#conn === undefined) {
181
+ throw componentError({ kind: "invalid-state" }, "udp-socket.send: connected but unbound (unreachable)");
182
+ }
183
+ try {
184
+ const sent = await this.#conn.send(data);
185
+ if (sent !== data.length) {
186
+ throw componentError({ kind: "other", value: `partial send: ${sent} of ${data.length} bytes` }, `udp-socket.send: partial send: ${sent} of ${data.length} bytes`);
187
+ }
188
+ }
189
+ catch (e) {
190
+ throw mapPlatformError(e, "udp-socket.send");
191
+ }
192
+ return;
193
+ }
194
+ if (this.#remote !== undefined) {
195
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.send: an explicit remote-address on a connected socket");
196
+ }
197
+ if (this.#conn === undefined) {
198
+ // "If the socket has not been explicitly bound, it will be implicitly
199
+ // bound to a random free port" — the wildcard bind wasmtime performs.
200
+ try {
201
+ this.#conn = this.#listen({
202
+ transport: "udp",
203
+ hostname: this.#family === "ipv4" ? "0.0.0.0" : "::",
204
+ port: 0,
205
+ });
206
+ }
207
+ catch (e) {
208
+ throw mapPlatformError(e, "udp-socket.send (implicit bind)");
209
+ }
210
+ this.#applyCachedOptions();
211
+ }
212
+ if (!isValidAddressFamily(this.#family, remoteAddress) ||
213
+ isUnspecified(remoteAddress) ||
214
+ remoteAddress.value.port === 0) {
215
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.send: the remote address must be a specific address and " +
216
+ `non-zero port in the socket's family (${this.#family})`);
217
+ }
218
+ if (remoteAddress.kind === "ipv6" && remoteAddress.value.scopeId !== 0) {
219
+ throw componentError({ kind: "not-supported" }, "udp-socket.send: non-zero scope-id (not expressible through Deno addresses)");
220
+ }
221
+ let sent;
222
+ try {
223
+ sent = await this.#conn.send(data, {
224
+ transport: "udp",
225
+ hostname: ipHostname(remoteAddress),
226
+ port: remoteAddress.value.port,
227
+ });
228
+ }
229
+ catch (e) {
230
+ throw mapPlatformError(e, "udp-socket.send");
231
+ }
232
+ if (sent !== data.length) {
233
+ throw componentError({ kind: "other", value: `partial send: ${sent} of ${data.length} bytes` }, `udp-socket.send: partial send: ${sent} of ${data.length} bytes`);
234
+ }
235
+ }
236
+ async receive() {
237
+ onCall("udp-socket.receive");
238
+ if (this.#conn === undefined) {
239
+ throw componentError({ kind: "invalid-state" }, "udp-socket.receive: the socket is not bound");
240
+ }
241
+ try {
242
+ for (;;) {
243
+ // Each datagram arrives as its own exactly-sized buffer; nothing
244
+ // the OS delivers is ever truncated (whole-datagram semantics).
245
+ const [payload, from] = await this.#conn.receive();
246
+ const source = parseNetAddr(from);
247
+ // The connected-mode filter, as a BACKSTOP over the OS's: the
248
+ // WIT pins "only receive datagrams from that address", the
249
+ // kernel filters on real node, but Deno's dgram compat treats
250
+ // connect() as a default destination only — so non-matching
251
+ // sources are dropped here either way (matching what a kernel
252
+ // filter would have done silently).
253
+ if (this.#remote === undefined || sameSocketAddress(source, this.#remote)) {
254
+ return [payload, source];
255
+ }
256
+ }
257
+ }
258
+ catch (e) {
259
+ throw mapPlatformError(e, "udp-socket.receive");
260
+ }
261
+ }
262
+ getLocalAddress() {
263
+ onCall("udp-socket.get-local-address");
264
+ if (this.#conn === undefined) {
265
+ throw componentError({ kind: "invalid-state" }, "udp-socket.get-local-address: the socket is not bound");
266
+ }
267
+ return parseNetAddr(this.#conn.addr);
268
+ }
269
+ getRemoteAddress() {
270
+ onCall("udp-socket.get-remote-address");
271
+ if (this.#remote === undefined) {
272
+ throw componentError({ kind: "invalid-state" }, "udp-socket.get-remote-address: the socket is not connected");
273
+ }
274
+ return this.#remote;
275
+ }
276
+ getAddressFamily() {
277
+ onCall("udp-socket.get-address-family");
278
+ return this.#family;
279
+ }
280
+ /** Stored-value getter (documented default 64, the common OS default):
281
+ * node exposes a setter (`setTTL`) but no getter. */
282
+ getUnicastHopLimit() {
283
+ onCall("udp-socket.get-unicast-hop-limit");
284
+ return this.#hopLimit ?? 64;
285
+ }
286
+ setUnicastHopLimit(value) {
287
+ onCall("udp-socket.set-unicast-hop-limit");
288
+ if (value < 1) {
289
+ // The WIT pins this: "set-unicast-hop-limit(0)" must fail.
290
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.set-unicast-hop-limit: the hop limit must be at least 1");
291
+ }
292
+ this.#hopLimit = value;
293
+ if (this.#conn !== undefined)
294
+ this.#applyCachedOptions();
295
+ }
296
+ getReceiveBufferSize() {
297
+ onCall("udp-socket.get-receive-buffer-size");
298
+ return this.#bufferSize("receive", this.#recvBuffer, this.#conn?.getRecvBufferSize);
299
+ }
300
+ setReceiveBufferSize(value) {
301
+ onCall("udp-socket.set-receive-buffer-size");
302
+ if (value === 0n) {
303
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.set-receive-buffer-size: zero is not a buffer size");
304
+ }
305
+ this.#recvBuffer = value;
306
+ if (this.#conn !== undefined)
307
+ this.#applyCachedOptions();
308
+ }
309
+ getSendBufferSize() {
310
+ onCall("udp-socket.get-send-buffer-size");
311
+ return this.#bufferSize("send", this.#sendBuffer, this.#conn?.getSendBufferSize);
312
+ }
313
+ setSendBufferSize(value) {
314
+ onCall("udp-socket.set-send-buffer-size");
315
+ if (value === 0n) {
316
+ throw componentError({ kind: "invalid-argument" }, "udp-socket.set-send-buffer-size: zero is not a buffer size");
317
+ }
318
+ this.#sendBuffer = value;
319
+ if (this.#conn !== undefined)
320
+ this.#applyCachedOptions();
321
+ }
322
+ /** Live kernel value when bound (SO_RCVBUF doubling and clamping
323
+ * included), the cached request before that, `not-supported` when
324
+ * neither exists (the OS default is unknowable pre-bind here). */
325
+ #bufferSize(which, cached, live) {
326
+ if (this.#conn !== undefined && live !== undefined) {
327
+ try {
328
+ return BigInt(live.call(this.#conn));
329
+ }
330
+ catch (e) {
331
+ throw mapPlatformError(e, `udp-socket.get-${which}-buffer-size`);
332
+ }
333
+ }
334
+ if (cached !== undefined)
335
+ return cached;
336
+ throw componentError({ kind: "not-supported" }, `udp-socket.get-${which}-buffer-size: unknowable before bind on this host`);
337
+ }
338
+ /** Cached options -> the live socket (at bind, and on later sets). */
339
+ #applyCachedOptions() {
340
+ const conn = this.#conn;
341
+ if (conn === undefined)
342
+ return;
343
+ try {
344
+ if (this.#hopLimit !== undefined)
345
+ conn.setTtl?.(this.#hopLimit);
346
+ if (this.#recvBuffer !== undefined) {
347
+ conn.setRecvBufferSize?.(Number(this.#recvBuffer));
348
+ }
349
+ if (this.#sendBuffer !== undefined) {
350
+ conn.setSendBufferSize?.(Number(this.#sendBuffer));
351
+ }
352
+ }
353
+ catch (e) {
354
+ throw mapPlatformError(e, "udp-socket (applying cached options)");
355
+ }
356
+ }
357
+ [(_connect_decorators = [suspending], Symbol.dispose)]() {
358
+ const conn = this.#conn;
359
+ this.#conn = undefined;
360
+ if (conn !== undefined) {
361
+ try {
362
+ conn.close();
363
+ }
364
+ catch {
365
+ // Already closed.
366
+ }
367
+ }
368
+ }
369
+ /** Re-detect per call: `create`'s answer must not outlive a test's stub. */
370
+ #listen(opts) {
371
+ const listen = listenDatagram();
372
+ if (listen === undefined) {
373
+ throw componentError({ kind: "not-supported" }, "udp-socket: the datagram backend disappeared after create");
374
+ }
375
+ return listen(opts);
376
+ }
377
+ };
378
+ })();
379
+ let TcpSocket = (() => {
380
+ let _instanceExtraInitializers = [];
381
+ let _listen_decorators;
382
+ return class TcpSocket {
383
+ static {
384
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
385
+ __esDecorate(this, null, _listen_decorators, { kind: "method", name: "listen", static: false, private: false, access: { has: obj => "listen" in obj, get: obj => obj.listen }, metadata: _metadata }, null, _instanceExtraInitializers);
386
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
387
+ }
388
+ #family = __runInitializers(this, _instanceExtraInitializers);
389
+ #state = "unbound";
390
+ #conn;
391
+ #listener;
392
+ /** The address `bind` recorded; the OS bind happens at `listen` (header). */
393
+ #localRequest;
394
+ #sendCalled = false;
395
+ #receiveCalled = false;
396
+ /** listen()'s accept-queue hint (`set-listen-backlog-size`). */
397
+ #backlog;
398
+ /** SO_KEEPALIVE + TCP_KEEPIDLE cache (node's exact option surface);
399
+ * applied at connect and on set-while-connected. The idle default is
400
+ * Linux's tcp_keepalive_time (7200 s) — DOCUMENTED, not read from
401
+ * the OS (node exposes no getter). */
402
+ #keepAliveEnabled = false;
403
+ #keepAliveIdleNs = 7200000000000n;
404
+ /**
405
+ * Shared-ownership references (WIT: "The OS socket is closed only
406
+ * after the last handle is dropped"): the resource handle plus each
407
+ * live pump and the accept stream. The conn/listener close at zero —
408
+ * so a live send, receive, or accept stream keeps the socket open
409
+ * past the guest dropping the handle.
410
+ */
411
+ #refs = 1;
412
+ #handleDropped = false;
413
+ constructor(family) {
414
+ this.#family = family;
415
+ }
416
+ /** An accepted connection, already in the `connected` state. */
417
+ static #accepted(family, conn) {
418
+ const socket = new TcpSocket(family);
419
+ socket.#conn = conn;
420
+ socket.#state = "connected";
421
+ return socket;
422
+ }
423
+ static create(addressFamily) {
424
+ onCall("tcp-socket.create");
425
+ if (tcpConnect() === undefined) {
426
+ throw componentError({ kind: "not-supported" }, "tcp-socket.create: this host provides no TCP sockets (no node:net)");
427
+ }
428
+ return new TcpSocket(addressFamily);
429
+ }
430
+ /**
431
+ * Records the local address; the OS bind is DEFERRED to `listen` or
432
+ * `connect` (recorded divergence: node cannot bind a socket it has
433
+ * not yet connected or listened — so `address-in-use` and friends
434
+ * surface at those calls, with their real codes).
435
+ */
436
+ bind(localAddress) {
437
+ onCall("tcp-socket.bind");
438
+ if (this.#state !== "unbound") {
439
+ throw componentError({ kind: "invalid-state" }, `tcp-socket.bind: not bindable from the '${this.#state}' state`);
440
+ }
441
+ if (!isValidAddressFamily(this.#family, localAddress)) {
442
+ throw componentError({ kind: "invalid-argument" }, `tcp-socket.bind: address family mismatch (an ${this.#family} socket)`);
443
+ }
444
+ if (localAddress.kind === "ipv6" && localAddress.value.scopeId !== 0) {
445
+ throw componentError({ kind: "not-supported" }, "tcp-socket.bind: non-zero scope-id (not expressible through node addresses)");
446
+ }
447
+ this.#localRequest = localAddress;
448
+ this.#state = "bound";
449
+ }
450
+ async connect(remoteAddress) {
451
+ onCall("tcp-socket.connect");
452
+ if (this.#state !== "unbound" && this.#state !== "bound") {
453
+ // Includes `closed` after a failed attempt: "A single socket can
454
+ // not be used to connect more than once."
455
+ throw componentError({ kind: "invalid-state" }, `tcp-socket.connect: not connectable from the '${this.#state}' state`);
456
+ }
457
+ if (!isValidAddressFamily(this.#family, remoteAddress) ||
458
+ isUnspecified(remoteAddress) ||
459
+ remoteAddress.value.port === 0) {
460
+ throw componentError({ kind: "invalid-argument" }, "tcp-socket.connect: the remote address must be a specific unicast " +
461
+ `address and non-zero port in the socket's family (${this.#family})`);
462
+ }
463
+ if (remoteAddress.kind === "ipv6" && remoteAddress.value.scopeId !== 0) {
464
+ throw componentError({ kind: "not-supported" }, "tcp-socket.connect: non-zero scope-id (not expressible through node addresses)");
465
+ }
466
+ const connect = tcpConnect();
467
+ if (connect === undefined) {
468
+ throw componentError({ kind: "not-supported" }, "tcp-socket: the TCP backend disappeared after create");
469
+ }
470
+ // Connect-from-bound: `bind` recorded the local address; the OS
471
+ // bind happens here, as part of the dial (`net.connect`'s
472
+ // localAddress/localPort) — so bind errors (address-in-use,
473
+ // address-not-bindable) surface at connect, the deferred-bind
474
+ // divergence the module header records.
475
+ const local = this.#localRequest;
476
+ this.#state = "connecting";
477
+ let conn;
478
+ try {
479
+ conn = await connect({
480
+ transport: "tcp",
481
+ hostname: ipHostname(remoteAddress),
482
+ port: remoteAddress.value.port,
483
+ ...(local === undefined ? {} : {
484
+ localHostname: ipHostname(local),
485
+ localPort: local.value.port,
486
+ }),
487
+ });
488
+ }
489
+ catch (e) {
490
+ // "After a failed connection attempt, the socket will be in the
491
+ // `closed` state and the only valid action left is to `drop`".
492
+ this.#state = "closed";
493
+ throw mapPlatformError(e, "tcp-socket.connect");
494
+ }
495
+ if (this.#state !== "connecting") {
496
+ // Disposed while the dial was in flight: nothing owns the fresh
497
+ // conn — close it rather than leak it.
498
+ try {
499
+ conn.close();
500
+ }
501
+ catch {
502
+ // Already closed.
503
+ }
504
+ throw componentError({ kind: "invalid-state" }, "tcp-socket.connect: the socket was dropped during connect");
505
+ }
506
+ this.#conn = conn;
507
+ this.#state = "connected";
508
+ this.#applyKeepAlive(); // options set before connect reach the OS here
509
+ }
510
+ /**
511
+ * WIT: `listen: func() -> result<stream<tcp-socket>, error-code>` —
512
+ * transitions to `listening` and returns the perpetual accept stream,
513
+ * whose elements are connected `TcpSocket` resources (lowered as
514
+ * `own<tcp-socket>` — amendment A13 destroys any element the guest
515
+ * never takes, closing that accepted connection). An unbound socket
516
+ * implicitly binds to the family wildcard with an ephemeral port.
517
+ *
518
+ * SUSPENDING (embedder-api A1/A2, the wasi:io `block` kernel): the OS
519
+ * bind is deferred one event-loop turn by `net.Server.listen` (module
520
+ * header), so this async method awaits the settle and the runtime
521
+ * parks the calling guest frame for that one tick. Full listener
522
+ * fidelity follows: `get-local-address` is real immediately after,
523
+ * and a failed bind is a branded err with its real code
524
+ * (address-in-use, …). Guests that link `listen` auto-select jspi
525
+ * mode on JSPI engines; engines without JSPI would raise `NeedsJspi`
526
+ * here — currently moot everywhere polyengine guests run (JSC lacks
527
+ * multi-memory, and browsers have no sockets).
528
+ *
529
+ * The stream only ends on fatal errors — the listener dying — while
530
+ * per-connection accept failures are skipped, per the WIT's
531
+ * implementors note.
532
+ */
533
+ async listen() {
534
+ onCall("tcp-socket.listen");
535
+ if (this.#state !== "unbound" && this.#state !== "bound") {
536
+ throw componentError({ kind: "invalid-state" }, `tcp-socket.listen: not listenable from the '${this.#state}' state`);
537
+ }
538
+ const listen = tcpListen();
539
+ if (listen === undefined) {
540
+ throw componentError({ kind: "not-supported" }, "tcp-socket.listen: this host provides no TCP listeners (no node:net)");
541
+ }
542
+ const local = this.#localRequest ?? wildcardAddress(this.#family);
543
+ const listener = listen({
544
+ transport: "tcp",
545
+ hostname: ipHostname(local),
546
+ port: local.value.port,
547
+ ...(this.#backlog === undefined ? {} : { backlog: this.#backlog }),
548
+ });
549
+ try {
550
+ await listener.settled(); // the one-tick park (doc comment above)
551
+ }
552
+ catch (e) {
553
+ listener.close();
554
+ this.#state = "closed";
555
+ throw mapPlatformError(e, "tcp-socket.listen");
556
+ }
557
+ this.#listener = listener;
558
+ this.#state = "listening";
559
+ this.#refs++; // the accept stream keeps the listener alive
560
+ const family = this.#family;
561
+ const release = () => this.#release();
562
+ const source = (async function* () {
563
+ try {
564
+ for (;;) {
565
+ let conn;
566
+ try {
567
+ conn = await listener.accept();
568
+ }
569
+ catch (e) {
570
+ const kind = mapPlatformError(e, "tcp-socket.listen (accept)")
571
+ .payload.kind;
572
+ // Per-connection failures are skipped (the WIT implementors
573
+ // note: "log it and then skip over non-fatal errors");
574
+ // anything else means the LISTENER is dead — closed under
575
+ // us, or never came up — and ends the perpetual stream.
576
+ if (TRANSIENT_ACCEPT_FAILURES.has(kind))
577
+ continue;
578
+ return;
579
+ }
580
+ yield TcpSocket.#accepted(family, conn);
581
+ }
582
+ }
583
+ finally {
584
+ release();
585
+ }
586
+ })();
587
+ // The A13 producer-cancellation hook: when the guest drops the
588
+ // stream while the loop above is PARKED in accept(), the runtime's
589
+ // pump invokes this — closing the listener is what unparks the
590
+ // accept (it rejects; classified fatal; the generator retires).
591
+ return Object.assign(source, {
592
+ cancel: () => {
593
+ try {
594
+ listener.close();
595
+ }
596
+ catch {
597
+ // Already closed.
598
+ }
599
+ },
600
+ });
601
+ }
602
+ /**
603
+ * WIT: `send: func(data: stream<u8>) -> future<result<_, error-code>>`
604
+ * — a sync func; the returned promise is the future source (A12).
605
+ * NEVER throws: the function has no error channel of its own, so every
606
+ * failure — including the state-machine ones — resolves the future as
607
+ * an err value. The argument stream is dropped on failure so its
608
+ * guest-side writer settles instead of parking forever.
609
+ */
610
+ send(data) {
611
+ onCall("tcp-socket.send");
612
+ if (this.#state !== "connected" || this.#sendCalled || this.#conn === undefined) {
613
+ dropSendSource(data);
614
+ return Promise.resolve(RESULT_INVALID_STATE);
615
+ }
616
+ this.#sendCalled = true;
617
+ return this.#sendPump(this.#conn, data);
618
+ }
619
+ async #sendPump(conn, data) {
620
+ this.#refs++;
621
+ try {
622
+ // Guest-side iteration failures (a peer trap while reading the
623
+ // lifted stream) are deliberately NOT caught: they are not socket
624
+ // errors, and the rejection rides the producer-failure channel.
625
+ for await (const chunk of data) {
626
+ const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk);
627
+ let at = 0;
628
+ while (at < bytes.length) {
629
+ let n;
630
+ try {
631
+ n = await conn.write(bytes.subarray(at));
632
+ }
633
+ catch (e) {
634
+ dropSendSource(data);
635
+ return resultErrOf(e, "tcp-socket.send");
636
+ }
637
+ at += n;
638
+ }
639
+ }
640
+ // End of the guest's stream ("the caller should close the stream
641
+ // when it has no more data"): shutdown(SHUT_WR) — the FIN. The
642
+ // future resolves ok only once the full contents are transmitted.
643
+ try {
644
+ await conn.closeWrite();
645
+ }
646
+ catch (e) {
647
+ return resultErrOf(e, "tcp-socket.send");
648
+ }
649
+ return RESULT_OK;
650
+ }
651
+ finally {
652
+ this.#release();
653
+ }
654
+ }
655
+ /**
656
+ * WIT: `receive: func() -> tuple<stream<u8>, future<result<_,
657
+ * error-code>>>`. NEVER throws; a not-connected or repeat call returns
658
+ * a closed stream and an already-err future, per the WIT. The stream
659
+ * ends cleanly (never fake data) on BOTH graceful FIN and abnormal
660
+ * close — the future distinguishes them (`ok` vs `err`). Dropping the
661
+ * stream's reader (guest SHUT_RD) stops the pump, discards queued
662
+ * data, and settles the future ok — the canceller is the observer
663
+ * (the same logic as embedder-api A8's cancelRead ruling).
664
+ */
665
+ receive() {
666
+ onCall("tcp-socket.receive");
667
+ if (this.#state !== "connected" || this.#receiveCalled || this.#conn === undefined) {
668
+ return [[], Promise.resolve(RESULT_INVALID_STATE)];
669
+ }
670
+ this.#receiveCalled = true;
671
+ const conn = this.#conn;
672
+ this.#refs++;
673
+ let settle;
674
+ const done = new Promise((r) => (settle = r));
675
+ const release = () => this.#release();
676
+ const source = (async function* () {
677
+ try {
678
+ for (;;) {
679
+ // The chunk is node's own buffer (no copy); it is borrowed by
680
+ // the rendezvous until the peer takes it (A5), which is safe —
681
+ // each read hands back a distinct buffer.
682
+ let chunk;
683
+ try {
684
+ chunk = await conn.read(TCP_RECEIVE_CHUNK);
685
+ }
686
+ catch (e) {
687
+ settle(resultErrOf(e, "tcp-socket.receive"));
688
+ return;
689
+ }
690
+ if (chunk === null) {
691
+ settle(RESULT_OK); // graceful FIN from the peer
692
+ return;
693
+ }
694
+ if (chunk.length > 0)
695
+ yield chunk;
696
+ }
697
+ }
698
+ finally {
699
+ settle(RESULT_OK); // no-op if already settled (resolve is once)
700
+ release();
701
+ }
702
+ })();
703
+ return [source, done];
704
+ }
705
+ getLocalAddress() {
706
+ onCall("tcp-socket.get-local-address");
707
+ if (this.#state === "listening" && this.#listener !== undefined) {
708
+ const addr = this.#listener.addr;
709
+ if (addr !== null)
710
+ return parseNetAddr(addr);
711
+ // Unreachable in practice: `listen` awaited the settle, after
712
+ // which the listener reports its address. Kept as an honest err
713
+ // rather than a non-null assertion.
714
+ throw componentError({ kind: "invalid-state" }, "tcp-socket.get-local-address: the listener reported no address");
715
+ }
716
+ if (this.#conn === undefined) {
717
+ throw componentError({ kind: "invalid-state" }, "tcp-socket.get-local-address: the socket is not bound");
718
+ }
719
+ return parseNetAddr(this.#conn.localAddr);
720
+ }
721
+ getRemoteAddress() {
722
+ onCall("tcp-socket.get-remote-address");
723
+ if (this.#state !== "connected" || this.#conn === undefined) {
724
+ throw componentError({ kind: "invalid-state" }, "tcp-socket.get-remote-address: the socket is not connected");
725
+ }
726
+ return parseNetAddr(this.#conn.remoteAddr);
727
+ }
728
+ getAddressFamily() {
729
+ onCall("tcp-socket.get-address-family");
730
+ return this.#family;
731
+ }
732
+ getIsListening() {
733
+ onCall("tcp-socket.get-is-listening");
734
+ return this.#state === "listening";
735
+ }
736
+ /** Stored pre-listen and applied as node's `listen` backlog hint;
737
+ * node cannot re-listen, so changing it on a LISTENING socket is
738
+ * `not-supported` (wasmtime re-listens; recorded divergence). */
739
+ setListenBacklogSize(value) {
740
+ onCall("tcp-socket.set-listen-backlog-size");
741
+ if (value === 0n) {
742
+ throw componentError({ kind: "invalid-argument" }, "tcp-socket.set-listen-backlog-size: zero is not a backlog");
743
+ }
744
+ if (this.#state === "listening") {
745
+ throw componentError({ kind: "not-supported" }, "tcp-socket.set-listen-backlog-size: node cannot re-listen an active listener");
746
+ }
747
+ if (this.#state !== "unbound" && this.#state !== "bound") {
748
+ throw componentError({ kind: "invalid-state" }, `tcp-socket.set-listen-backlog-size: not settable in the '${this.#state}' state`);
749
+ }
750
+ // Clamp to a safe int; the OS clamps to SOMAXCONN anyway.
751
+ this.#backlog = Number(value > 0x7fffffffn ? 0x7fffffffn : value);
752
+ }
753
+ getKeepAliveEnabled() {
754
+ onCall("tcp-socket.get-keep-alive-enabled");
755
+ return this.#keepAliveEnabled;
756
+ }
757
+ setKeepAliveEnabled(value) {
758
+ onCall("tcp-socket.set-keep-alive-enabled");
759
+ this.#keepAliveEnabled = value;
760
+ this.#applyKeepAlive();
761
+ }
762
+ /** Stored-value getter (field doc: the default is documented, not
763
+ * read — node has no getter). */
764
+ getKeepAliveIdleTime() {
765
+ onCall("tcp-socket.get-keep-alive-idle-time");
766
+ return this.#keepAliveIdleNs;
767
+ }
768
+ setKeepAliveIdleTime(value) {
769
+ onCall("tcp-socket.set-keep-alive-idle-time");
770
+ if (value < 1n) {
771
+ throw componentError({ kind: "invalid-argument" }, "tcp-socket.set-keep-alive-idle-time: the idle time must be at least 1 ns");
772
+ }
773
+ this.#keepAliveIdleNs = value;
774
+ this.#applyKeepAlive();
775
+ }
776
+ // TCP_KEEPINTVL / TCP_KEEPCNT / IP_TTL / SO_RCVBUF / SO_SNDBUF have no
777
+ // node:net surface at all — answered honestly, not emulated.
778
+ getKeepAliveInterval() {
779
+ onCall("tcp-socket.get-keep-alive-interval");
780
+ throw this.#noOption("keep-alive-interval (TCP_KEEPINTVL)");
781
+ }
782
+ setKeepAliveInterval(_value) {
783
+ onCall("tcp-socket.set-keep-alive-interval");
784
+ throw this.#noOption("keep-alive-interval (TCP_KEEPINTVL)");
785
+ }
786
+ getKeepAliveCount() {
787
+ onCall("tcp-socket.get-keep-alive-count");
788
+ throw this.#noOption("keep-alive-count (TCP_KEEPCNT)");
789
+ }
790
+ setKeepAliveCount(_value) {
791
+ onCall("tcp-socket.set-keep-alive-count");
792
+ throw this.#noOption("keep-alive-count (TCP_KEEPCNT)");
793
+ }
794
+ getHopLimit() {
795
+ onCall("tcp-socket.get-hop-limit");
796
+ throw this.#noOption("hop-limit (IP_TTL)");
797
+ }
798
+ setHopLimit(_value) {
799
+ onCall("tcp-socket.set-hop-limit");
800
+ throw this.#noOption("hop-limit (IP_TTL)");
801
+ }
802
+ getReceiveBufferSize() {
803
+ onCall("tcp-socket.get-receive-buffer-size");
804
+ throw this.#noOption("receive-buffer-size (SO_RCVBUF)");
805
+ }
806
+ setReceiveBufferSize(_value) {
807
+ onCall("tcp-socket.set-receive-buffer-size");
808
+ throw this.#noOption("receive-buffer-size (SO_RCVBUF)");
809
+ }
810
+ getSendBufferSize() {
811
+ onCall("tcp-socket.get-send-buffer-size");
812
+ throw this.#noOption("send-buffer-size (SO_SNDBUF)");
813
+ }
814
+ setSendBufferSize(_value) {
815
+ onCall("tcp-socket.set-send-buffer-size");
816
+ throw this.#noOption("send-buffer-size (SO_SNDBUF)");
817
+ }
818
+ #noOption(what) {
819
+ return componentError({ kind: "not-supported" }, `tcp-socket: node:net exposes no ${what}`);
820
+ }
821
+ /** The keep-alive cache -> the live socket, when there is one. */
822
+ #applyKeepAlive() {
823
+ const conn = this.#conn;
824
+ if (this.#state !== "connected" || conn === undefined)
825
+ return;
826
+ if (conn.setKeepAlive === undefined) {
827
+ throw componentError({ kind: "not-supported" }, "tcp-socket: this host's TCP backend has no keep-alive control");
828
+ }
829
+ try {
830
+ conn.setKeepAlive(this.#keepAliveEnabled, Number(this.#keepAliveIdleNs / 1000000n));
831
+ }
832
+ catch (e) {
833
+ throw mapPlatformError(e, "tcp-socket (applying keep-alive)");
834
+ }
835
+ }
836
+ [(_listen_decorators = [suspending], Symbol.dispose)]() {
837
+ if (this.#handleDropped)
838
+ return;
839
+ this.#handleDropped = true;
840
+ if (this.#state === "unbound" || this.#state === "bound" ||
841
+ this.#state === "connecting") {
842
+ // An in-flight dial observes this and closes its fresh conn.
843
+ this.#state = "closed";
844
+ }
845
+ this.#release();
846
+ }
847
+ #release() {
848
+ this.#refs--;
849
+ if (this.#refs === 0) {
850
+ const conn = this.#conn;
851
+ this.#conn = undefined;
852
+ if (conn !== undefined) {
853
+ try {
854
+ conn.close();
855
+ }
856
+ catch {
857
+ // Already closed.
858
+ }
859
+ }
860
+ const listener = this.#listener;
861
+ this.#listener = undefined;
862
+ if (listener !== undefined) {
863
+ try {
864
+ listener.close();
865
+ }
866
+ catch {
867
+ // Already closed (e.g. by the accept stream's cancel hook).
868
+ }
869
+ }
870
+ }
871
+ }
872
+ };
873
+ })();
874
+ /**
875
+ * `wasi:sockets/ip-name-lookup@0.3`: `resolve-addresses: async
876
+ * func(name) -> result<list<ip-address>, error-code>` — getaddrinfo
877
+ * over the platform seam (node:dns `lookup`, i.e. the system resolver,
878
+ * not raw DNS). IP literals resolve locally without touching the
879
+ * resolver (wasmtime parity); answers keep the resolver's order.
880
+ */
881
+ const resolveAddresses = async (name) => {
882
+ onCall("ip-name-lookup.resolve-addresses");
883
+ const nameErr = (payload, detail) => new ComponentException(payload, `wasi:sockets/ip-name-lookup@0.3: ${detail}`);
884
+ const toIpAddress = (hostname) => {
885
+ const parsed = parseNetAddr({ hostname, port: 0 });
886
+ return parsed.kind === "ipv4"
887
+ ? { kind: "ipv4", value: parsed.value.address }
888
+ : { kind: "ipv6", value: parsed.value.address };
889
+ };
890
+ if (name.length === 0) {
891
+ throw nameErr({ kind: "invalid-argument" }, "resolve-addresses: empty name");
892
+ }
893
+ // An IP literal is already an answer (and `lookup` would hand it back
894
+ // unchanged anyway — skip the resolver round-trip).
895
+ try {
896
+ return [toIpAddress(name.startsWith("[") ? name.slice(1, -1) : name)];
897
+ }
898
+ catch {
899
+ // Not a literal: a real name for the resolver.
900
+ }
901
+ const lookup = dnsLookup();
902
+ if (lookup === undefined) {
903
+ throw nameErr({ kind: "permanent-resolver-failure" }, "resolve-addresses: this host provides no resolver (no node:dns)");
904
+ }
905
+ let answers;
906
+ try {
907
+ answers = await lookup(name);
908
+ }
909
+ catch (e) {
910
+ const code = e?.code;
911
+ const message = e instanceof Error ? e.message : String(e);
912
+ if (code === "ENOTFOUND" || code === "EAI_NONAME" || code === "ENODATA") {
913
+ throw nameErr({ kind: "name-unresolvable" }, `resolve-addresses: ${message}`);
914
+ }
915
+ if (code === "EAI_AGAIN" || code === "ETIMEOUT" || code === "ETIMEDOUT") {
916
+ throw nameErr({ kind: "temporary-resolver-failure" }, `resolve-addresses: ${message}`);
917
+ }
918
+ if (isDenoError(e, "NotCapable") || isDenoError(e, "PermissionDenied") ||
919
+ code === "EACCES" || code === "EPERM") {
920
+ throw nameErr({ kind: "access-denied" }, `resolve-addresses: ${message}`);
921
+ }
922
+ if (e instanceof TypeError) {
923
+ throw nameErr({ kind: "invalid-argument" }, `resolve-addresses: ${message}`);
924
+ }
925
+ throw nameErr({ kind: "other", value: message }, `resolve-addresses: ${message}`);
926
+ }
927
+ try {
928
+ return answers.map((a) => toIpAddress(a.address));
929
+ }
930
+ catch (e) {
931
+ const message = e instanceof Error ? e.message : String(e);
932
+ throw nameErr({ kind: "other", value: message }, `resolve-addresses: ${message}`);
933
+ }
934
+ };
935
+ return {
936
+ imports: {
937
+ "wasi:sockets/types@0.3": { UdpSocket, TcpSocket },
938
+ "wasi:sockets/ip-name-lookup@0.3": { resolveAddresses },
939
+ },
940
+ UdpSocket,
941
+ TcpSocket,
942
+ resolveAddresses,
943
+ };
944
+ }
945
+ /** How many bytes one tcp receive read asks the OS for. */
946
+ const TCP_RECEIVE_CHUNK = 16384;
947
+ /**
948
+ * Accept failures that are per-connection, not per-listener: the WIT
949
+ * implementors note says to skip them ("Guest code never gets to see
950
+ * these failures"); everything else ends the perpetual stream.
951
+ */
952
+ const TRANSIENT_ACCEPT_FAILURES = new Set([
953
+ "connection-aborted",
954
+ "connection-reset",
955
+ "connection-refused",
956
+ "connection-broken",
957
+ "remote-unreachable",
958
+ "timeout",
959
+ ]);
960
+ /**
961
+ * Abandon tcp send's input when the operation fails: a lifted `Stream`
962
+ * handle is dropped so the guest's writer settles ("reader went away")
963
+ * instead of parking forever; other producer shapes are cleaned up by the
964
+ * iteration protocol itself (`for await`'s abrupt-exit `return()`).
965
+ */
966
+ function dropSendSource(data) {
967
+ if (data instanceof Stream)
968
+ data.drop();
969
+ }