wsHandler
Accept WebSocket connections at the path of a route.ts, and decide per connection whether to keep each one.
app/chat/[room]/route.ts
import { broadcast, type GioSocket } from '@gio.js/core';
export function wsHandler(socket: GioSocket) {
const room = socket.params.room ?? 'lobby'; // ws://host/chat/news → 'news'
socket.join(room);
socket.send(`welcome to ${room}`);
socket.on('message', (text) => broadcast(room, String(text)));
}The Rust server completes the WebSocket upgrade and hands each connection to the Node worker over a pipe of its own, so sockets and HTTP requests never wait on each other. The worker finds the route.ts whose pattern matches the path - with the same rules as pages: [id], [...slug], [[...slug]], (group) folders - and calls its wsHandler once for the connection.
Reference
Parameters
socket (GioSocket) is the connection:
| Field | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique connection id (for broadcast(room, data, { except })). |
path | string | - | The URL path the client connected to. routeId is the same value. |
params | Record<string, string> | - | The dynamic segments of the matched route.ts. |
query | Record<string, string> | - | The query string of the upgrade request. |
headers | Record<string, string> | - | A fixed subset of the upgrade request's headers, lowercase: cookie, authorization, user-agent, accept-language, origin and x-request-id. |
cookies | Record<string, string> | - | The Cookie header, parsed; sessions.getSession(socket) reads it. |
ip | string | undefined | - | The client's address, behind [server] trusted_proxies. |
requestId | string | undefined | - | The upgrade request's X-Request-Id. |
rooms | ReadonlySet<string> | - | The rooms this socket joined. |
send(data) | void | - | A string sends a text frame, a Buffer a binary frame. |
close(code?, reason?) | void | - | Closes the connection (default 1000). Use 4000-4999 for your own codes. |
on('message', fn) | void | - | Text frames arrive as strings, binary frames as Buffer. |
on('close', fn) | void | - | (code, reason) once the connection is gone. |
join(room) / leave(room) | void | - | Room membership. A room name is a non-empty string of up to 256 bytes, and a socket joins at most 100 rooms; join throws past either limit. |
broadcast(data) | void | - | Sends to every accepted socket connected to the same path, this one included. |
Returns
| The handler | The connection |
|---|---|
returns or resolves to false | Closed with 4401 unauthorized. |
| returns anything else, or resolves | Accepted. |
| throws or rejects | Closed with 1011 internal error; the error is logged. |
calls socket.close(code, reason) | Closed with your code. |
Behavior
- Before it is accepted a socket receives no route or room broadcasts, even from rooms it already joined; its own
socket.send()reaches it (to ask for a token, say). - Early messages that arrive before any
'message'listener exists are held and delivered to the first one, in order - up to 256 messages or 1 MiB; past that the connection closes with1008. Messages held for 10 seconds by a handler that neither listens nor finishes are dropped. - No handler. A connection to a path whose
route.tsexports nowsHandler(or with noroute.tsat all) closes with4404no websocket handler. One to aroute.tsthat threw while it was imported closes with1011andinternal error (digest ...). - Server-side limits come from
[websocket]in gio.toml:enabled,max_connections(further connections close with1013) andping_interval_secs. A shutdown or a worker restart closes every socket with1001. - Origin check. An upgrade whose
Originis another site is refused with403before the handler runs, unless the origin is in[security.csrf] trusted_originsor[security.websocket] check_origin = false.
Types
WsHandler is (socket: GioSocket) => void | boolean | Promise<void | boolean>. Both are exported from @gio.js/core.
Examples
Authenticate with the session cookie
app/live/route.ts
import type { GioSocket } from '@gio.js/core';
import { sessions } from '../../lib/session.server.ts';
import { db } from '../../lib/db.server.ts';
import { handle } from '../../lib/live.server.ts';
export async function wsHandler(socket: GioSocket) {
const userId = sessions.getSession(socket).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));
}Wait for a token message
app/feed/route.ts
import type { GioSocket } from '@gio.js/core';
import { verifyToken } from '../../lib/tokens.server.ts';
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); // never wait forever
});
const user = token === null ? null : await verifyToken(token);
if (user === null) return false; // close 4401 'unauthorized'
socket.send('accepted');
return true;
}Publish from an HTTP request
app/api/rooms/[room]/route.ts
import { broadcast, type RouteHandler } from '@gio.js/core';
export const POST: RouteHandler<'/api/rooms/:room'> = (req) => {
const delivered = broadcast(req.params.room, JSON.stringify(req.json()));
return { delivered }; // false: no WebSocket server connected
};Good to know
- Keep an async handler to the decision: a handler that never resolves never accepts. Start long-running work without awaiting it.
- Browsers cannot set headers on a WebSocket, so a cookie (or a first message) is the credential. Avoid long-lived tokens in the URL: URLs end up in logs.
- Rooms and connections are per server instance. Across instances, relay through a shared bus (Redis, NATS, Postgres
LISTEN). - On the client,
useWebSocketreconnects with backoff, but not after1000or a4000-4499close: use4500-4999for refusals a retry may fix. - The same
route.tscan also export HTTP method handlers.
Related
- WebSockets - the guide, with close codes
broadcastuseWebSocket[websocket]and[security.websocket]route.ts
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Page routing for WebSocket paths (socket.params); path, query, headers, cookies, ip and requestId on the socket; return false to reject (4401); async handlers decide the connection; rooms; 4404 for a path without a handler. |
v0.1.0-beta.5 | Binary frames arrive as a Buffer and send() accepts one; connections survive worker restarts. |
v0.1.0-beta.1 | Introduced. |