From first server to production multiplayer

See how the pieces fit together.

Start with a complete, tested application below. The later patterns explain individual APIs and may require your own classes, assets, or services.

Private rooms

1. One identity for a page and a protected room

Top

A complete local demonstration of shared authentication, decorator-first server HTML and explicitly authorized raw socket room entry.

TSX
import { randomBytes } from 'node:crypto';
import { page, defineApp, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';

// A runnable local demonstration, not a production credential store.
export async function createApp(port = 8181) {
    const token = randomBytes(32).toString('base64url');
    let enabled = true;
    const authenticate = (request: Pick<RedWebRequest, 'headers'>) =>
        enabled && request.headers.authorization === `Bearer ${token}` ? 'alice' : false;

    @page('/', { authorize: context => context.principal === 'alice' })
    class Home {
        render({ principal }: LivePageRequestContext) { return <main><h1>Private workspace</h1><p>{principal}</p></main>; }
    }

    class Join extends BaseHandler {
        constructor() { super('join'); }
        async onMessage(socket: RedWebSocket) {
            socket.sendJson({ joined: await socket.enterRoom!('team'), principal: socket.context!.principal });
        }
    }
    class Team extends SocketRoute {
        constructor() {
            super({ path: '/team', handlers: [Join], allowDuplicateConnections: true, logger: null,
                admission: { authenticate },
                rooms: { authorize: (context, roomId) => enabled && context.principal === 'alice' && roomId === 'team' },
            });
        }
    }

    const app = defineApp({ pages: [Home], sockets: [Team], authenticate, port, bind: '127.0.0.1', logger: null });
    await app.run();
    const team = app.sockets!.routes.find(route => route instanceof Team)!;
    return {
        app, team, token,
        async revoke() {
            enabled = false; // Invalidate credentials and future permissions first.
            team.clients.forEach(socket => team.rooms!.leaveAll(socket));
            await app.revoke('alice');
        },
        shutdown: () => app.shutdown(),
    };
}

if (require.main === module) {
    createApp().then(demo => {
        console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');
        console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.
    });
}
  • Save this as src/app.tsx in an initialized realtime starter, build and start it. The printed token is a fresh local-demo credential; do not publish it or treat this as a production identity service.
  • Supply the Authorization header for GET / and the /team WebSocket, then send {"type":"join"}. Browser products should use a secure session/cookie integration; native browser WebSockets cannot set this header.
  • Both decorator modes and source-free production execution are checked with real HTTP/WebSockets. The example explicitly revokes raw room memberships as well as Live HTML page sessions.
Read this example as Markdown →
Start here

2. HTTP and WebSockets on one listener

Top

Define pages, socket routes and application services together. Nothing listens until run(); one owner starts and stops the shared HTTP/WebSocket listener.

TSX
import { BaseHandler, defineApp, METHODS, SocketRoute, type RedWebSocket } from 'redweb';

export class Hello extends BaseHandler {
    constructor() { super('hello'); }

    onMessage(socket: RedWebSocket) {
        socket.sendJson({ type: 'hello', message: 'Hello from the server!' });
    }
}

export class ChatRoute extends SocketRoute {
    constructor() {
        super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });
    }
}

export const app = defineApp({
    sockets: [ChatRoute],
    port: Number(process.env.PORT ?? 8181),
    bind: '127.0.0.1',
    publicPaths: [],
    httpServices: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],
});

if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
  • GET /health returns JSON; ws://127.0.0.1:8181/chat accepts {"type":"hello"}. The HTTP endpoint reports liveness, not readiness.
  • Await run to know startup succeeded; no separately stored HTTP server or second socket startup call is necessary. CommonJS entry points use an async function or handle the returned promise; top-level await requires a suitable ESM configuration.
  • For tests, use an ephemeral port, disable process signal ownership and await shutdown in cleanup. The generated applications ship real HTTP/socket and process tests.
  • This is lifecycle composition, not dependency injection or durable/distributed state. Services must cooperate with cancellation and release partially acquired resources. Deadlines cannot interrupt synchronous event-loop blocking. Shared page-class closure objects stay shared; use separate classes/rooms when isolation is required.
Read this example as Markdown →
Rendered UI

3. Server state and reusable TSX components

Top

Ordinary TSX expressions read server-owned state and update automatically after an action; no repeated binding names or browser component runtime.

TSX
import { action, defineApp, page, state } from 'redweb';

@page('/', { css: 'app.css', shared: true })
export class CounterPage {
    @state() count = 0;

    @action()
    increment() { this.count += 1; }

    render() {
        return (
            <main class="home">
                <h1>A counter owned by the server</h1>
                <p>Open this page in two tabs. Either button updates both.</p>
                <button rw-click="increment">
                    Count {this.count}
                </button>
            </main>
        );
    }
}

export const app = defineApp({ pages: [CounterPage], port: Number(process.env.PORT ?? 8181), templateRoot: __dirname });

if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
  • The first response is complete server-rendered HTML.
  • Function components handle stateless snippets; decorated classes own state and actions.
  • There is no React dependency, virtual DOM, or hydration pass.
Read this example as Markdown →
Static HTML

4. A complete site with shared defaults

Top

Define metadata, layout, CSS, caching, and asset export once, then keep each page focused on its content.

TSX
import { defineSite } from 'redweb'

const site = defineSite({
  origin: 'https://example.com',
  css: 'site.css',
  head: { description: 'Product documentation', image: '/og.png' },
  cache: { maxAge: 300 },
  layout: content => <body><nav>Product</nav><main>{content}</main></body>,
})

@site.page('/docs', { head: { title: 'Documentation' } })
class DocsPage {
  render() { return <h1>Documentation</h1> }
}

await site.export(DocsPage, {
  outDir: 'dist',
  publicDir: 'public',
})
  • Canonical URLs are derived from the route and origin.
  • Public assets and rendered pages are staged before output changes.
  • The generated site contains no Redweb browser runtime or WebSocket.
Read this example as Markdown →
Messages

5. JSON routing, broadcast, and binary frames

Top

Text messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.

JavaScript
import { BaseHandler, SocketRoute } from 'redweb'

class ChatHandler extends BaseHandler {
  constructor() { super('chat') }

  onMessage(socket, message) {
    socket.broadcast({ type: 'chat', text: message.text })
  }
}

class SnapshotHandler extends BaseHandler {
  constructor() { super('snapshot') }
  onMessage() {}
  acceptsBinary(_socket, buffer) { return buffer.length > 0 }
  onBinaryMessage(socket, buffer) {
    socket.sendJson({ type: 'snapshot:received', bytes: buffer.length })
  }
}

class RealtimeRoute extends SocketRoute {
  constructor() {
    super({
      path: '/realtime',
      handlers: [ChatHandler, SnapshotHandler],
      websocketOptions: { maxPayload: 64 * 1024 },
    })
  }
}
  • sendJson and broadcast share the same outbound policy.
  • acceptsBinary can select among multiple binary handlers.
  • Async handler failures become safe client errors.
Read this example as Markdown →
Production

6. Bound admission, work, and slow peers

Top

Production controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.

JavaScript
class MatchRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [InputHandler],
      admission: {
        origins: ['https://game.example'],
        timeoutMs: 3000,
        authenticate: (request, { signal }) =>
          verifyPlayer(request, signal),
      },
      maxPendingUpgrades: 64,
      limits: {
        maxConnections: 5000,
        maxBufferedBytes: 1024 * 1024,
        maxPendingMessages: 64,
        messageRate: { capacity: 60, refillPerSecond: 30 },
      },
      orderedMessages: true,
      heartbeat: { intervalMs: 30000, timeoutMs: 10000 },
      websocketOptions: { maxPayload: 64 * 1024 },
    })
  }
}
  • Authentication completes before onInitialContact.
  • Ordered overflow closes pending work synchronously.
  • Disabled controls add no per-connection queue or timer.
Read this example as Markdown →
Players

7. Match handlers and resumable ownership

Top

Give the match its own socket route, then dispatch join, move and resume by type. These canonical socket-starter handlers create and recover server-owned player sessions; they are not a room-broadcast or account-authentication example.

TypeScript
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 });
});
  • Initialize the complete socket recipe: src/contract.ts defines validated payloads and src/app.tsx configures /match, session capacity and transport limits. This file is not a standalone server.
  • Join issues a random bearer session token. Move requires an existing player; resume restores that player on a new connection and replaces the previous owner.
  • Keep the state response and session token private. Add account authentication and application movement rules before production; sessions remain in memory and expire 30 seconds after disconnect.
  • For authenticated group delivery, use the separate One identity for a page and a protected room example. The shared socket contracts guide links its complete source and the room-authorization guide.
Read this example as Markdown →
Simulation

8. Fixed-step work without overlapping ticks

Top

FixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.

JavaScript
import { FixedStepService, SocketRoute } from 'redweb'

class Simulation extends FixedStepService {
  constructor() {
    super('simulation', 50, 3, 250)
  }

  async onTick(stepMs, tick) {
    await authoritativeGame.update(stepMs, tick)
  }

  onLagDropped(milliseconds) {
    console.warn('Simulation lag discarded', { milliseconds })
  }
}

class SimulationRoute extends SocketRoute {
  constructor() {
    super({
      path: '/simulation',
      handlers: [InputHandler],
      services: [Simulation],
    })
  }
}
  • The active async tick must finish before another begins.
  • maxCatchUpTicks prevents a spiral of death.
  • maxRetainedLagMs bounds remembered delay.
Read this example as Markdown →
Clients

9. Versioned envelopes with an explicitly owned connection

Top

Negotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.

JavaScript
import { SocketRoute } from 'redweb'

class ProtocolRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [MoveHandler],
      protocol: {
        versions: ['2', '1'],
        binary: {
          maxBytes: 64 * 1024,
          encode: (state) => codec.encode(state),
          decode: (bytes) => codec.decode(bytes),
        },
      },
    })
  }
}

// Browser entry (bundle separately from server code)
import { ProtocolClient, ERROR_CODES } from 'redweb/client'
const socket = new WebSocket(
  'wss://game.example/match?redwebVersion=2'
)
const client = new ProtocolClient(socket, '2')

socket.addEventListener('message', (event) => {
  try {
    const message = client.parse(event)
    if (message.error?.code === ERROR_CODES.RATE_LIMITED) backOff()
  } catch (error) {
    console.error('Invalid server envelope', error)
  }
})

socket.addEventListener('open', () => {
  client.send('move', { x: 4, y: 2 }, { sequence: 17 })
})
  • Browsers negotiate with redwebVersion in the query.
  • requestId correlates; sequence expresses application ordering.
  • Neither field promises durability or exactly-once delivery.
  • ProtocolClient is the low-level envelope helper, not redweb-client. For generated server-TSX UI, bind typed handlers instead of writing this browser module.
Read this example as Markdown →
Multiple nodes

10. Bring your own broker adapter

Top

Redweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.

JavaScript
import { SocketRoute } from 'redweb'

class DistributedMatchRoute extends SocketRoute {
  constructor() {
    super({
      path: '/match',
      handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],
      rooms: true,
      distribution: {
        adapter: brokerAdapter,
        channel: 'matches',
        nodeId: process.env.INSTANCE_ID,
        required: true,
        maxEventBytes: 64 * 1024,
        maxConcurrentPublishes: 32,
        onEvent(event, route) {
          route.rooms.broadcast(event.payload.roomId, {
            type: event.type,
            payload: event.payload,
          })
        },
      },
    })
  }
}

// From a connected socket:
await socket.publishEvent('match:update', update)
  • Required adapters affect readiness; best-effort adapters do not.
  • Source-node events are ignored to prevent reflection loops.
  • Authoritative state and partition reconciliation remain application work.
Read this example as Markdown →
Operations

11. Readiness first, then bounded shutdown

Top

Stop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.

JavaScript
import { defineApp, METHODS } from 'redweb'
import { MatchRoute } from './MatchRoute.js'

const app = defineApp({
  sockets: [MatchRoute],
  httpServices: [{
    serviceName: '/ready',
    method: METHODS.GET,
    function: (_request, response) => response.sendStatus(app.sockets?.isReady() ? 200 : 503),
  }],
})
await app.run()

// A deployment controller can invoke this; normal signals use app shutdown.
async function retireNode() {
  app.sockets?.beginDrain()
  await stopExternalPlacement()
  await app.shutdown()
}
  • New upgrades receive 503 once draining starts.
  • Handlers on routes with drainHandlers: true receive cancellation through socket.context.signal; application work must cooperate.
  • A hard deadline terminates non-cooperating peers.
Read this example as Markdown →