Documentation for Redweb 0.13.0. Install that exact version when following these examples.

Share typed WebSocket contracts

Build a /match service with independent join, move and resume handlers. A shared schema validates payloads and supplies TypeScript types to both sides. Use this when you need a raw socket protocol, such as a game client or a custom realtime client, rather than a server-rendered page.

Explain it like I'm five

The URL is the room's address. A message's type tells the receptionist which person should handle it. The shared contract is the form that says what information that person needs. Checking the form before handing it over prevents a movement handler from receiving a name where a coordinate should be.

Follow the design

  1. The contract declares join, move, resume and state once. It is safe to import into a browser bundle because it does not import the server application.
  2. The route binds /match, enables the contract protocol and registers Join, Move and Resume. There is no socket decorator layer and no inner message.action switch.
  3. The handlers below receive parsed payloads. Join creates an in-memory player session, Move changes its server-owned coordinates, and Resume reclaims it using a private bearer token.
  4. Each sends a validated state response. A client uses match.client(socket) to send and parse typed messages. That wrapper does not open or reconnect its transport; the application creates the WebSocket first.

Connect to ws://localhost:8181/match?redwebVersion=1 during local development. Follow the complete recipe's client example for opening a transport and handling responses; use WSS outside local development. The contract reference documents wire envelopes, validation and failure behavior.

Check that it works

Join with two independent clients, move one, then disconnect and resume it with its session token. The other player's state must remain independent. Send invalid coordinates and a malformed raw message to verify both client and server checks. The real-socket acceptance test covers those sequences, including server rejection that bypasses client validation.

This is not a complete game backend

The example bounds coordinate values; it does not prove a move obeys your game's speed, turn or collision rules. Add authoritative game rules and authentication. Keep session tokens private: possession permits resume and connection takeover. Sessions are capped at 100, expire 30 seconds after disconnect, and are lost on restart. They are not a durable or cross-worker identity store.

Per-connection ordering is not exactly-once delivery. After a disconnect, a client may not know whether its last action completed; reconcile state before retrying side effects. Choose transport limits from measured load and read operations and runtime retry boundaries.

Build and run the complete application

sh
npx --yes redweb@0.13.0 init my-socket --template socket
cd my-socket
npm install --save-exact redweb@0.13.0
npm test
npm run dev

The complete socket recipe contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.

Source walkthrough: src/handlers.ts

ts
import { randomUUID } from 'node:crypto';
import type { RedWebSocket } from 'redweb';
import { match } from './contract';

class Player {
    readonly session = randomUUID();
    x = 0;
    y = 0;
    constructor(readonly name: string) {}
}

function requireUnjoined(socket: RedWebSocket) {
    if (socket.context?.session) throw new Error('Already joined.');
}

function currentPlayer(socket: RedWebSocket) {
    const session = socket.context?.session as { data?: unknown } | null | undefined;
    if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');
    return session.data;
}

export const Join = match.handler('join', (socket, { name }, message) => {
    requireUnjoined(socket);
    const player = new Player(name);
    if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');
    return match.send(socket, 'state', player, { requestId: message.requestId });
});

export const Move = match.handler('move', (socket, { x, y }, message) => {
    const player = currentPlayer(socket);
    player.x = x;
    player.y = y;
    return match.send(socket, 'state', player, { requestId: message.requestId });
});

export const Resume = match.handler('resume', (socket, { session }, message) => {
    requireUnjoined(socket);
    if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');
    return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });
});