@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,530 @@
1
+ // The platform seam under `sockets.ts`: the connection shapes the
2
+ // providers drive (`DatagramConn`, `TcpConn`, `TcpListener`), served by
3
+ // ONE backend — the node builtins (`node:dgram` / `node:net`), resolved
4
+ // through `process.getBuiltinModule` (synchronous, Node >= 20.16 / 22.3;
5
+ // no static `node:` imports, so the module graph stays bundler- and
6
+ // browser-safe).
7
+ //
8
+ // Why node builtins EVERYWHERE, including on Deno (a deliberate reversal
9
+ // of the earlier native-first split, with better reasoning):
10
+ //
11
+ // * Deno ships these builtins as STABLE node-compat surface. Its own
12
+ // `Deno.listenDatagram` sits behind `--unstable-net`, but that flag
13
+ // gates the native API's SHAPE stability, not the capability — so
14
+ // the old backend told stock-Deno consumers "UDP not-supported"
15
+ // while the platform's stable surface could serve it. One backend
16
+ // drops the `--unstable-net` requirement entirely.
17
+ // * One behavior on every runtime: one divergence table, one test
18
+ // matrix (the whole provider suite exercises this path under Deno's
19
+ // compat; `just test-sockets-node` re-runs it on genuine Node).
20
+ // * `net.connect` exposes `localAddress`/`localPort`, so
21
+ // connect-from-bound works — the native `Deno.connect` never could.
22
+ // * The engines where node builtins do not exist and JSPI is absent
23
+ // (JSC, and Bun atop it) cannot run polyengine guests anyway: JSC lacks
24
+ // multi-memory. Browsers have no sockets of any flavor.
25
+ //
26
+ // Costs, measured and accepted:
27
+ // * ~2x per-operation overhead on a UDP loopback ping-pong microbench
28
+ // vs the native API (~105k vs ~158k pkt/s round-trips under Deno) —
29
+ // far above what the QUIC consumers draw, and the CM boundary
30
+ // dominates real flows.
31
+ // * Permission denials arrive as genuine `Deno.errors.NotCapable`
32
+ // instances through the compat layer (verified) — the provider's
33
+ // error mapper keeps its Deno-class checks for exactly this.
34
+ //
35
+ // Node adapter notes (each verified empirically on pinned node 26.7.0,
36
+ // system node 24, and Deno's node-compat):
37
+ //
38
+ // * dgram bind is made SYNCHRONOUS by giving `createSocket` a custom
39
+ // `lookup` whose callback fires synchronously (addresses here are
40
+ // always numeric, so there is nothing to resolve). The WIT `bind` and
41
+ // `get-local-address` are sync funcs; with the default async lookup,
42
+ // `address()` right after `bind()` throws EBADF and bind errors only
43
+ // surface on a later tick. With the sync lookup, `address()` is valid
44
+ // on return and EADDRINUSE throws at the bind call site.
45
+ // * `net.Server.listen` has NO such escape hatch: with a specific host
46
+ // the OS bind is DEFERRED one event-loop turn (`'listening'` /
47
+ // `'error'` arrive later). The seam exposes that settle as
48
+ // `TcpListener.settled()`; the provider awaits it inside a
49
+ // `suspending`-marked `listen`, parking the calling guest frame for
50
+ // the one tick (embedder-api A1/A2 — the same kernel that serves
51
+ // wasi:io's sync `block`). Full listener fidelity follows: real
52
+ // ephemeral addresses, real bind error codes.
53
+ // * dgram receive is push-shaped (`'message'` events); the adapter
54
+ // bridges to the seam's pull shape with a BOUNDED queue
55
+ // (tail-drop past `MAX_QUEUED_DATAGRAMS` — kernel-buffer semantics:
56
+ // UDP is lossy by contract, and a guest that stops reading must not
57
+ // grow host memory without bound).
58
+ // * `net.connect` gets `allowHalfOpen: true` — Node's default auto-ends
59
+ // the write side on peer FIN, which would break the WIT's
60
+ // shared-ownership/half-close contract (the send stream must remain
61
+ // usable after the receive side ends).
62
+ // * TCP reads pull via `'readable'` + `read()`, handing node's own
63
+ // buffers through (zero extra copy); the excess past the caller's
64
+ // `max` is `unshift()`ed back.
65
+ //
66
+ // The adapters throw RAW platform errors (node `code`-carrying errors,
67
+ // and `Deno.errors.NotCapable` where the compat layer raises it); mapping
68
+ // onto the WIT `error-code` vocabulary stays with the providers
69
+ // (`sockets.ts` `mapPlatformError`), so this module has no imports at
70
+ // all. Everything is looked up through `globalThis` at call time — the
71
+ // module never assumes a platform at evaluation, and `create` can answer
72
+ // `not-supported` truthfully on hosts with no node builtins (browsers).
73
+ // --- detection ----------------------------------------------------------------
74
+ /** `process.getBuiltinModule(name)`, if this host has it (Node, Deno, Bun). */
75
+ function nodeBuiltin(name) {
76
+ const proc = globalThis.process;
77
+ const get = proc?.getBuiltinModule;
78
+ if (typeof get !== "function")
79
+ return undefined;
80
+ try {
81
+ return get.call(proc, name);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ /** The datagram backend, re-detected per call. */
88
+ export function listenDatagram() {
89
+ return nodeListenDatagram();
90
+ }
91
+ /** The TCP-connect backend, re-detected per call. */
92
+ export function tcpConnect() {
93
+ return nodeTcpConnect();
94
+ }
95
+ /** The TCP-listen backend, re-detected per call. */
96
+ export function tcpListen() {
97
+ return nodeTcpListen();
98
+ }
99
+ /** The name-resolution backend, re-detected per call. */
100
+ export function dnsLookup() {
101
+ const dns = nodeBuiltin("node:dns");
102
+ const promises = dns?.promises;
103
+ const lookup = promises?.lookup;
104
+ if (promises === undefined || lookup === undefined)
105
+ return undefined;
106
+ // verbatim: getaddrinfo order as-is (the WIT: "returned in the order
107
+ // the resolver prefers").
108
+ return (name) => lookup.call(promises, name, { all: true, verbatim: true });
109
+ }
110
+ // --- the node:dgram backend -----------------------------------------------------
111
+ /**
112
+ * Queued-but-unread datagrams past this bound are dropped (tail-drop, the
113
+ * kernel-buffer analogue). Node's `'message'` push keeps delivering
114
+ * whether or not the guest reads; unread datagrams must not accumulate
115
+ * without bound.
116
+ */
117
+ export const MAX_QUEUED_DATAGRAMS = 256;
118
+ function nodeListenDatagram() {
119
+ const dgram = nodeBuiltin("node:dgram");
120
+ const net = nodeBuiltin("node:net");
121
+ if (dgram === undefined || net === undefined)
122
+ return undefined;
123
+ return ({ hostname, port }) => {
124
+ const socket = dgram.createSocket({
125
+ type: hostname.includes(":") ? "udp6" : "udp4",
126
+ // The synchronous-lookup trick (module header): makes bind()
127
+ // complete synchronously for numeric addresses.
128
+ lookup: (addr, _options, cb) => cb(null, addr, net.isIP(addr)),
129
+ });
130
+ try {
131
+ socket.bind(port, hostname);
132
+ }
133
+ catch (e) {
134
+ try {
135
+ socket.close();
136
+ }
137
+ catch {
138
+ // Never came up.
139
+ }
140
+ throw e;
141
+ }
142
+ return new NodeDatagramConn(socket);
143
+ };
144
+ }
145
+ /** A plain Error carrying a Node-style `code` (for the provider's mapper). */
146
+ function codedError(code, message) {
147
+ return Object.assign(new Error(message), { code });
148
+ }
149
+ class NodeDatagramConn {
150
+ addr;
151
+ #socket;
152
+ #queue = [];
153
+ #waiters = [];
154
+ #failure;
155
+ #closed = false;
156
+ /** Promise-swap wake for the poll-shaped consumers (waitReceive). */
157
+ #wake = () => { };
158
+ #wakePromise;
159
+ constructor(socket) {
160
+ this.#socket = socket;
161
+ this.#wakePromise = new Promise((r) => (this.#wake = r));
162
+ const a = socket.address();
163
+ this.addr = { transport: "udp", hostname: a.address, port: a.port };
164
+ socket.on("message", (...args) => {
165
+ const msg = args[0];
166
+ const rinfo = args[1];
167
+ const from = {
168
+ transport: "udp",
169
+ hostname: rinfo.address,
170
+ port: rinfo.port,
171
+ };
172
+ const waiter = this.#waiters.shift();
173
+ if (waiter !== undefined) {
174
+ waiter.resolve([msg, from]);
175
+ return;
176
+ }
177
+ if (this.#closed || this.#queue.length >= MAX_QUEUED_DATAGRAMS)
178
+ return; // tail-drop
179
+ this.#queue.push([msg, from]);
180
+ this.#signal();
181
+ });
182
+ socket.on("error", (...args) => {
183
+ this.#failure = args[0];
184
+ this.#failWaiters(args[0]);
185
+ this.#signal();
186
+ });
187
+ }
188
+ #signal() {
189
+ const wake = this.#wake;
190
+ this.#wakePromise = new Promise((r) => (this.#wake = r));
191
+ wake();
192
+ }
193
+ #failWaiters(e) {
194
+ const waiters = this.#waiters;
195
+ this.#waiters = [];
196
+ for (const w of waiters)
197
+ w.reject(e);
198
+ }
199
+ send(p, addr) {
200
+ return new Promise((resolve, reject) => {
201
+ if (this.#closed) {
202
+ reject(codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket is closed"));
203
+ return;
204
+ }
205
+ const cb = (err) => err !== null && err !== undefined ? reject(err) : resolve(p.length);
206
+ if (addr === undefined)
207
+ this.#socket.send(p, cb); // connected mode
208
+ else
209
+ this.#socket.send(p, addr.port, addr.hostname, cb);
210
+ });
211
+ }
212
+ connect(addr) {
213
+ return new Promise((resolve, reject) => {
214
+ if (this.#closed) {
215
+ reject(codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket is closed"));
216
+ return;
217
+ }
218
+ this.#socket.connect(addr.port, addr.hostname, (err) => {
219
+ if (err !== null && err !== undefined)
220
+ reject(err);
221
+ else
222
+ resolve();
223
+ });
224
+ });
225
+ }
226
+ disconnect() {
227
+ this.#socket.disconnect();
228
+ }
229
+ setTtl(ttl) {
230
+ this.#socket.setTTL(ttl);
231
+ }
232
+ getRecvBufferSize() {
233
+ return this.#socket.getRecvBufferSize();
234
+ }
235
+ setRecvBufferSize(size) {
236
+ this.#socket.setRecvBufferSize(size);
237
+ }
238
+ getSendBufferSize() {
239
+ return this.#socket.getSendBufferSize();
240
+ }
241
+ setSendBufferSize(size) {
242
+ this.#socket.setSendBufferSize(size);
243
+ }
244
+ receive() {
245
+ // Node hands each datagram as its own exactly-sized buffer, passed
246
+ // through directly.
247
+ if (this.#failure !== undefined)
248
+ return Promise.reject(this.#failure);
249
+ if (this.#closed) {
250
+ return Promise.reject(codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket is closed"));
251
+ }
252
+ const queued = this.#queue.shift();
253
+ if (queued !== undefined)
254
+ return Promise.resolve(queued);
255
+ return new Promise((resolve, reject) => {
256
+ this.#waiters.push({ resolve, reject });
257
+ });
258
+ }
259
+ tryReceive() {
260
+ if (this.#failure !== undefined)
261
+ throw this.#failure;
262
+ if (this.#closed) {
263
+ throw codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket is closed");
264
+ }
265
+ return this.#queue.shift();
266
+ }
267
+ receiveReady() {
268
+ return this.#queue.length > 0 || this.#failure !== undefined || this.#closed;
269
+ }
270
+ waitReceive() {
271
+ return this.#wakePromise;
272
+ }
273
+ close() {
274
+ if (this.#closed)
275
+ return;
276
+ this.#closed = true;
277
+ // A parked receive settles as an error mapping onto `invalid-state`.
278
+ this.#failWaiters(codedError("ERR_SOCKET_DGRAM_NOT_RUNNING", "socket closed under a pending receive"));
279
+ this.#signal();
280
+ try {
281
+ this.#socket.close();
282
+ }
283
+ catch {
284
+ // Already closed.
285
+ }
286
+ }
287
+ }
288
+ function nodeTcpConnect() {
289
+ const net = nodeBuiltin("node:net");
290
+ if (net === undefined)
291
+ return undefined;
292
+ return async ({ hostname, port, localHostname, localPort }) => {
293
+ // allowHalfOpen is load-bearing (module header): the WIT half-close
294
+ // contract needs the write side to survive the peer's FIN.
295
+ const socket = net.connect({
296
+ host: hostname,
297
+ port,
298
+ allowHalfOpen: true,
299
+ ...(localHostname === undefined ? {} : {
300
+ localAddress: localHostname,
301
+ localPort: localPort ?? 0,
302
+ }),
303
+ });
304
+ await new Promise((resolve, reject) => {
305
+ const onError = (...args) => reject(args[0]);
306
+ socket.once("error", onError);
307
+ socket.once("connect", () => {
308
+ socket.off("error", onError);
309
+ resolve();
310
+ });
311
+ });
312
+ return new NodeTcpConn(socket);
313
+ };
314
+ }
315
+ class NodeTcpConn {
316
+ localAddr;
317
+ remoteAddr;
318
+ #socket;
319
+ #failure;
320
+ #ended = false;
321
+ constructor(socket) {
322
+ this.#socket = socket;
323
+ this.localAddr = {
324
+ transport: "tcp",
325
+ hostname: socket.localAddress ?? "",
326
+ port: socket.localPort ?? 0,
327
+ };
328
+ this.remoteAddr = {
329
+ transport: "tcp",
330
+ hostname: socket.remoteAddress ?? "",
331
+ port: socket.remotePort ?? 0,
332
+ };
333
+ // A persistent listener: an unhandled 'error' event would throw
334
+ // process-wide. In-flight reads observe #failure; writes get the
335
+ // error through their own callbacks.
336
+ socket.on("error", (...args) => {
337
+ this.#failure = args[0];
338
+ });
339
+ socket.on("end", () => {
340
+ this.#ended = true;
341
+ });
342
+ }
343
+ async read(max) {
344
+ for (;;) {
345
+ if (this.#failure !== undefined)
346
+ throw this.#failure;
347
+ const chunk = this.#socket.read();
348
+ if (chunk !== null) {
349
+ if (chunk.length > max) {
350
+ this.#socket.unshift(chunk.subarray(max));
351
+ return chunk.subarray(0, max);
352
+ }
353
+ return chunk;
354
+ }
355
+ if (this.#ended || this.#socket.readableEnded)
356
+ return null; // peer FIN
357
+ if (this.#socket.destroyed) {
358
+ // Locally destroyed under a pending read: never a fake EOS — maps
359
+ // onto invalid-state.
360
+ throw codedError("ERR_STREAM_DESTROYED", "socket closed under a pending read");
361
+ }
362
+ await new Promise((resolve) => {
363
+ const done = () => {
364
+ this.#socket.off("readable", done);
365
+ this.#socket.off("end", done);
366
+ this.#socket.off("error", done);
367
+ this.#socket.off("close", done);
368
+ resolve();
369
+ };
370
+ this.#socket.once("readable", done);
371
+ this.#socket.once("end", done);
372
+ this.#socket.once("error", done);
373
+ this.#socket.once("close", done);
374
+ });
375
+ }
376
+ }
377
+ write(p) {
378
+ return new Promise((resolve, reject) => {
379
+ try {
380
+ // The callback fires once the chunk is handed to the kernel —
381
+ // awaiting it per-write is the backpressure.
382
+ this.#socket.write(p, (err) => {
383
+ if (err !== null && err !== undefined)
384
+ reject(err);
385
+ else
386
+ resolve(p.length);
387
+ });
388
+ }
389
+ catch (e) {
390
+ // write() itself throws after end()/destroy() (ERR_STREAM_*).
391
+ reject(e);
392
+ }
393
+ });
394
+ }
395
+ closeWrite() {
396
+ return new Promise((resolve) => {
397
+ this.#socket.end(resolve);
398
+ });
399
+ }
400
+ setKeepAlive(enabled, idleMs) {
401
+ this.#socket.setKeepAlive(enabled, idleMs);
402
+ }
403
+ close() {
404
+ this.#socket.destroy();
405
+ }
406
+ }
407
+ // --- the node:net listener --------------------------------------------------------
408
+ /**
409
+ * Accepted-but-unread connections past this bound are REFUSED (destroyed)
410
+ * — node's `'connection'` push keeps accepting whether or not the guest
411
+ * reads the accept stream, and unlike datagrams an accepted connection is
412
+ * a live socket, so tail-drop here means an active refusal rather than a
413
+ * silent discard (the polymorph-iroh#56 stance).
414
+ */
415
+ export const MAX_QUEUED_CONNECTIONS = 64;
416
+ function nodeTcpListen() {
417
+ const net = nodeBuiltin("node:net");
418
+ if (net === undefined)
419
+ return undefined;
420
+ return ({ hostname, port, backlog }) => {
421
+ const queue = [];
422
+ const waiters = [];
423
+ let failure;
424
+ let closed = false;
425
+ // Promise-swap wake for the poll-shaped consumers (waitAccept).
426
+ let wake = () => { };
427
+ let wakePromise = new Promise((r) => (wake = r));
428
+ const signal = () => {
429
+ const w = wake;
430
+ wakePromise = new Promise((r) => (wake = r));
431
+ w();
432
+ };
433
+ const failWaiters = (e) => {
434
+ const w = waiters.splice(0, waiters.length);
435
+ for (const waiter of w)
436
+ waiter.reject(e);
437
+ };
438
+ const server = net.createServer({ allowHalfOpen: true }, (socket) => {
439
+ const waiter = waiters.shift();
440
+ if (waiter !== undefined) {
441
+ waiter.resolve(new NodeTcpConn(socket));
442
+ return;
443
+ }
444
+ if (closed || queue.length >= MAX_QUEUED_CONNECTIONS) {
445
+ socket.destroy(); // refuse: nobody is going to take it
446
+ return;
447
+ }
448
+ queue.push(socket);
449
+ signal();
450
+ });
451
+ // The deferred OS bind (module header): 'listening' or 'error' arrive
452
+ // one tick after listen(). `settled` is created eagerly (with a no-op
453
+ // catch so an unobserved rejection cannot escape) and the provider
454
+ // awaits it inside its suspending `listen`.
455
+ let settle;
456
+ let fail;
457
+ const settledOnce = new Promise((resolve, reject) => {
458
+ settle = resolve;
459
+ fail = reject;
460
+ });
461
+ settledOnce.catch(() => {
462
+ // Observed via settled(); this guard only prevents an unhandled
463
+ // rejection if the provider never gets the chance.
464
+ });
465
+ server.on("listening", () => settle());
466
+ server.on("error", (...args) => {
467
+ failure = args[0];
468
+ fail(args[0]);
469
+ failWaiters(args[0]);
470
+ signal();
471
+ });
472
+ server.listen({ port, host: hostname, ...(backlog === undefined ? {} : { backlog }) });
473
+ return {
474
+ get addr() {
475
+ const a = server.address();
476
+ return a === null
477
+ ? null
478
+ : { transport: "tcp", hostname: a.address, port: a.port };
479
+ },
480
+ settled() {
481
+ return settledOnce;
482
+ },
483
+ accept() {
484
+ if (failure !== undefined)
485
+ return Promise.reject(failure);
486
+ if (closed) {
487
+ return Promise.reject(codedError("ERR_SERVER_NOT_RUNNING", "listener is closed"));
488
+ }
489
+ const queued = queue.shift();
490
+ if (queued !== undefined) {
491
+ return Promise.resolve(new NodeTcpConn(queued));
492
+ }
493
+ return new Promise((resolve, reject) => {
494
+ waiters.push({ resolve, reject });
495
+ });
496
+ },
497
+ tryAccept() {
498
+ if (failure !== undefined)
499
+ throw failure;
500
+ if (closed) {
501
+ throw codedError("ERR_SERVER_NOT_RUNNING", "listener is closed");
502
+ }
503
+ const queued = queue.shift();
504
+ return queued === undefined ? undefined : new NodeTcpConn(queued);
505
+ },
506
+ acceptReady() {
507
+ return queue.length > 0 || failure !== undefined || closed;
508
+ },
509
+ waitAccept() {
510
+ return wakePromise;
511
+ },
512
+ close() {
513
+ if (closed)
514
+ return;
515
+ closed = true;
516
+ signal();
517
+ failWaiters(codedError("ERR_SERVER_NOT_RUNNING", "listener closed under a pending accept"));
518
+ for (const socket of queue.splice(0, queue.length)) {
519
+ socket.destroy(); // refuse queued-but-untaken connections
520
+ }
521
+ try {
522
+ server.close();
523
+ }
524
+ catch {
525
+ // Never listening.
526
+ }
527
+ },
528
+ };
529
+ };
530
+ }