Skip to content

Add -sNODERAWSOCKETS backend for real TCP & UDP on Node.js#27080

Open
guybedford wants to merge 3 commits into
emscripten-core:mainfrom
guybedford:nodenet
Open

Add -sNODERAWSOCKETS backend for real TCP & UDP on Node.js#27080
guybedford wants to merge 3 commits into
emscripten-core:mainfrom
guybedford:nodenet

Conversation

@guybedford

@guybedford guybedford commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

This adds a new -sNODERAWSOCKETS setting that for supporting direct full sockets on Node.js via the node:net for TCP and node:dgram for UDP modules, without needing ws, an external proxy process, or pthreads.

  • Full support for both outgoing and incoming TCP, using the node:net APIs.
  • Full support for UDP using the node:dgram APIs
  • Support for threading, with tests
  • Support for IPV6

To support these embeddings without JSPI being mandatory requires using process.binding('tcp_wrap') and process.binding('udp_wrap') in Node.js, which are used here to support older versions of Node.js.

Further, to avoid having to rely on private APIs in modern Node.js I actually implemented upstream Node.js PRs to make public API surface area available for the full embedding in the following:

Then this PR conditionally checks these features and uses the public APIs as defined by them when it is able to, falling back to tcp_wrap and udp_wrap only when not supported. While Node.js with these features has not yet been released, as soon as all three have landed, we can rely on this surface area for modern 26+ versions of Node.js compat.

Note: AI was used to create this PR, under my review.

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I've not yet reviewed the meat of libsockfs.js but looks good so far.

Comment thread src/settings.js Outdated
Comment thread src/settings.js Outdated
Comment thread tools/system_libs.py Outdated
Comment thread test/test_other.py Outdated
Comment thread test/test_other.py Outdated
Comment thread test/sockets/test_nodenet.c Outdated
Comment thread test/sockets/test_nodenet.c Outdated
Comment thread test/sockets/test_nodenet.c Outdated
Comment thread test/sockets/test_tcp_echo.c
Comment thread src/lib/libsockfs.js Outdated
@guybedford guybedford changed the title Add -sNODENET backend for real outgoing TCP via node:net Add -sNODERAWSOCKETS backend for real TCP & UDP on Node.js Jun 10, 2026

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to review the details of libsockfs_node.js but the general shape here LGTM!

@guybedford guybedford force-pushed the nodenet branch 2 times, most recently from 97ce010 to 43d6cd4 Compare June 10, 2026 22:14
Comment thread system/lib/libc/emscripten_syscall_stubs.c Outdated

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on all the test cases. I can't say I've read all the test code yet though..

Can you confirm they all run on linux and/or macOS nativly too? (at least the ones where it makes sense that they could).

Comment thread src/lib/libsockfs_node.js Outdated
Comment thread src/lib/libsockfs_node.js Outdated
Comment thread src/lib/libsockfs_node.js Outdated
Comment thread src/lib/libsockfs_node.js Outdated
Comment thread src/lib/libsockfs_node.js
Comment thread test/sockets/test_tcp_backpressure.c Outdated
@guybedford guybedford force-pushed the nodenet branch 8 times, most recently from 17092cd to 035cd77 Compare June 15, 2026 22:30
@guybedford

This comment was marked as outdated.

@guybedford guybedford force-pushed the nodenet branch 4 times, most recently from d179b41 to 5f24cd5 Compare June 17, 2026 19:29
@guybedford

Copy link
Copy Markdown
Collaborator Author

@sbc100 this is ready for final review to land. The tests are comprehensive, and I've also tested it in real world end-to-end Rust applications. Any outstanding issues should be fairly straightforward guarded followups under this feature. The last topic that does also come up here is DNS, but I plan to make a follow-on PR for this instead.

Adds a new NODERAWSOCKETS setting that backs the POSIX sockets API directly
with Node.js's node:net and node:dgram, giving real, non-blocking TCP and UDP
sockets without WebSockets, an external proxy process, or pthreads. This is the
sockets counterpart to NODERAWFS: where NODERAWFS gives direct access to the
host filesystem, this gives direct access to host sockets.

Unlike PROXY_POSIX_SOCKETS this is single-threaded and event-driven: socket
readiness is delivered through the same emscripten_set_socket_*_callback hooks
the default WebSocket backend uses, so it drops into existing readiness reactors
unchanged. Under -pthread the socket syscalls are proxied to the main thread, so
the backend always runs on node's event loop and a SharedArrayBuffer heap is
safe.

Supported:

* TCP clients: connect, send, recv, shutdown and close, with non-blocking
  semantics and backpressure (send reports EAGAIN rather than buffering
  unboundedly).
* TCP servers: bind, listen, accept, getsockname/getpeername.
* UDP: bind, connect, sendto/recvfrom, with connected-peer filtering.
* IPv4 and IPv6 (AF_INET6): TCP and UDP over v6, including IPV6_V6ONLY.
* get/setsockopt: SO_ERROR, SO_KEEPALIVE and TCP_KEEPIDLE, TCP_NODELAY,
  SO_RCVBUF/SO_SNDBUF, SO_BROADCAST, IP_TTL, SO_REUSEPORT and IPV6_V6ONLY.
  Options are mirrored to a cache (the getsockopt source of truth) and projected
  onto the live socket; we only report options we can actually honor (e.g.
  SO_REUSEADDR reads back as 1 since libuv forces it on, and IPV6_V6ONLY returns
  EINVAL if changed after bind).

Binding is eager and synchronous, so a conflict surfaces as EADDRINUSE at bind()
and getsockname() reports the kernel-assigned ephemeral port immediately - there
is no deferred-bind or lazy-handle promotion. A bound socket is a role-neutral
handle, adopted as-is by listen() (server.listen) or connect() (net.Socket), and
released by close() only if it was never adopted. Bind-time options (ipv6Only,
reusePort) are passed to the handle at construction. The bind primitive is
selected once per capability:

* the public, synchronous net.BoundHandle (and dgram bindSync/connectSync) when
  the Node.js runtime provides them; and
* the private tcp_wrap/udp_wrap bindings as a fallback on Node.js versions that
  do not (bind6/send6 for IPv6).

Details:

* new node backend in src/lib/libsockfs_node.js, pulled in only under
  -sNODERAWSOCKETS, implementing the sock_ops contract
* __syscall_setsockopt and __syscall_shutdown now live in JS, routing to the
  backend under NODERAWSOCKETS (else reporting the option/feature as
  unsupported), avoiding a libstubs variation
* tests under test/sockets exercise TCP echo, server accept/echo (including
  listen-without-bind autobind), client source-port bind plus synchronous
  EADDRINUSE, client semantics (EISCONN, half-close, EPIPE), backpressure,
  connection refused, UDP echo/connect, and IPv6 TCP/UDP over ::1 (including
  IPV6_V6ONLY before/after bind); all build and run natively against the host
  stack and run under node, including PROXY_TO_PTHREAD variants
Adds a new NODERAWSOCKETS setting that backs the POSIX sockets API directly
with Node.js's node:net and node:dgram, giving real, non-blocking TCP and UDP
sockets without WebSockets, an external proxy process, or pthreads. This is the
sockets counterpart to NODERAWFS: where NODERAWFS gives direct access to the
host filesystem, this gives direct access to host sockets.

Unlike PROXY_POSIX_SOCKETS this is single-threaded and event-driven: socket
readiness is delivered through the same emscripten_set_socket_*_callback hooks
the default WebSocket backend uses, so it drops into existing readiness reactors
unchanged. Under -pthread the socket syscalls are proxied to the main thread, so
the backend always runs on node's event loop and a SharedArrayBuffer heap is
safe.

Supported:

* TCP clients: connect, send, recv, shutdown and close, with non-blocking
  semantics and backpressure (send reports EAGAIN rather than buffering
  unboundedly).
* TCP servers: bind, listen, accept, getsockname/getpeername.
* UDP: bind, connect, sendto/recvfrom, with connected-peer filtering.
* IPv4 and IPv6 (AF_INET6): TCP and UDP over v6, including IPV6_V6ONLY.
* get/setsockopt: SO_ERROR, SO_KEEPALIVE and TCP_KEEPIDLE, TCP_NODELAY,
  SO_RCVBUF/SO_SNDBUF, SO_BROADCAST, IP_TTL, SO_REUSEPORT and IPV6_V6ONLY.
  Options are mirrored to a cache (the getsockopt source of truth) and projected
  onto the live socket; we only report options we can actually honor (e.g.
  SO_REUSEADDR reads back as 1 since libuv forces it on, and IPV6_V6ONLY returns
  EINVAL if changed after bind).

Binding is eager and synchronous, so a conflict surfaces as EADDRINUSE at bind()
and getsockname() reports the kernel-assigned ephemeral port immediately - there
is no deferred-bind or lazy-handle promotion. A bound socket is a role-neutral
handle, adopted as-is by listen() (server.listen) or connect() (net.Socket), and
released by close() only if it was never adopted. Bind-time options (ipv6Only,
reusePort) are passed to the handle at construction. The bind primitive is
selected once per capability:

* the public, synchronous net.BoundHandle (and dgram bindSync/connectSync) when
  the Node.js runtime provides them; and
* the private tcp_wrap/udp_wrap bindings as a fallback on Node.js versions that
  do not (bind6/send6 for IPv6).

Details:

* new node backend in src/lib/libsockfs_node.js, pulled in only under
  -sNODERAWSOCKETS, implementing the sock_ops contract
* __syscall_setsockopt and __syscall_shutdown now live in JS, routing to the
  backend under NODERAWSOCKETS (else reporting the option/feature as
  unsupported), avoiding a libstubs variation
* tests under test/sockets exercise TCP echo, server accept/echo (including
  listen-without-bind autobind), client source-port bind plus synchronous
  EADDRINUSE, client semantics (EISCONN, half-close, EPIPE), backpressure,
  connection refused, UDP echo/connect, and IPv6 TCP/UDP over ::1 (including
  IPV6_V6ONLY before/after bind); all build and run natively against the host
  stack and run under node, including PROXY_TO_PTHREAD variants
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