Chat: complete application

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

Chat starter

src/chatroom.tsx is the canonical Redweb chat component example, included directly rather than a second implementation. The component stores ordinary message/member data and renders it with reactive TSX and stable list keys; no HTML-valued state or explicit binding names are needed. Visitors choose a name once, chat in a shared room, and see live presence. Disconnect removes online presence; the page session retains its identity briefly for reconnect, then disposal releases it.

Display names are not authenticated identities. History is bounded to 100 messages in memory, not a persistent database. Use an application-owned persistence service before promising history across restarts or multiple server processes.

@action({ input: chatInputs.join }) validates and normalizes the form before join runs; ActionInput<typeof chatInputs.join> supplies its TypeScript input type. The same pattern handles messages. The starter installs Zod as an application dependency; Redweb itself remains validator-independent. Invalid field values (including repeated names represented as arrays) receive ACTION_INVALID_INPUT, keep the draft, and show Redweb's built-in form feedback. Name collisions remain a room rule with their own friendly message. Calling a component method directly from server code bypasses transport validation: pass schema-parsed input. The schemas reject ordinary unexpected fields; Zod may discard reserved object keys such as __proto__. Only the parsed name or message reaches the corresponding action.

When using the packed examples/live-html/chatroom.js directly instead of the generated starter, install its application validator with npm install zod. A cloned-repository development install already includes it. Redweb's core and the counter example do not require Zod.

Setup and acceptance

sh
npx --yes redweb@0.13.3 init my-chat --template chat
cd my-chat
npm install --save-exact redweb@0.13.3
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 the shared runApp(createApp) helper. Importing either module starts no listener and installs no process handlers. On SIGINT/SIGTERM, a listener error, or native listener closure, the helper calls application shutdown once. Repeated signals do not bypass cleanup. The five-second outer deadline covers the whole application, including database/worker cleanup after HTTP closes; customize it with the helper's second argument if necessary. Cleanup must resolve only after resources are released. A failed cleanup sets a failure exit status and retains a deadline for any surviving handles; the helper never resets an existing failure status. If cleanup does not finish in time, the entrypoint terminates the process with a failure status. This cannot preempt synchronous code blocking Node's event loop and does not make in-memory state durable. Factory functions remain responsible for releasing partially constructed resources before throwing.

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.

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/run-app.test.cjs",
    "test:coverage": "npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/run-app.test.cjs"
  },
  "dependencies": {
    "redweb": "^0.13.3",
    "zod": "^4.4.3"
  },
  "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 { start, type LiveHtmlStartOptions } from 'redweb';
import { createChatroomPage } from './chatroom';
import { runApp } from './run-app';

export function createApp(options: LiveHtmlStartOptions = {}) {
    return start(createChatroomPage(), { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });
}

if (require.main === module) runApp(createApp);

src/run-app.ts

ts
import type { Server } from 'node:http';

interface Application { server: Server; shutdown(): Promise<void>; }

/** Entry-point policy only: importing a recipe never installs process handlers. */
export function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {
    if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {
        throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');
    }
    const fail = (message: string) => {
        console.error(message);
        if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;
    };
    let app: T;
    try { app = createApp(); }
    catch { fail('Application startup failed.'); return undefined; }

    let closing: Promise<void> | undefined;
    const stop = () => {
        if (!closing) {
            let failed = false;
            const deadline = setTimeout(() => {
                fail('Application cleanup exceeded its deadline; terminating the process.');
                process.exit();
            }, shutdownTimeoutMs);
            closing = Promise.resolve().then(() => app.shutdown()).catch(() => {
                failed = true;
                fail('Application cleanup failed.');
            }).finally(() => {
                // Failed cleanup may leave live handles. Permit natural exit if none
                // remain, but still force a bounded exit when resources were leaked.
                if (failed) { deadline.unref(); return; }
                clearTimeout(deadline);
                process.off('SIGINT', stop);
                process.off('SIGTERM', stop);
                app.server.off('error', onError);
                app.server.off('close', stop);
            });
        }
        return closing;
    };
    const onError = () => { fail('Application listener failed.'); void stop(); };
    // Persistent handlers keep repeated signals from bypassing active cleanup.
    process.on('SIGINT', stop);
    process.on('SIGTERM', stop);
    app.server.on('error', onError);
    // Native close can precede database/worker cleanup: it starts, never ends, shutdown.
    app.server.once('close', stop);
    return app;
}

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 { createApp } = require('../dist/app.js');

async function listen(t) {
    const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });
    t.after(() => app.shutdown());
    if (!app.server.listening) await once(app.server, 'listening');
    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 = { listen, connect, live };

test/app.test.cjs

js
const test = require('node:test');
const { once } = require('node:events');
const assert = require('node:assert/strict');
const { listen, live, connect } = require('./network.cjs');
const { createChatroomPage, chatInputs } = require('../dist/chatroom.js');

test('the standalone canonical chat reports an occupied default port', { timeout: 10000 }, async t => {
    const net = require('node:net');
    const { spawnSync } = require('node:child_process');
    const occupied = net.createServer(socket => socket.destroy());
    t.after(() => new Promise(resolve => occupied.close(resolve)));
    occupied.listen(8080, '0.0.0.0');
    try { await once(occupied, 'listening'); }
    catch (error) { assert.equal(error.code, 'EADDRINUSE'); } // An existing listener is left untouched.
    const result = spawnSync(process.execPath, ['dist/chatroom.js'], {
        encoding: 'utf8', timeout: 5000, windowsHide: true,
    });
    assert.equal(result.error, undefined);
    assert.equal(result.status, 1);
    assert.match(result.stderr, /EADDRINUSE/);
});

test('members join once, exchange messages, and leave presence on disconnect', { timeout: 10000 }, async t => {
    const origin = await listen(t);
    const alice = await live(t, origin);
    const bob = await live(t, origin);
    alice.action('join', [{ name: 'Alice' }], 'chat');
    bob.action('join', [{ name: 'Bob' }], 'chat');
    await alice.patch(patch => patch.html.includes('Online · 2'));
    await bob.patch(patch => patch.html.includes('Online · 2'));
    alice.action('send', [{ message: 'Hello <friends>' }], 'chat');
    await bob.patch(patch => patch.html.includes('Hello &lt;friends&gt;'));
    const closed = once(alice.socket, 'close');
    alice.socket.close();
    await closed;
    await bob.patch(patch => patch.html.includes('Online · 1'));
});

test('identities stay reserved across reconnects and are released by leaving', { timeout: 10000 }, async t => {
    const origin = await listen(t);
    const alice = await live(t, origin);
    const visitor = await live(t, origin);
    alice.action('join', [{ name: ' Alice ' }], 'chat');
    await alice.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Alice'));
    visitor.action('join', [{ name: 'ALICE' }], 'chat');
    await visitor.patch(patch => patch.html.includes('already in use'));
    const closed = once(alice.socket, 'close');
    alice.socket.close();
    await closed;
    visitor.send({ v: visitor.config.version, type: 'redweb:html', requestId: 'reserved-name', payload: { kind: 'action', name: 'join', args: [{ name: 'ALICE' }], component: 'chat' } });
    assert.equal((await visitor.receive(message => message.requestId === 'reserved-name')).payload, false);
    const { config } = alice;
    const resumed = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin);
    await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Online · 1')));
    resumed.send({ v: config.version, type: 'redweb:html', payload: { kind: 'action', name: 'leave', args: [], component: 'chat' } });
    await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Join the chatroom')));
    visitor.action('join', [{ name: 'Alice' }], 'chat');
    await visitor.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Online · 1'));
});

test('room units bound history/presence, isolate rooms, and make repeated lifecycle calls harmless', () => {
    const Page = createChatroomPage();
    const alice = new Page().chat;
    const bob = new Page().chat;
    const isolated = new (createChatroomPage())().chat;
    assert.equal(alice.send({ message: 'not joined' }), false);
    alice.connected();
    assert.equal(alice.join(chatInputs.join.parse({ name: ' Alice ' })), true);
    assert.equal(alice.join({ name: 'Replacement' }), false);
    assert.equal(bob.join({ name: 'ALICE' }), false);
    assert.match(bob.render().toString(), /already in use/);
    assert.equal(bob.join({ name: 'Bob' }), true);
    assert.equal(isolated.join({ name: 'Alice' }), true);
    assert.match(alice.render().toString(), /No messages yet/);
    for (let index = 0; index < 101; index++) assert.equal(alice.send({ message: `message-${index}` }), true);
    assert.equal(bob.messages.length, 100);
    assert.deepEqual(bob.messages[0], { id: 2, sender: 'Alice', text: 'message-1' });
    assert.equal(bob.messages.at(-1).id, 101);
    assert.equal(isolated.messages.length, 0);
    assert.match(bob.render().toString(), /message-100/);
    alice.disconnected();
    alice.disconnected();
    assert.deepEqual(bob.members, ['Bob']);
    assert.equal(alice.send({ message: 'offline' }), false);
    alice.connected();
    assert.deepEqual(bob.members, ['Bob', 'Alice']);
    const visitors = Array.from({ length: 100 }, (_, index) => {
        const member = new Page().chat;
        assert.equal(member.join({ name: `visitor-${index}` }), true);
        return member;
    });
    const rendered = alice.render().toString();
    assert.match(rendered, /Online · 102/);
    assert.match(rendered, /\+2 more/);
    assert.doesNotMatch(rendered, /<li[^>]*>visitor-99<\/li>/);
    visitors.forEach(member => member.disposed());
    alice.leave();
    alice.disposed();
    alice.disposed();
    assert.deepEqual([alice.displayName, alice.feedback, alice.messages, alice.members], ['', '', [], []]);
    assert.deepEqual(bob.members, ['Bob']);
    assert.match(alice.render().toString(), /Join the chatroom/);
    bob.disposed();
    isolated.disposed();
});

test/run-app.test.cjs

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

// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.
// Windows cannot deliver POSIX signals through child.kill, so only that platform
// explicitly emits the signal event inside the child. Linux uses real OS signals.
const fixture = String.raw`
const assert = require('node:assert/strict');
const http = require('node:http');
const net = require('node:net');
const { once } = require('node:events');
const WebSocket = require('ws');
const mode = process.argv[1];
const signals = ['SIGINT', 'SIGTERM'];
const initial = signals.map(signal => process.listenerCount(signal));
const { runApp } = require('./dist/run-app.js');
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
require('./dist/app.js');
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
let cleanups = 0;
process.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));
const signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);
if (mode === 'invalid') {
    for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);
} else if (mode === 'factory') {
    assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);
} else {
    if (mode === 'preserve') process.exitCode = '7';
    const server = http.createServer((_request, response) => response.end('ready'));
    const wss = new WebSocket.Server({ server });
    wss.on('error', () => {}); // The HTTP listener error is owned by runApp.
    const peers = new Set();
    server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });
    const close = async () => {
        for (const peer of peers) peer.destroy();
        for (const peer of wss.clients) peer.terminate();
        await new Promise(resolve => wss.close(resolve));
        await new Promise(resolve => server.close(resolve));
    };
    const app = runApp(() => ({ server, shutdown() {
        cleanups++;
        console.log('cleanup-started');
        if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }
        if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));
        return close().then(async () => {
            if (mode === 'hung') return new Promise(() => {});
            if (mode === 'reject') throw Error('private cleanup detail');
            if (mode === 'repeat') {
                signal('SIGINT'); signal('SIGTERM');
                server.emit('error', Error('private listener detail'));
            }
            await new Promise(resolve => setTimeout(resolve, 20));
        });
    } }), 200);
    assert.equal(app.server, server);
    (async () => {
        if (mode === 'occupied') {
            const other = http.createServer();
            await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));
            server.once('error', () => other.close());
            server.listen(other.address().port, '127.0.0.1');
            return;
        }
        await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
        const port = server.address().port;
        const response = await fetch('http://127.0.0.1:' + port);
        assert.equal(await response.text(), 'ready');
        const peer = net.connect(port, '127.0.0.1');
        peer.on('error', () => {});
        await once(peer, 'connect');
        peer.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
        const socket = new WebSocket('ws://127.0.0.1:' + port);
        socket.on('error', () => {});
        await once(socket, 'open');
        if (mode === 'native-close') {
            for (const connection of peers) connection.destroy();
            server.close();
            return;
        }
        // A partial HTTP peer otherwise prevents native close; application cleanup
        // begins via the signal and the later native close must not end its timer.
        signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');
    })().catch(error => { console.error(error); process.exit(99); });
}
`;

function execute(mode, t, args = ['-e', fixture, mode], env = process.env) {
    return new Promise((resolve, reject) => {
        const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });
        let stdout = '', stderr = '';
        let timedOut = false, finished = false;
        const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
        const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 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);
            if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\n${stdout}\n${stderr}`));
            else resolve({ code, signal, stdout, stderr });
        });
    });
}

test('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {
    const net = require('node:net');
    const { once } = require('node:events');
    const fs = require('node:fs');
    const path = require('node:path');
    const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));
    const occupied = net.createServer(socket => socket.destroy());
    const loopback = net.createServer(socket => socket.destroy());
    let failure;
    try {
        occupied.listen(0, '0.0.0.0');
        await once(occupied, 'listening');
        // Windows permits distinct wildcard/loopback binds on the same port.
        // Hold both addresses; Unix may already reject the second bind.
        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: path.join(directory, 'test.sqlite') };
        delete env.DASHBOARD_ORIGIN;
        const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);
        assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
        assert.equal(result.signal, null);
        assert.match(result.stderr, /Application listener failed/);
    } catch (error) { failure = error; }
    const cleanup = await Promise.allSettled([
        ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>
            error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),
        fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
    ]);
    const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];
    if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');
});

for (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {
    test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {
        const result = await execute(mode, t);
        const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;
        assert.equal(result.code, expected, `${result.stdout}\n${result.stderr}`);
        assert.equal(result.signal, null);
        assert.doesNotMatch(result.stderr, /private .* detail/);
        const noApp = ['invalid', 'factory'].includes(mode);
        assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);
        if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);
        if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {
            const snapshot = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1));
            assert.deepEqual(snapshot.signals, snapshot.initial);
        }
    });
}

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 the shared `runApp(createApp)` helper. Importing either module starts no listener and installs no process handlers. On SIGINT/SIGTERM, a listener error, or native listener closure, the helper calls application shutdown once. Repeated signals do not bypass cleanup. The five-second outer deadline covers the whole application, including database/worker cleanup after HTTP closes; customize it with the helper's second argument if necessary. Cleanup must resolve only after resources are released. A failed cleanup sets a failure exit status and retains a deadline for any surviving handles; the helper never resets an existing failure status. If cleanup does not finish in time, the entrypoint terminates the process with a failure status. This cannot preempt synchronous code blocking Node's event loop and does not make in-memory state durable. Factory functions remain responsible for releasing partially constructed resources before throwing.

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.

## Chat starter

`src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.
The component stores ordinary message/member data and renders it with reactive TSX and stable list keys; no HTML-valued state or explicit binding names are needed.
Visitors choose a name once, chat in a shared room, and see live presence. Disconnect removes online presence;
the page session retains its identity briefly for reconnect, then disposal releases it.

Display names are not authenticated identities. History is bounded to 100 messages in memory, not a persistent database.
Use an application-owned persistence service before promising history across restarts or multiple server processes.

`@action({ input: chatInputs.join })` validates and normalizes the form before `join` runs;
`ActionInput<typeof chatInputs.join>` supplies its TypeScript input type. The same pattern handles messages.
The starter installs Zod as an application dependency; Redweb itself remains validator-independent.
Invalid field values (including repeated names represented as arrays) receive `ACTION_INVALID_INPUT`, keep the draft,
and show Redweb's built-in form feedback. Name collisions remain a room rule with their own friendly message.
Calling a component method directly from server code bypasses transport validation: pass schema-parsed input.
The schemas reject ordinary unexpected fields; Zod may discard reserved object keys such as `__proto__`.
Only the parsed `name` or `message` reaches the corresponding action.

When using the packed `examples/live-html/chatroom.js` directly instead of the generated starter,
install its application validator with `npm install zod`. A cloned-repository development install already
includes it. Redweb's core and the counter example do not require Zod.

.gitignore

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

src/chatroom.tsx

tsx
import { action, component, page, start, state, type ActionInput } from 'redweb';
import { z } from 'zod';

const MAX_VISIBLE_MEMBERS = 100;
const visibleText = (maximum: number) => z.string()
    .transform(value => value.normalize('NFKC').trim())
    .pipe(z.string().min(1).max(maximum).regex(/^[^\p{Cc}\p{Cf}]+$/u));
export const chatInputs = {
    join: z.object({ name: visibleText(40) }).strict(),
    send: z.object({ message: visibleText(500) }).strict(),
};

interface StoredMessage { id: number; sender: string; text: string; }
interface RoomParticipant {
    readonly displayName: string;
    updateMessages(messages: readonly StoredMessage[]): void;
    updatePresence(members: readonly string[]): void;
}

class ChatRoom {
    private history: readonly StoredMessage[] = [];
    private nextMessageId = 0;
    private readonly participants = new Set<RoomParticipant>();
    private readonly online = new Set<RoomParticipant>();

    join(participant: RoomParticipant) {
        const name = participant.displayName.toLocaleLowerCase();
        if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) return false;
        this.participants.add(participant);
        this.online.add(participant);
        participant.updateMessages(this.history);
        this.publishPresence();
        return true;
    }

    disconnect(participant: RoomParticipant) {
        if (this.online.delete(participant)) this.publishPresence();
    }

    leave(participant: RoomParticipant) {
        this.online.delete(participant);
        if (this.participants.delete(participant)) this.publishPresence();
    }

    send(participant: RoomParticipant, text: string) {
        if (!this.online.has(participant)) return false;
        this.history = [...this.history, { id: ++this.nextMessageId, sender: participant.displayName, text }].slice(-100);
        for (const member of this.participants) member.updateMessages(this.history);
        return true;
    }

    private publishPresence() {
        const members = [...this.online].map(participant => participant.displayName);
        for (const participant of this.participants) participant.updatePresence(members);
    }
}

@component()
export class ChatroomComponent implements RoomParticipant {
    @state() displayName = '';
    @state() feedback = '';
    @state() messages: readonly StoredMessage[] = [];
    @state() members: readonly string[] = [];

    constructor(private readonly room: ChatRoom) {}

    connected() { if (this.displayName) this.room.join(this); }
    disconnected() { this.room.disconnect(this); }
    disposed() { this.room.leave(this); }

    @action({ input: chatInputs.join })
    join({ name }: ActionInput<typeof chatInputs.join>) {
        if (this.displayName) return false;
        this.displayName = name;
        if (!this.room.join(this)) {
            this.displayName = '';
            this.feedback = 'That display name is already in use.';
            return false;
        }
        this.feedback = '';
        return true;
    }

    @action({ input: chatInputs.send })
    send({ message }: ActionInput<typeof chatInputs.send>) {
        return this.room.send(this, message);
    }

    @action()
    leave() {
        this.room.leave(this);
        this.displayName = '';
        this.feedback = '';
        this.messages = [];
        this.members = [];
    }

    updateMessages(messages: readonly StoredMessage[]) { this.messages = messages; }
    updatePresence(members: readonly string[]) { this.members = members; }

    render() {
        return <section class="chatroom">{this.displayName ? this.roomScreen() : this.joinScreen()}</section>;
    }

    private joinScreen() {
        return (
            <section class="join-panel">
                <p class="eyebrow">Live room</p>
                <h1>Join the chatroom</h1>
                <p>Choose a name once, then chat in realtime with everyone currently in the room.</p>
                {this.feedback && <p class="form-error" role="alert">{this.feedback}</p>}
                <form rw-submit="join" class="join-form">
                    <label for="display-name">Display name</label>
                    <div class="input-row">
                        <input id="display-name" name="name" maxlength="40" autocomplete="nickname" required autofocus />
                        <button type="submit">Join room</button>
                    </div>
                </form>
            </section>
        );
    }

    private roomScreen() {
        const remaining = this.members.length - MAX_VISIBLE_MEMBERS;
        return (
            <div class="room-layout">
                <section class="conversation">
                    <header class="room-header">
                        <div><p class="eyebrow">Connected as</p><h1>{this.displayName}</h1></div>
                        <button type="button" class="quiet-button" rw-click="leave">Leave</button>
                    </header>
                    <ol class="message-list" aria-live="polite">
                        {this.messages.length ? this.messages.map(entry => (
                            <li key={entry.id}><strong>{entry.sender}</strong><p>{entry.text}</p></li>
                        )) : <li class="empty-message">No messages yet. Say hello.</li>}
                    </ol>
                    <form rw-submit="send" class="composer">
                        <label class="sr-only" for="chat-message">Message</label>
                        <input id="chat-message" name="message" maxlength="500" autocomplete="off" placeholder="Message the room…" required autofocus />
                        <button type="submit">Send</button>
                    </form>
                </section>
                <aside class="presence" aria-label="People in the room">
                    <p class="eyebrow">Online · {this.members.length}</p>
                    <ul>
                        {this.members.slice(0, MAX_VISIBLE_MEMBERS).map(member => <li key={member}>{member}</li>)}
                        {remaining > 0 && <li class="more-members">+{remaining} more</li>}
                    </ul>
                </aside>
            </div>
        );
    }
}

export function createChatroomPage() {
    const room = new ChatRoom();

    @page('/', { css: 'chatroom.css' })
    class ChatroomPage {
        chat = new ChatroomComponent(room);
        render() { return <main>{this.chat}</main>; }
    }

    return ChatroomPage;
}

if (require.main === module) start(createChatroomPage(), { port: 8080 });

src/chatroom.css

css
:root {
    color-scheme: dark;
    font-family: Inter, ui-sans-serif, system-ui, sans-serif;
    background: #07111f;
    color: #e5eef9;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    min-height: 100vh;
    background: radial-gradient(circle at top, #15304b 0, #07111f 45rem);
}

main {
    width: min(68rem, calc(100% - 2rem));
    margin: 0 auto;
    padding: 3rem 0;
}

.chatroom,
.join-panel,
.conversation,
.presence {
    border: 1px solid #27435e;
    border-radius: 1rem;
    background: rgba(9, 24, 40, .92);
    box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .28);
}

.chatroom { overflow: hidden; }

.join-panel {
    max-width: 38rem;
    margin: 8vh auto;
    padding: 2.5rem;
}

.join-panel h1,
.room-header h1 { margin: .2rem 0 .75rem; }

.eyebrow {
    margin: 0;
    color: #67e8f9;
    font-size: .75rem;
    font-weight: 800;
    letter-spacing: .12em;
    text-transform: uppercase;
}

.join-form { margin-top: 2rem; }
.form-error { color: #fda4af; font-weight: 700; }

label { display: block; margin-bottom: .5rem; font-weight: 700; }

.input-row,
.composer { display: flex; gap: .75rem; }

input,
button { font: inherit; }

input {
    min-width: 0;
    flex: 1;
    padding: .8rem 1rem;
    border: 1px solid #365570;
    border-radius: .55rem;
    background: #07111f;
    color: inherit;
}

button {
    padding: .8rem 1.1rem;
    border: 0;
    border-radius: .55rem;
    background: #22d3ee;
    color: #083344;
    cursor: pointer;
    font-weight: 800;
}

.room-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 15rem;
    min-height: 38rem;
}

.conversation,
.presence { border: 0; border-radius: 0; box-shadow: none; }

.conversation { display: grid; grid-template-rows: auto 1fr auto; }

.room-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.25rem 1.5rem;
    border-bottom: 1px solid #27435e;
}

.quiet-button { background: #1b3349; color: #d8e8f6; }

.message-list {
    display: flex;
    flex-direction: column;
    gap: .75rem;
    margin: 0;
    padding: 1.5rem;
    list-style: none;
    overflow-wrap: anywhere;
}

.message-list li:not(.empty-message) {
    max-width: 80%;
    padding: .8rem 1rem;
    border-radius: .75rem;
    background: #142b40;
}

.message-list strong { color: #67e8f9; }
.message-list p { margin: .25rem 0 0; }
.empty-message { margin: auto; color: #8ca3b8; }

.composer { padding: 1rem 1.5rem 1.5rem; }

.presence {
    padding: 1.5rem;
    border-left: 1px solid #27435e;
    background: rgba(5, 17, 29, .72);
}

.presence ul { padding: 0; list-style: none; }
.presence li { padding: .45rem 0; }
.presence li::before { content: '●'; margin-right: .5rem; color: #4ade80; }
.presence .more-members { color: #8ca3b8; }
.presence .more-members::before { content: ''; margin: 0; }

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

@media (max-width: 42rem) {
    main { padding: 1rem 0; }
    .room-layout { grid-template-columns: 1fr; }
    .presence { border-top: 1px solid #27435e; border-left: 0; }
    .input-row { flex-direction: column; }
}