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/corefails 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.
What crosses the boundary
| Request | Answered by |
|---|---|
A page cache hit, a static file, a public/ file, a font, an optimized image | Rust alone |
A guard, redirect, rewrite or header rule; CSRF, rate-limit and path checks; /_gio/health, /_gio/metrics, /_gio/revalidate | Rust alone |
| A cache miss, a personalized page, a page action, a route handler | A Node worker, over the render connection |
| A WebSocket message | The 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/corethat 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.
| Frame | Direction | Purpose |
|---|---|---|
| request | Rust → Node | Method, path, params, query, headers, body, locale, client IP and request id |
| response | Node → Rust | Status, headers, cookies, body, and whether and for how long Rust may cache it |
chunk, shell_end, chunk_end | Node → Rust | A streamed body, and where a PPR shell ends |
sse_chunk, sse_done | Node → Rust | Server-Sent Events |
flow | Rust → Node | Pause and resume a streamed body (backpressure) |
cancel, sse_close | Rust → Node | The client went away or the deadline passed: stop the work |
revalidate, revalidate_ack | Both | revalidateTag() 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 gets504and the worker acancel. Route-handler streams and event streams have no idle limit. - A response that does not parse gets a
500at once. A request too large for one frame gets413without 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.