GioJSdocs
On this page

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:

FieldTypeDefaultDescription
idstring-Unique connection id (for broadcast(room, data, { except })).
pathstring-The URL path the client connected to. routeId is the same value.
paramsRecord<string, string>-The dynamic segments of the matched route.ts.
queryRecord<string, string>-The query string of the upgrade request.
headersRecord<string, string>-A fixed subset of the upgrade request's headers, lowercase: cookie, authorization, user-agent, accept-language, origin and x-request-id.
cookiesRecord<string, string>-The Cookie header, parsed; sessions.getSession(socket) reads it.
ipstring | undefined-The client's address, behind [server] trusted_proxies.
requestIdstring | undefined-The upgrade request's X-Request-Id.
roomsReadonlySet<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 handlerThe connection
returns or resolves to falseClosed with 4401 unauthorized.
returns anything else, or resolvesAccepted.
throws or rejectsClosed 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 with 1008. Messages held for 10 seconds by a handler that neither listens nor finishes are dropped.
  • No handler. A connection to a path whose route.ts exports no wsHandler (or with no route.ts at all) closes with 4404 no websocket handler. One to a route.ts that threw while it was imported closes with 1011 and internal error (digest ...).
  • Server-side limits come from [websocket] in gio.toml: enabled, max_connections (further connections close with 1013) and ping_interval_secs. A shutdown or a worker restart closes every socket with 1001.
  • Origin check. An upgrade whose Origin is another site is refused with 403 before the handler runs, unless the origin is in [security.csrf] trusted_origins or [security.websocket] check_origin = false.

Types

WsHandler is (socket: GioSocket) => void | boolean | Promise<void | boolean>. Both are exported from @gio.js/core.

Examples

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, useWebSocket reconnects with backoff, but not after 1000 or a 4000-4499 close: use 4500-4999 for refusals a retry may fix.
  • The same route.ts can also export HTTP method handlers.

Version history

VersionChanges
v0.1.0-beta.8Page 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.5Binary frames arrive as a Buffer and send() accepts one; connections survive worker restarts.
v0.1.0-beta.1Introduced.