GioJSdocs
On this page

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.

ts
// 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

MemberDescription
idUnique connection id.
path, params, queryThe URL the client connected to, its dynamic segments, and its query string.
headersA fixed subset of the upgrade request's headers, lowercase: cookie, authorization, user-agent, accept-language, origin and x-request-id.
cookiesThe Cookie header, parsed.
ipThe client's address - behind a proxy only when it is listed in [server] trusted_proxies, like req.ip.
requestIdThe 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), roomsRoom 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 own socket.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 with 1008; 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.
app/live/route.ts
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:

ts
// 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:

app/api/rooms/[room]/route.ts
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/Buffer as 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 (join throws beyond either).
  • broadcast returns false when 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

CodeMeaning
1000Normal close (socket.close()).
1001The server is shutting down, or the worker restarted (its sockets' state is gone): reconnect.
1008Too many messages (256, or 1 MiB) before the handler listened or accepted the connection.
1011The 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.
4401The handler rejected the connection (returned false).
4404No 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

components/Chat.tsx
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: or wss: to match). Binary frames arrive as ArrayBuffer.
  • After an unintended close it reconnects with exponential backoff and jitter (on by default; reconnect: false turns it off). The backoff starts over only after a connection stays open for minUptimeMs (default 5 s): the server refuses after the upgrade (1013 at max_connections, 1011), so a socket that opens and is closed right away keeps backing off and counts toward maxAttempts. It does not reconnect after 1000 or a 4000-4499 close, after close(), or once unmounted; shouldReconnect(event) replaces that policy, and reconnect() opens a fresh connection.
  • send() returns false when 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 - after close(), 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.
  • lastMessage drives renders, but React may batch two quick messages into one render - use onMessage when every message matters.
  • Returns readyState (-1 during SSR), reconnectAttempts and isReconnecting; 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.