GioJSdocs
On this page

The Rust ⇄ Node Boundary

Persistent IPC connections carry cache-missed requests to the Node workers.

Rust talks to each long-lived Node worker over its own Unix socket (Linux/macOS) or named pipe (Windows), using a versioned, length-prefixed JSON protocol. There is no per-request process spawn. With a worker pool, every worker has its own connection and each request goes to the least busy one.

  • Authenticated - a worker proves with a per-worker token that this server started it, and the protocol version is checked at the handshake, so a mismatched @gio.js/core fails loudly instead of misbehaving.
  • Streaming - personalized pages and streamed route responses cross as chunk frames while React produces them; Rust splices its head and body injections into the stream.
  • Binary-safe - request bodies and binary route responses cross byte-for-byte; a request too large for one frame is answered with 413.
  • Cancellable - a client disconnect or timeout aborts the render in Node instead of finishing work nobody reads.
Routing, caching, compression and the security checks all happen in Rust before Node is ever consulted - the boundary is crossed only for a cache-missed render, a route handler, an action, or a WebSocket message.

What crosses the boundary

RequestAnswered by
A page cache hit, a static file, a public/ file, a font, an optimized imageRust alone
A guard, redirect, rewrite or header rule; CSRF, rate-limit and path checks; /_gio/health, /_gio/metrics, /_gio/revalidateRust alone
A cache miss, a personalized page, a page action, a route handlerA Node worker, over the render connection
A WebSocket messageThe worker that accepted the socket, over a second connection

The handshake

The server starts each worker with a fresh random token and the path of its socket (under .gio/). The worker listens there, builds the client bundles (or loads the first worker's build), discovers the routes, and sends a ready frame. Neither side ever sends the raw token: each sends a proof derived from it and its role, so an endpoint that captures one proof cannot answer with the other. The ready frame carries:

  • the protocol version - a server and a @gio.js/core that speak different versions refuse to work together, with an error that says to update both;
  • the route manifest, which Rust loads into its router;
  • the rules from middleware.ts, which Rust compiles and enforces;
  • a hash of the client build and the app's server sources, which becomes the deployment ID.

Rust answers with an ack carrying the deployment ID, and only then sends traffic. The server tries to connect for up to 60 attempts while a worker boots.

Frames

Every message is a 4-byte big-endian length followed by that many bytes of JSON, at most 64 MiB. Requests are multiplexed by id over the one connection, so a slow render never blocks the others. A request body that is not valid UTF-8 crosses base64-encoded, which limits binary bodies to about 48 MiB.

FrameDirectionPurpose
requestRust → NodeMethod, path, params, query, headers, body, locale, client IP and request id
responseNode → RustStatus, headers, cookies, body, and whether and for how long Rust may cache it
chunk, shell_end, chunk_endNode → RustA streamed body, and where a PPR shell ends
sse_chunk, sse_doneNode → RustServer-Sent Events
flowRust → NodePause and resume a streamed body (backpressure)
cancel, sse_closeRust → NodeThe client went away or the deadline passed: stop the work
revalidate, revalidate_ackBothrevalidateTag() and revalidatePath() purges, confirmed by Rust

Deadlines and limits

  • A worker must answer within [server] render_timeout_secs (30 seconds by default): the whole buffered response, the head of a streamed one, and every gap between page chunks. Past it the client gets 504 and the worker a cancel. Route-handler streams and event streams have no idle limit.
  • A response that does not parse gets a 500 at once. A request too large for one frame gets 413 without touching the connection.
  • Event-stream and WebSocket data frames are dropped, with a warning, while more than 8 MiB is waiting to be written to Rust, so a stalled consumer cannot grow the worker's memory without bound. Response frames are never dropped.

When a worker fails

A worker that crashes fails only the requests it had in flight, with 503, and is restarted with backoff while the other workers - and every cached page - keep serving. Its WebSockets close with 1001. The worker also watches the stdin pipe the server holds: if the server dies outright (SIGKILL, the OOM killer), the pipe closes and the worker exits instead of lingering. On a normal stop each worker gets 6 seconds to run plugin shutdown hooks.