# Socket: complete application

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

## Socket starter

This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.
The URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.
There are no socket decorators or secondary `message.action` dispatchers.

Read `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define
the wire contract, route/server configuration, and join/move/resume handlers.
The displayed handlers depend on those other generated files; initialize the
complete recipe first. Session ownership is separate from room fan-out.

`src/contract.ts` declares the wire payloads once using Zod, a Standard Schema validator. Both the server and a bundled browser/Node client can import it for runtime validation and inferred TypeScript types:

```ts
import { match } from './contract';

const socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');
const client = match.client(socket);
socket.addEventListener('open', () => {
    client.send('join', { name: 'Ada' }).catch(console.error);
});
socket.addEventListener('message', async event => {
    try { console.log(await client.parse(event)); }
    catch (error) { console.error(error); }
});
```

The initial `state` response contains `{ session, name, x, y }`. Send `move` with `{ x: 7, y: -3 }` to change your server-owned position; send `resume` with `{ session }` on a new connection to recover it. Messages are processed in order on each connection. The client wrapper validates messages; it does not open or reconnect the WebSocket for you. Use WSS outside local development.

`npm test` opens real sockets and checks independent players, server-side moves, disconnect/resume, client validation, and server rejection of a malformed raw message. The starter also passes with the original source directory unavailable after building.

### Boundaries

- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.
- The random session ID is a bearer credential. Anyone holding it can resume that player and replace its previous connection. Keep it private; do not broadcast the `state` response to other players. Add account authentication and bind sessions to authenticated identity for production.
- Sessions are in memory, local to this server, capped at 100, and expire 30 seconds after disconnect. Server restart loses them. This is not persistent storage or a multi-instance session system.
- Calling `join` or `resume` while already joined is rejected. Movement before joining and unknown/expired sessions are rejected. Application failures currently use the protocol's sanitized `HANDLER_FAILED` error.
- Invalid contract payloads produce `INVALID_PAYLOAD` and close that connection with code 1008. The contract's `state` type is server output; it has no client-callable handler.
- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.


## Setup and acceptance

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


Requirements: Node.js 18 or newer and npm for the realtime, chat, site, socket and http-ws templates; the dashboard template requires Node.js 22.13+ for native SQLite. Use a currently supported Node.js release in production.

For an unreleased checkout or tarball, first run `npm install --save-exact TARBALL`, replacing `TARBALL` with the absolute path to the same tested Redweb tarball used to generate this app (quote paths containing spaces). This installs the matching package and its published client dependency. Do not substitute an older registry release or `latest`. Published Redweb releases can use the installation command below directly.

```sh
npm install
npm test
npm run dev
```

HTTP starters open at http://localhost:8181; the authenticated dashboard uses http://127.0.0.1:8181/login and requires account provisioning described below. Set the `PORT` environment variable to change the listener.
`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.
`npm run test:coverage` runs the same tests with application coverage mapped back to TypeScript. Reports are written to the ignored `coverage/` directory; this is separate from Redweb library coverage. TypeScript-generated decorator accessors can appear in function counts even when the framework does not call them. The report exposes remaining gaps; it does not certify complete application coverage. Source maps are generated during the build for diagnostics and coverage, but no coverage collector is loaded by `npm start`.

## Development and production

Edit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,
then rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,
HTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible
notice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,
not autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.
The generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.
The refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),
and creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;
custom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.
`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.
Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.

The standalone entrypoint calls `app.run()` on a `defineApp` definition. Importing it opens no listener and installs no process handlers. Redweb owns HTTP and WebSocket startup together, including signal handling and bounded shutdown; no generated `run-app.ts` helper is needed. Configure `startupTimeoutMs` and `shutdownTimeoutMs` on the definition (both default to five seconds). App-wide service classes acquire resources in `onInit(app, signal)` and release them in `onShutdown()`; the dashboard uses this for its auth/database resources. The dashboard's factory configures an independent private workspace but does not start it.

Repeated signals do not bypass cleanup. Failed process-owned cleanup sets a failure exit status and retains a deadline for surviving handles. Explicit `shutdown()` rejects on cleanup failures without terminating its caller. Deadlines cannot preempt synchronous code blocking Node's event loop or arbitrary operations that ignore cancellation. Tests can define an independent application from `{ ...app.options, port: 0, signals: false }` and await `run()`; app-owned state is isolated, but objects deliberately captured by page class closures remain shared unless a new class/room is created.

The shipped lifecycle tests exercise actual processes, HTTP/TCP/WebSocket peers and timers. Linux uses actual OS signals; Windows tests explicitly emit signal events inside the process because killing a Windows child does not exercise graceful POSIX signal delivery. This is not a claim that Windows console/service managers forward the same signals. Deploy with a supervisor that forwards the supported termination signal and allows longer than the configured cleanup deadline.

For public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,
and application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.
Never commit secrets; `.env` is ignored but is not loaded automatically.

`npx --no-install redweb doctor --json` reports configuration problems without changing your files.
## Dependency security

This starter includes an application-root npm override for Express 4's `qs`
dependency, selecting patched `qs@6.16.0`. Keep the override when merging this
starter into an existing application, refresh its lockfile and run `npm audit`.
Overrides in Redweb's own package do not apply to installed consumers. Recheck
upstream Express/body-parser releases before removing this temporary mitigation.


## Exact generated files

These files come from the initializer itself. The tests below run real listeners; they are not illustrative pseudocode. The generated manifest uses the package metadata version; the installation step above pins the matching artifact or release.

### package.json

```json
{
  "name": "redweb-app",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "build": "tsc && node scripts/copy-assets.cjs",
    "start": "node dist/app.js",
    "dev": "nodemon",
    "test": "npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs",
    "test:coverage": "npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs"
  },
  "dependencies": {
    "redweb": "^0.15.0",
    "zod": "^4.4.3"
  },
  "overrides": {
    "express": {
      "qs": "6.16.0"
    }
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "nodemon": "^3.1.11",
    "ws": "^8.21.3",
    "c8": "^10.1.3"
  },
  "nodemonConfig": {
    "env": {
      "REDWEB_DEV_REFRESH": "1"
    },
    "watch": [
      "src",
      "tsconfig.json"
    ],
    "ext": "ts,tsx,css,html,json",
    "exec": "npm run build && npm start || exit 1",
    "delay": 200
  }
}
```

### tsconfig.json

```json
{
  "extends": "redweb/tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ]
}
```

### src/app.tsx

```tsx
import { defineApp, SocketRoute } from 'redweb';
import { match } from './contract';
import { Join, Move, Resume } from './handlers';

export class MatchRoute extends SocketRoute {
    constructor() {
        super({
            path: '/match',
            handlers: [Join, Move, Resume],
            protocol: match.protocol,
            orderedMessages: true,
            sessions: { ttlMs: 30000, maxSessions: 100 },
            heartbeat: { intervalMs: 15000, timeoutMs: 10000 },
            allowDuplicateConnections: true,
            websocketOptions: { maxPayload: 4096 },
            limits: { maxConnections: 100, maxPendingMessages: 32, maxBufferedBytes: 65536 },
        });
    }
}

export const app = defineApp({ sockets: [MatchRoute], port: Number(process.env.PORT ?? 8181) });

if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
```

### src/app.css

```css
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }
body { margin: 0; }
.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }
h1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }
p { color: #bfc1ca; line-height: 1.6; }
button { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }
button:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }
nav { padding: 1rem; } a { color: #ff8795; }
```

### scripts/copy-assets.cjs

```js
const fs = require('node:fs');
const path = require('node:path');

// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.
fs.cpSync('src', 'dist', {
    recursive: true,
    filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),
});
```

### test/network.cjs

```js
const assert = require('node:assert/strict');
const { once } = require('node:events');
const WebSocket = require('ws');
const { defineApp } = require('redweb');
const { app: definition } = require('../dist/app.js');

function createApp(options = {}) {
    return defineApp({ ...definition.options, port: 0, bind: '127.0.0.1', logger: null, signals: false, ...options });
}

async function listen(t, options) {
    const app = createApp(options);
    t.after(() => app.shutdown());
    await app.run();
    return `http://127.0.0.1:${app.server.address().port}`;
}

async function connect(t, url, origin, headers = {}) {
    const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });
    const messages = [];
    socket.on('message', raw => messages.push(JSON.parse(raw.toString())));
    t.after(async () => {
        if (socket.readyState === WebSocket.CLOSED) return;
        const closed = once(socket, 'close');
        // Cleanup must not depend on a peer completing the closing handshake.
        // Tests of graceful disconnect explicitly close and await their sockets.
        socket.terminate();
        await closed;
    });
    await once(socket, 'open');
    return {
        socket,
        send: message => socket.send(JSON.stringify(message)),
        async receive(predicate) {
            const deadline = Date.now() + 3000;
            while (Date.now() < deadline) {
                const index = messages.findIndex(predicate);
                if (index !== -1) return messages.splice(index, 1)[0];
                await new Promise(resolve => setTimeout(resolve, 10));
            }
            assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);
        },
    };
}

async function live(t, origin, headers = {}) {
    const response = await fetch(origin, { headers });
    assert.equal(response.status, 200);
    const document = await response.text();
    const config = JSON.parse(document.match(/id="__redweb_page">([^<]+)</)[1]);
    const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);
    return {
        ...connection,
        document, config,
        patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),
        action: (name, args = [], component) => connection.send({
            v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },
        }),
        state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&
            message.payload.name === name && message.payload.component === component && value(message.payload.value)),
    };
}

module.exports = { createApp, listen, connect, live };
```

### test/app.test.cjs

```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const { listen, connect } = require('./network.cjs');
const { match } = require('../dist/contract.js');

async function rejected(client, type, payload) {
    const closed = once(client.socket, 'close');
    await match.client(client.socket).send(type, payload);
    assert.equal((await client.receive(message => message.type === 'error')).error.code, 'HANDLER_FAILED');
    assert.equal((await closed)[0], 1011);
}

test('join, move and resume use separate validated handlers with isolated server sessions', { timeout: 10000 }, async t => {
    const origin = await listen(t);
    const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
    const first = await connect(t, url, origin);
    const second = await connect(t, url, origin);
    const client = match.client(first.socket);
    await client.send('join', { name: ' Ada ' }, { requestId: 'join-1' });
    const joined = await first.receive(message => message.type === 'state');
    assert.equal(joined.requestId, 'join-1');
    assert.equal(joined.payload.name, 'Ada');
    assert.deepEqual([joined.payload.x, joined.payload.y], [0, 0]);

    const unjoined = await connect(t, url, origin);
    await rejected(unjoined, 'move', { x: 2, y: 3 });
    await match.client(second.socket).send('join', { name: 'Grace' });
    const other = await second.receive(message => message.type === 'state');
    assert.notEqual(other.payload.session, joined.payload.session);

    await client.send('move', { x: 7, y: -3 }, { requestId: 'move-1' });
    assert.deepEqual((await first.receive(message => message.type === 'state')).payload,
        { ...joined.payload, x: 7, y: -3 });
    await assert.rejects(client.send('move', { x: 101, y: 0 }), { code: 'INVALID_PAYLOAD' });
    const closed = once(first.socket, 'close');
    first.socket.close();
    await closed;
    const resumed = await connect(t, url, origin);
    await match.client(resumed.socket).send('resume', { session: joined.payload.session });
    assert.deepEqual((await resumed.receive(message => message.type === 'state')).payload,
        { ...joined.payload, x: 7, y: -3 });

    // Bypass client validation to prove the server independently rejects malformed input.
    const rejectedClosed = once(resumed.socket, 'close');
    resumed.send({ v: match.version, type: 'move', payload: { x: 'wrong', y: 0 } });
    assert.equal((await resumed.receive(message => message.type === 'error')).error.code, 'INVALID_PAYLOAD');
    assert.equal((await rejectedClosed)[0], 1008);
});

test('joined identities cannot join/resume again and unknown sessions fail closed', { timeout: 10000 }, async t => {
    const origin = await listen(t);
    const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
    for (const type of ['join', 'resume']) {
        const client = await connect(t, url, origin);
        await match.client(client.socket).send('join', { name: 'Ada' });
        const joined = await client.receive(message => message.type === 'state');
        await rejected(client, type, type === 'join' ? { name: 'Replacement' } : { session: joined.payload.session });
    }
    const visitor = await connect(t, url, origin);
    await rejected(visitor, 'resume', { session: require('node:crypto').randomUUID() });
});

test('retained disconnected sessions count toward the bounded session capacity', { timeout: 20000 }, async t => {
    const origin = await listen(t);
    const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
    const sessions = new Set();
    for (let index = 0; index < 100; index++) {
        const client = await connect(t, url, origin);
        await match.client(client.socket).send('join', { name: `player-${index}` });
        const joined = await client.receive(message => message.type === 'state');
        sessions.add(joined.payload.session);
        const closed = once(client.socket, 'close');
        client.socket.close();
        await closed;
    }
    assert.equal(sessions.size, 100);
    const overflow = await connect(t, url, origin);
    await rejected(overflow, 'join', { name: 'overflow' });
    // Capacity rejection does not invalidate a previously issued bearer session.
    const resumed = await connect(t, url, origin);
    const session = sessions.values().next().value;
    await match.client(resumed.socket).send('resume', { session });
    assert.equal((await resumed.receive(message => message.type === 'state')).payload.session, session);
});
```

### test/lifecycle.test.cjs

```js
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { spawn } = require('node:child_process');
const { once } = require('node:events');

function execute(t, args, env = process.env) {
    return new Promise((resolve, reject) => {
        const child = spawn(process.execPath, args, { env, windowsHide: true });
        let stdout = '', stderr = '', finished = false;
        const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
        const deadline = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Entrypoint did not exit')); }, 5000);
        t.after(async () => {
            clearTimeout(deadline);
            if (!finished) { child.kill('SIGKILL'); await closed; }
        });
        child.stdout.on('data', data => { stdout += data; });
        child.stderr.on('data', data => { stderr += data; });
        child.once('error', reject);
        child.once('close', (code, signal) => { clearTimeout(deadline); resolve({ code, signal, stdout, stderr }); });
    });
}

// Each generated app is tested in a real process. Framework deadline/failure
// coverage lives with Application itself, not in six copied startup helpers.
for (const mode of ['SIGINT', 'SIGTERM', 'native-close']) {
    test(`import is inert; application owns ${mode} cleanup`, { timeout: 7000 }, async t => {
        const result = await execute(t, ['-e', String.raw`
            const assert = require('node:assert/strict');
            const { once } = require('node:events');
            const { defineApp } = require('redweb');
            const initial = ['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal));
            const source = require('./dist/app');
            assert.deepEqual(['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal)), initial);
            const app = source.app
                ? defineApp({ ...source.app.options, port: 0, bind: '127.0.0.1', logger: null })
                : source.createApp({ port: 0, database: ':memory:' });
            assert.equal(app.server, null);
            (async () => {
                const running = await app.run();
                const response = await fetch('http://127.0.0.1:' + running.server.address().port, { headers: { Connection: 'close' } });
                assert.ok(response.status < 500);
                await response.arrayBuffer();
                const closed = once(running.server, 'close');
                if (process.argv[1] === 'native-close') {
                    running.server.close();
                } else {
                    // Windows kill does not deliver a graceful POSIX signal.
                    if (process.platform === 'win32') process.emit(process.argv[1]);
                    else process.kill(process.pid, process.argv[1]);
                }
                await closed;
                await app.shutdown();
                assert.equal(running.server.listening, false);
                assert.deepEqual(['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal)), initial);
            })().catch(error => { console.error(error); process.exitCode = 1; });
        `, mode]);
        assert.equal(result.code, 0, result.stdout + result.stderr);
        assert.equal(result.signal, null);
    });
}

test('the actual application entrypoint reports an occupied port', { timeout: 7000 }, async t => {
    const net = require('node:net');
    const occupied = net.createServer(socket => socket.destroy());
    const loopback = net.createServer(socket => socket.destroy());
    t.after(async () => {
        for (const server of [occupied, loopback]) await new Promise(resolve => server.close(resolve));
    });
    occupied.listen(0, '0.0.0.0');
    await once(occupied, 'listening');
    // Windows may allow separate wildcard and loopback binds to the same port.
    loopback.listen(occupied.address().port, '127.0.0.1');
    try { await once(loopback, 'listening'); }
    catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
    const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: ':memory:' };
    delete env.DASHBOARD_ORIGIN;
    const result = await execute(t, ['dist/app.js'], env);
    assert.equal(result.code, 1, result.stdout + result.stderr);
    assert.equal(result.signal, null);
    assert.match(result.stderr, /EADDRINUSE/);
});
```

### README.md

````md
# Your Redweb application

Requirements: Node.js 18 or newer and npm for the realtime, chat, site, socket and http-ws templates; the dashboard template requires Node.js 22.13+ for native SQLite. Use a currently supported Node.js release in production.

For an unreleased checkout or tarball, first run `npm install --save-exact TARBALL`, replacing `TARBALL` with the absolute path to the same tested Redweb tarball used to generate this app (quote paths containing spaces). This installs the matching package and its published client dependency. Do not substitute an older registry release or `latest`. Published Redweb releases can use the installation command below directly.

```sh
npm install
npm test
npm run dev
```

HTTP starters open at http://localhost:8181; the authenticated dashboard uses http://127.0.0.1:8181/login and requires account provisioning described below. Set the `PORT` environment variable to change the listener.
`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.
`npm run test:coverage` runs the same tests with application coverage mapped back to TypeScript. Reports are written to the ignored `coverage/` directory; this is separate from Redweb library coverage. TypeScript-generated decorator accessors can appear in function counts even when the framework does not call them. The report exposes remaining gaps; it does not certify complete application coverage. Source maps are generated during the build for diagnostics and coverage, but no coverage collector is loaded by `npm start`.

## Development and production

Edit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,
then rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,
HTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible
notice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,
not autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.
The generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.
The refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),
and creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;
custom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.
`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.
Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.

The standalone entrypoint calls `app.run()` on a `defineApp` definition. Importing it opens no listener and installs no process handlers. Redweb owns HTTP and WebSocket startup together, including signal handling and bounded shutdown; no generated `run-app.ts` helper is needed. Configure `startupTimeoutMs` and `shutdownTimeoutMs` on the definition (both default to five seconds). App-wide service classes acquire resources in `onInit(app, signal)` and release them in `onShutdown()`; the dashboard uses this for its auth/database resources. The dashboard's factory configures an independent private workspace but does not start it.

Repeated signals do not bypass cleanup. Failed process-owned cleanup sets a failure exit status and retains a deadline for surviving handles. Explicit `shutdown()` rejects on cleanup failures without terminating its caller. Deadlines cannot preempt synchronous code blocking Node's event loop or arbitrary operations that ignore cancellation. Tests can define an independent application from `{ ...app.options, port: 0, signals: false }` and await `run()`; app-owned state is isolated, but objects deliberately captured by page class closures remain shared unless a new class/room is created.

The shipped lifecycle tests exercise actual processes, HTTP/TCP/WebSocket peers and timers. Linux uses actual OS signals; Windows tests explicitly emit signal events inside the process because killing a Windows child does not exercise graceful POSIX signal delivery. This is not a claim that Windows console/service managers forward the same signals. Deploy with a supervisor that forwards the supported termination signal and allows longer than the configured cleanup deadline.

For public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,
and application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.
Never commit secrets; `.env` is ignored but is not loaded automatically.

`npx --no-install redweb doctor --json` reports configuration problems without changing your files.
## Dependency security

This starter includes an application-root npm override for Express 4's `qs`
dependency, selecting patched `qs@6.16.0`. Keep the override when merging this
starter into an existing application, refresh its lockfile and run `npm audit`.
Overrides in Redweb's own package do not apply to installed consumers. Recheck
upstream Express/body-parser releases before removing this temporary mitigation.

## Socket starter

This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.
The URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.
There are no socket decorators or secondary `message.action` dispatchers.

Read `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define
the wire contract, route/server configuration, and join/move/resume handlers.
The displayed handlers depend on those other generated files; initialize the
complete recipe first. Session ownership is separate from room fan-out.

`src/contract.ts` declares the wire payloads once using Zod, a Standard Schema validator. Both the server and a bundled browser/Node client can import it for runtime validation and inferred TypeScript types:

```ts
import { match } from './contract';

const socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');
const client = match.client(socket);
socket.addEventListener('open', () => {
    client.send('join', { name: 'Ada' }).catch(console.error);
});
socket.addEventListener('message', async event => {
    try { console.log(await client.parse(event)); }
    catch (error) { console.error(error); }
});
```

The initial `state` response contains `{ session, name, x, y }`. Send `move` with `{ x: 7, y: -3 }` to change your server-owned position; send `resume` with `{ session }` on a new connection to recover it. Messages are processed in order on each connection. The client wrapper validates messages; it does not open or reconnect the WebSocket for you. Use WSS outside local development.

`npm test` opens real sockets and checks independent players, server-side moves, disconnect/resume, client validation, and server rejection of a malformed raw message. The starter also passes with the original source directory unavailable after building.

### Boundaries

- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.
- The random session ID is a bearer credential. Anyone holding it can resume that player and replace its previous connection. Keep it private; do not broadcast the `state` response to other players. Add account authentication and bind sessions to authenticated identity for production.
- Sessions are in memory, local to this server, capped at 100, and expire 30 seconds after disconnect. Server restart loses them. This is not persistent storage or a multi-instance session system.
- Calling `join` or `resume` while already joined is rejected. Movement before joining and unknown/expired sessions are rejected. Application failures currently use the protocol's sanitized `HANDLER_FAILED` error.
- Invalid contract payloads produce `INVALID_PAYLOAD` and close that connection with code 1008. The contract's `state` type is server output; it has no client-callable handler.
- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.
````

### .gitignore

```text
node_modules/
dist/
coverage/
.env
data/
*.sqlite
*.sqlite-wal
*.sqlite-shm
```

### src/contract.ts

```ts
import { defineSocketContract } from 'redweb/contract';
import { z } from 'zod';

const position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };

// Share this module with a browser or Node client. It imports no server application code.
export const match = defineSocketContract('1', {
    join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),
    move: z.object(position).strict(),
    resume: z.object({ session: z.string().uuid() }).strict(),
    state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),
});
```

### 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 });
});
```
