WebSockets
Routed, authenticated, full-duplex connections with rooms.
Export a wsHandler from a route.ts to accept WebSocket connections at that path. Patterns follow the same rules as pages - dynamic [segments], [...catchAll], [[...optional]] and (groups) - and the matched segments arrive in socket.params. WebSockets run over their own IPC pipe, so neither they nor HTTP requests block each other.
// app/chat/[room]/route.ts → ws://host/chat/lobby
import { broadcast, type GioSocket } from '@gio.js/core';
export function wsHandler(socket: GioSocket) {
const room = socket.params.room; // 'lobby'
socket.join(room);
socket.on('message', (text) => broadcast(room, `${socket.id}: ${text}`));
socket.on('close', (code, reason) => console.log('left', room, code, reason));
}The socket
| Member | Description |
|---|---|
id | Unique connection id. |
path, params, query | The URL the client connected to, its dynamic segments, and its query string. |
headers | A fixed subset of the upgrade request's headers, lowercase: cookie, authorization, user-agent, accept-language, origin and x-request-id. |
cookies | The Cookie header, parsed. |
ip | The client's address - behind a proxy only when it is listed in [server] trusted_proxies, like req.ip. |
requestId | The upgrade request's X-Request-Id, on every log line. |
send(data) | A string sends a text frame, a Buffer a binary frame. |
close(code?, reason?) | Close the connection (default 1000). |
on('message' | 'close', fn) | Text frames arrive as strings, binary frames as Buffer. |
join(room), leave(room), rooms | Room membership (below). |
broadcast(data) | Send to every socket connected to the same path, this one included. |
Authenticating connections
The handler runs once per connection and decides whether to keep it. Return false (or resolve to false) to reject: the connection closes with code 4401 and reason unauthorized. Call socket.close(code, reason) to reject with your own code. A handler that throws closes the connection with 1011. Anything else accepts it - for an async handler, when its promise resolves.
- Until the handler accepts, the socket receives nothing from
socket.broadcast()or room broadcasts (even rooms it already joined), so a client you are about to reject never sees other members' traffic. Your ownsocket.send()reaches it, e.g. to ask for a token. - Messages that arrive before the handler registers a
'message'listener are held and delivered to it, in order. Once a listener exists, messages reach it as they arrive - even while an async handler is still deciding - so register yours after the check unless it is the check (below). A client that sends more than 256 messages or 1 MiB before anyone listens is closed with1008; messages held for 10 seconds by a handler that neither listens nor finishes are dropped. - Keep an async handler to the decision: start long-lived work (a feed loop) without awaiting it, since a handler that never resolves never accepts.
import type { GioSocket } from '@gio.js/core';
import { sessions } from '../../lib/session.server.ts';
export async function wsHandler(socket: GioSocket) {
const session = sessions.getSession(socket); // reads socket.cookies
const userId = session.get('userId');
if (userId === undefined) return false; // → close 4401 'unauthorized'
const user = await db.users.find(userId);
if (user.banned) return socket.close(4403, 'forbidden');
socket.join(`user:${userId}`);
socket.on('message', (msg) => handle(user, msg));
}Browsers send the page's cookies with the upgrade request but cannot set other headers on a WebSocket, so a session cookie is the natural credential. Other clients can send Authorization. Without a cookie, have the client send a token as its first message and await it - give the wait a deadline, or a client that never sends one keeps its socket open:
// app/feed/route.ts - the client sends its token first
import type { GioSocket } from '@gio.js/core';
export async function wsHandler(socket: GioSocket) {
const token = await new Promise<string | null>((resolve) => {
socket.on('message', (data) => resolve(String(data)));
setTimeout(() => resolve(null), 5_000);
});
const user = token === null ? null : await verifyToken(token);
if (user === null) return false; // → close 4401
socket.join(`user:${user.id}`);
socket.on('message', (msg) => handle(user, msg)); // later messages
}That first listener stays registered and sees later messages too (resolving an already-resolved promise does nothing). Avoid tokens in the URL (socket.query.token) unless they are short-lived: URLs end up in logs.
The upgrade itself always completes before your handler runs, so a rejected client sees the connection open and then close with your code - which is exactly what a browser can observe (it never sees the HTTP status of a failed upgrade).
Rooms
socket.join(room) adds a socket to a named room; it leaves with socket.leave(room) or when it disconnects. broadcast(room, data) sends to every member - from a wsHandler, or from any route handler, so an HTTP request can publish to WebSocket clients:
import { broadcast, type GioRequest } from '@gio.js/core';
export function POST(req: GioRequest) {
const delivered = broadcast(req.params.room, JSON.stringify(req.json()));
return { delivered };
}
// Everyone but the sender:
socket.on('message', (msg) => broadcast(room, msg, { except: socket.id }));- Strings go out as text frames,
Uint8Array/Bufferas binary frames. - Membership lives in the Rust server, so a broadcast crosses to it once, however many sockets it reaches. Rooms disappear when their last member leaves.
- A room name is any non-empty string up to 256 bytes; a socket can be in up to 100 rooms (
jointhrows beyond either). broadcastreturnsfalsewhen no WebSocket server is connected ([websocket] enabled = false, or for the moment the worker restarts). Delivery is best-effort, like any WebSocket send: under heavy backpressure, messages are dropped rather than buffered without bound.- Rooms are per server process. Running several GioJS instances behind a load balancer needs a shared bus (Redis, NATS, Postgres
LISTEN) that each instance relays to its local rooms.
Close codes
| Code | Meaning |
|---|---|
1000 | Normal close (socket.close()). |
1001 | The server is shutting down, or the worker restarted (its sockets' state is gone): reconnect. |
1008 | Too many messages (256, or 1 MiB) before the handler listened or accepted the connection. |
1011 | The wsHandler threw, or the route.ts for this path threw while it was imported: the reason is internal error (digest ...) and the server log has the file and the error under that digest. |
1013 | [websocket] max_connections reached: try again later. |
4401 | The handler rejected the connection (returned false). |
4404 | No route.ts exports a wsHandler for this path. |
Use 4000-4499 for refusals that retrying will not fix (like HTTP 4xx) and 4500-4999 for transient ones: useWebSocket reconnects on the latter only.
On the client: useWebSocket
import { useState } from 'react';
import { useWebSocket } from '@gio.js/react';
export function Chat({ room }: { room: string }) {
const [text, setText] = useState('');
const { send, lastMessage, readyState, isReconnecting } = useWebSocket(`/chat/${room}`, {
reconnect: { maxAttempts: 20, initialDelayMs: 500, maxDelayMs: 30_000 },
queueWhileDisconnected: true, // buffer sends until (re)connected
onMessage: (msg) => console.log('chat', msg), // every message, in order
});
return (
<form onSubmit={(e) => { e.preventDefault(); if (send(text)) setText(''); }}>
<input value={text} onChange={(e) => setText(e.target.value)} />
{isReconnecting ? 'Reconnecting…' : readyState === WebSocket.OPEN ? 'Live' : 'Connecting…'}
<p>{String(lastMessage ?? '')}</p>
</form>
);
}- Relative URLs resolve against the page (
ws:orwss:to match). Binary frames arrive asArrayBuffer. - After an unintended close it reconnects with exponential backoff and jitter (on by default;
reconnect: falseturns it off). The backoff starts over only after a connection stays open forminUptimeMs(default 5 s): the server refuses after the upgrade (1013atmax_connections,1011), so a socket that opens and is closed right away keeps backing off and counts towardmaxAttempts. It does not reconnect after1000or a 4000-4499 close, afterclose(), or once unmounted;shouldReconnect(event)replaces that policy, andreconnect()opens a fresh connection. send()returnsfalsewhen the message was dropped: the socket is not open and queueing is off (or its 100-message queue is full), or the hook has stopped - afterclose(), after unmount, or once it has given up reconnecting. Queueing does not change that: a stopped hook queues nothing.- There is no
'use client'directive to add: every page and nested layout is server-rendered and hydrated (see Known issues), and the hook only connects in the browser. lastMessagedrives renders, but React may batch two quick messages into one render - useonMessagewhen every message matters.- Returns
readyState(-1during SSR),reconnectAttemptsandisReconnecting; the hook is a no-op on the server.
Origin check
Browsers let any website open a WebSocket to your server with your users' cookies attached. GioJS refuses upgrade requests whose Origin is another site (403, before the upgrade); same-origin pages, origins listed in [security.csrf] trusted_origins, and clients that send no Origin connect normally. The check stays on when [security.csrf] enabled = false; [security.websocket] check_origin switches it. See Security.
Limits
[websocket] in gio.toml sets max_connections (default 1000; further connections close with 1013; 0 = unlimited) and ping_interval_secs (default 30), the keep-alive ping that also reaps dead peers (0 sends none, so a vanished peer lingers until TCP notices). See Configuration.