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.
Learn by building · Redweb TSX + sockets Build authenticated tic-tac-toe from the ground up. Initialize once with Redweb, then add game rules, typed commands, connected players, server TSX, authentication, and one application entry point. Every chapter extends the same project.
Start the guided tutorial → Redweb 0.16.5 · Build something useful What do you want to build? Start with a working application. Understand the pieces, run the real tests, and know the deployment limits.
Redweb 0.16.5 · Guides and tested applications Complete files, setup commands, real-network tests, and explicit limits. Agent-readable index
Available versions: Redweb 0.16.5 · Redweb 0.13.0 · Redweb 0.13.2 · Redweb 0.13.3 · Redweb 0.13.4 · Redweb 0.13.5 · Redweb 0.14.0 · Redweb 0.15.0 · Redweb 0.16.0 · Redweb 0.16.1 · Redweb 0.16.2 · Redweb 0.16.3 · Redweb 0.16.4 ·
Choose a starter and build a working app Requirements, fit, development, tests, and production boundaries. One application, one listener Define pages, socket routes and application services together; run and shut down one owned HTTP/WebSocket listener. Upgrade an existing Redweb application Historical socket defaults, asynchronous ownership, HTML migration and version-matched verification. Initialization and diagnostics Noninteractive setup, safe existing-project adoption, and structured repair guidance. Develop with browser refresh and safe inspection Loopback-only refresh with an explicit edit guard, plus bounded metadata and reactive update inspection without application values. Understand runtime failures before retrying Safe authentication, authorization, validation and application errors, with actual retry and cancellation guarantees. Optional read-only agent documentation access Configure local MCP search and exact recipe retrieval without adding dependencies to Redweb servers. Pages, components, state, actions, and CSS Server-side TSX, automatic updates, keyed lists, lifecycle, and static export. Typed socket routes and handlers Shared validation, client/server types, protocol errors, and join/move/resume. Server-side TSX for custom socket routes Typed handler bindings, private page state and one generated client connection without handwritten browser glue. Private rooms and shared request identity Explicit entry policies, bounded authorization, trusted context, publication and revocation. Deploy and operate socket services Readiness, shutdown, capacity, reconnection, and distributed boundaries. Production guarantees and limits Resource ownership, delivery semantics, compatibility, and release gates. Recorded verification evidence Historical measurements and their exact scope; not proof of a newer release. Release status Acceptance checklist, completed increments, and remaining publication or deployment boundaries. Choose and verify a release Maintained runtimes, compiler/browser verification boundaries, pinned packages, registry signatures, provenance and support limits. Realtime starter `CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors. Chat starter `src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation. Site starter `defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title. Socket starter This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`. Dashboard starter This recipe combines decorator-first pages, reusable live cards, validated actions, and real SQLite persistence. It is an application example, not an authentication framework or managed database. Http-ws starter One Node server answers ordinary HTTP requests and upgrades `/chat` connections to WebSockets. HTTP paths select Express services; a socket URL selects a route and each message's `type` selects a handler. No socket decorators or secondary `message.action` dispatcher are needed. Complete public TypeScript declarations Exact shipped signatures, options, and public types; not standalone application snippets. Redweb 0.16.5 · Live HTML Server-owned UI beyond the counter One complete example connects a file upload, an explicit provider, and resource updates across two browser tabs. It comes from the published guide, not a second site implementation.
Shared notes · app.tsx import { action, defineApp, inject, liveResource, page, resource, upload, type UploadedFile } from 'redweb' ;
const noteUpdates = liveResource<string, string>();
class NoteStore {
current = '' ;
async save(file: UploadedFile) {
const chunks: Buffer[] = [];
for await (const chunk of file.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from (chunk));
}
this.current = Buffer.concat(chunks).toString('utf8' );
noteUpdates.publish('shared-note' , this.current);
}
clear() {
this.current = '' ;
noteUpdates.publish('shared-note' , this.current);
}
}
@page('/notes' )
class NotesPage {
@inject('notes' ) notes!: NoteStore;
@resource(noteUpdates, () => 'shared-note' ) note: string | null = null ;
@upload({ maxBytes: 64 * 1024 , accept: 'text/plain' })
async saveNote(file: UploadedFile) {
await this.notes.save(file);
}
@action()
clearNote() {
this.notes.clear();
}
render() {
return <main>
<h1>Shared note</h1>
<input type="file" accept="text/plain" rw-upload="saveNote" />
<button rw-click="clearNote" >Clear</button>
<p rw-status="saveNote" role="status" aria-live="polite" />
<pre>{this.note ?? this.notes.current}</pre>
</main>;
}
}
const notes = new NoteStore();
export const app = defineApp({ pages: [NotesPage], providers: { notes } });
if (require.main === module) void app.run();The in-memory note is a teaching example. Real applications supply persistence and authorize reads and writes on the server.
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' ;
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 ;
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}` );
});
}
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 → 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 → 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 → 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 → 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 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 → 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 → 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 → 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),
},
},
})
}
}
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 → 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,
})
},
},
})
}
}
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 → 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()
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 →