Skip to content

wasi-shims: Node backend for the à la carte sockets fragment - #124

Merged
lannbot merged 1 commit into
mainfrom
sockets-node-backend
Aug 13, 2026
Merged

lannbot merged 1 commit into
mainfrom
sockets-node-backend

Conversation

@lannbot

@lannbot lannbot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The decision

@deltic/wasi-shims/sockets now serves real Node (and Bun via its node compat, findings-only) alongside Deno. The provider classes were already platform-agnostic — they drive two structural connection shapes — so the change is a platform seam module (sockets_platform.ts: seam types + both backends + per-call detection) and two adapters over node:dgram / node:net, resolved through process.getBuiltinModule (synchronous; no static node: imports, so the graph stays bundler- and browser-safe).

Detection: Deno-native APIs whenever a Deno global exists — unchanged contract, and deliberately not routing around Deno's --unstable-net capability gate via its own node-compat — node builtins otherwise.

The empirical keystones (all verified on pinned node 26.7.0, system node 24, and Deno's node-compat before committing to the design)

  • Synchronous dgram bind via a custom lookup whose callback fires synchronously: address() valid on return, EADDRINUSE thrown at the call site. This is what makes the sync WIT bind + get-local-address funcs servable at all — the iroh exam's create/bind/get-local-address driving order runs inside one guest activation with no event-loop turn. (Default async lookup: address() right after bind() throws EBADF.)
  • allowHalfOpen: true on net.connect — Node's default auto-ends the write side on peer FIN, which would break the WIT shared-ownership/half-close contract.
  • Bounded push→pull bridge for dgram receive (tail-drop past 256 queued datagrams — kernel-buffer semantics; same no-unbounded-growth stance as the call-log decision in wasi-shims: à la carte wasi:sockets UDP over Deno.listenDatagram #122). Zero-length datagrams (the iroh pump self-wake) pass through.
  • err.code table in mapPlatformError (after Deno's classes, before the plain-message spellings); the adapters' synthetic closed-under-a-pending-op errors map to invalid-state, mirroring BadResource.

tcp bind/listen stay absent on both backends for provider parity (node could express them; a consumer linking them reopens #4).

Tests: two lanes

  • Fake-node in-suite (10 tests): detection forced to the node adapters via an @internal seam, run under Deno's node-compat. Hiding the Deno global does NOT work — the compat layer's own internals reference it (udp_wrap throws ReferenceError: Deno is not defined), which is the seam's documented reason for existing.
  • Pinned-Node smoke (just test-sockets-node, new gate in gates + gha::core): the same load-bearing semantics on genuine node:dgram/node:net, plus the real no-Deno-global detection path. deno bundle resolves workspace imports into one self-contained ESM file; runs on the tools/shell pinned node.

Why not a polyfill

@deno/shim-deno was read and rejected: its connect swallows socket errors into console.error (a refused dial never rejects — an infinite hang where we need connection-refused), no allowHalfOpen, reads through the raw fd behind the Socket's back, and no UDP at all. The 10-member structural seam is smaller than the corrective wrapper would have been. (Consumers who want to inject a Deno-namespace polyfill can still do so — detection reads globalThis — at their own fidelity risk.)

Gates

just test-wasi-shims (98), just test-sockets-node (new), publish-check, test-bundle; runtime/ports/conformance untouched by this diff (wasi-shims + orchestration files only).

Refs #4.

…ode:net)

The provider classes were already platform-agnostic — they drive two
structural connection shapes (DatagramConn, TcpConn). Those seams move to
sockets_platform.ts, which now carries two backends and per-call
detection: Deno-native APIs whenever a Deno global exists (unchanged
contract — Deno.listenDatagram still wants --unstable-net, and we do NOT
route around Deno's own capability gate via its node-compat layer), node
builtins otherwise (process.getBuiltinModule — synchronous, and no static
node: imports, so the module graph stays bundler- and browser-safe; real
Node, and Bun through its compat, findings-only as everywhere).

Node adapter facts, each verified empirically on pinned node 26.7.0,
system node 24, and Deno's node-compat before the design committed:

- dgram bind is made SYNCHRONOUS by a custom lookup whose callback fires
  synchronously (addresses here are always numeric): address() is valid
  on return and EADDRINUSE throws at the bind call site — exactly what
  the sync WIT bind + get-local-address funcs need (the iroh exam's
  create/bind/get-local-address driving order happens inside one guest
  activation, no event-loop turn between the calls). With the default
  async lookup, address() right after bind() throws EBADF.
- dgram receive is push-shaped; the adapter bridges to the seam's pull
  shape with a BOUNDED queue (tail-drop past MAX_QUEUED_DATAGRAMS=256 —
  kernel-buffer semantics; a guest that stops reading must not grow host
  memory without bound). Zero-length datagrams (the iroh pump's
  self-wake) pass through; EMSGSIZE arrives as a coded error.
- net.connect gets allowHalfOpen: true — Node's default auto-ends the
  write side on peer FIN, which would break the WIT shared-ownership /
  half-close contract. Reads pull via 'readable' + read()/unshift (one
  copy into the caller's buffer; Deno keeps its zero-extra-copy path);
  a persistent 'error' listener keeps socket errors off the process and
  routes them to in-flight operations.
- error mapping: mapPlatformError gains a Node err.code table (a sharper
  channel than Deno's classes), tried after the Deno class checks and
  before the plain-message spellings; the adapters' synthetic
  closed-under-a-pending-op errors (ERR_SOCKET_DGRAM_NOT_RUNNING,
  ERR_STREAM_DESTROYED) map to invalid-state, mirroring BadResource.

tcp bind/listen stay absent on BOTH backends for provider parity, even
though node:net could express them (localAddress binding, createServer);
a consumer linking them reopens that on #4.

Tests, two lanes:
- fake-node in-suite (tests/sockets_node_test.ts, 10 tests): detection
  forced to the node adapters via an @internal seam
  (forceNodeBackendForTests) and run under Deno's node-compat. Hiding
  the Deno global instead does NOT work: the compat layer's own
  internals reference the global (udp_wrap throws 'Deno is not
  defined'), which is why the seam exists.
- pinned-Node smoke (tests/node_smoke.ts; just test-sockets-node, new
  gate wired into gates + gha::core): the same load-bearing semantics on
  genuine node:dgram/node:net plus the real no-Deno-global detection
  path. deno bundle resolves the workspace imports into one ESM file;
  runs on the tools/shell pinned node (26.7.0). Also verified by hand on
  system node 24.18.

Not shimmed: @deno/shim-deno was evaluated and rejected — its connect
swallows socket errors into console.error (a refused dial never
rejects), createConnection lacks allowHalfOpen, and reads go through the
raw fd behind the Socket's back; UDP is absent entirely. The structural
seam is smaller than a corrective wrapper would have been.

Gates: test-wasi-shims (98), test-sockets-node (new), publish-check,
test-bundle.
@lannbot
lannbot enabled auto-merge August 13, 2026 03:49
@lannbot
lannbot merged commit 3d4906a into main Aug 13, 2026
7 of 8 checks passed
@lannbot
lannbot deleted the sockets-node-backend branch August 23, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants