redweb 0.13.1 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +286 -290
- package/docs/AGENT_READY_ACCEPTANCE.md +4 -0
- package/docs/CLI.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LIVE_HTML.md +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/RELEASE_TRUST.md +10 -6
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +2 -2
- package/docs/generated.json +2154 -2154
- package/docs/releases/0.13.2.json +2154 -0
- package/package.json +2 -1
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"packageVersion": "0.13.2",
|
|
4
|
+
"channel": "0.13.2",
|
|
5
|
+
"basePath": "/docs/reference/0.13.2",
|
|
6
|
+
"llms": "# Redweb\n\n> Server-rendered TypeScript/TSX sites with server-owned state/actions and routed WebSockets on Node.js.\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nTSX is not React. State is assignment-driven. Shared memory is not durable storage. Socket routes select services; message types select handlers. Applications own identity, authorization, persistence, and delivery reconciliation.\n\n## Guides and complete recipes\n\n- [Choose a starter and build a working app](/docs/reference/0.13.2/getting-started.md): Requirements, fit, development, tests, and production boundaries.\n\n- [Build a private realtime dashboard](/docs/reference/0.13.2/guides/realtime-dashboard.md): Persistent SQLite cards, account-private updates and sign-out across tabs, with explicit single-process limits.\n\n- [Render JSX without React](/docs/reference/0.13.2/guides/jsx-without-react.md): TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.\n\n- [Build a chatroom with live presence](/docs/reference/0.13.2/guides/chatroom.md): Reusable server-side components, validated forms and disconnect-aware presence, without custom browser socket glue.\n\n- [Share typed WebSocket contracts](/docs/reference/0.13.2/guides/typed-websockets.md): One match route, separate join/move/resume handlers and validated client/server payloads from the same schema.\n\n- [Serve HTTP and WebSockets on one port](/docs/reference/0.13.2/guides/http-websocket.md): An Express endpoint and raw socket route share one listener with one explicit shutdown owner.\n\n- [Upgrade an existing Redweb application](/docs/reference/0.13.2/migration.md): Historical socket defaults, asynchronous ownership, HTML migration and version-matched verification.\n\n- [Initialization and diagnostics](/docs/reference/0.13.2/cli.md): Noninteractive setup, safe existing-project adoption, and structured repair guidance.\n\n- [Develop with browser refresh and safe inspection](/docs/reference/0.13.2/development.md): Loopback-only refresh with an explicit edit guard, plus bounded metadata and reactive update inspection without application values.\n\n- [Understand runtime failures before retrying](/docs/reference/0.13.2/runtime-diagnostics.md): Safe authentication, authorization, validation and application errors, with actual retry and cancellation guarantees.\n\n- [Optional read-only agent documentation access](/docs/reference/0.13.2/agent-access.md): Configure local MCP search and exact recipe retrieval without adding dependencies to Redweb servers.\n\n- [Pages, components, state, actions, and CSS](/docs/reference/0.13.2/live-html.md): Server-side TSX, automatic updates, keyed lists, lifecycle, and static export.\n\n- [Typed socket routes and handlers](/docs/reference/0.13.2/socket-contracts.md): Shared validation, client/server types, protocol errors, and join/move/resume.\n\n- [Private rooms and shared request identity](/docs/reference/0.13.2/room-authorization.md): Explicit entry policies, bounded authorization, trusted context, publication and revocation.\n\n- [Deploy and operate socket services](/docs/reference/0.13.2/operations.md): Readiness, shutdown, capacity, reconnection, and distributed boundaries.\n\n- [Production guarantees and limits](/docs/reference/0.13.2/production-contract.md): Resource ownership, delivery semantics, compatibility, and release gates.\n\n- [Recorded verification evidence](/docs/reference/0.13.2/verification.md): Historical measurements and their exact scope; not proof of a newer release.\n\n- [Release status](/docs/reference/0.13.2/release-status.md): Acceptance checklist, completed increments, and remaining publication or deployment boundaries.\n\n- [Choose and verify a release](/docs/reference/0.13.2/release-trust.md): Maintained runtimes, compiler/browser verification boundaries, pinned packages, registry signatures, provenance and support limits.\n\n- [Realtime starter](/docs/reference/0.13.2/recipes/realtime.md): `CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.\n\n- [Chat starter](/docs/reference/0.13.2/recipes/chat.md): `src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.\n\n- [Site starter](/docs/reference/0.13.2/recipes/site.md): `defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title.\n\n- [Socket starter](/docs/reference/0.13.2/recipes/socket.md): This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.\n\n- [Dashboard starter](/docs/reference/0.13.2/recipes/dashboard.md): 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.\n\n- [Http-ws starter](/docs/reference/0.13.2/recipes/http-ws.md): 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.\n\n- [One identity for a page and a protected room](/docs/reference/0.13.2/examples/room-access.md): A complete local demonstration of shared authentication, decorator-first server HTML and explicitly authorized raw socket room entry.\n\n- [HTTP and WebSockets on one listener](/docs/reference/0.13.2/examples/shared-server.md): Build the Express side without binding, attach route classes to the same Node server, and explicitly give the socket service responsibility for listening and cleanup.\n\n- [Server state and reusable TSX components](/docs/reference/0.13.2/examples/live-html.md): Ordinary TSX expressions read server-owned state and update automatically after an action; no repeated binding names or browser component runtime.\n\n- [A complete site with shared defaults](/docs/reference/0.13.2/examples/static-site.md): Define metadata, layout, CSS, caching, and asset export once, then keep each page focused on its content.\n\n- [JSON routing, broadcast, and binary frames](/docs/reference/0.13.2/examples/handlers.md): Text messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.\n\n- [Bound admission, work, and slow peers](/docs/reference/0.13.2/examples/protected-route.md): Production controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.\n\n- [Match handlers and resumable ownership](/docs/reference/0.13.2/examples/rooms-sessions.md): 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.\n\n- [Fixed-step work without overlapping ticks](/docs/reference/0.13.2/examples/fixed-step.md): FixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.\n\n- [Versioned envelopes and the dependency-free client](/docs/reference/0.13.2/examples/protocol.md): Negotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.\n\n- [Bring your own broker adapter](/docs/reference/0.13.2/examples/distribution.md): Redweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.\n\n- [Readiness first, then bounded shutdown](/docs/reference/0.13.2/examples/draining.md): Stop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.\n\n- [HttpServer](/docs/reference/0.13.2/api/httpserver.md): Wraps Express with sensible defaults (JSON body parsing, CORS, and static asset folders) and starts listening immediately unless `listen: false` is supplied. You get the underlying Express instance back via `app`.\n\n- [BaseHttpServer](/docs/reference/0.13.2/api/basehttpserver.md): Public Express app builder used by HttpServer and HttpsServer. Use it for advanced composition when you want Redweb middleware, static files, and services without any listener behavior.\n\n- [HttpsServer](/docs/reference/0.13.2/api/httpsserver.md): TLS-enabled variant of `HttpServer`. Accepts `ssl.key` and `ssl.cert` file paths, wraps them in an https server, and bootstraps the same middleware pipeline.\n\n- [SocketServer](/docs/reference/0.13.2/api/socketserver.md): HTTP-upgrade WebSocket server on top of `ws`. Builds and listens on its own HTTP server by default; if you pass a Node `server`, it attaches upgrade handling and leaves `.listen()` to you unless `listen: true` is explicit.\n\n- [SecureSocketServer](/docs/reference/0.13.2/api/securesocketserver.md): HTTPS + WebSocket pairing. Mirrors `SocketServer` but wraps an HTTPS server built from the provided TLS files.\n\n- [SocketRoute](/docs/reference/0.13.2/api/socketroute.md): Defines a WebSocket endpoint and owns its handlers, services, clients, and opt-in multiplayer policies. Routes can add bounded admission, transport limits, ordered work, heartbeat, rooms, resumable sessions, distribution, draining, metrics, and protocol negotiation without changing legacy routes.\n\n- [SocketService](/docs/reference/0.13.2/api/socketservice.md): Route-scoped background worker. Used by `SocketRoute` to run ticks or lifecycle hooks tied to a specific route.\n\n- [FixedStepService](/docs/reference/0.13.2/api/fixedstepservice.md): Route-scoped simulation clock that compensates for drift, prevents overlapping async ticks, bounds catch-up work, and reports dropped retained lag instead of replaying forever.\n\n- [RoomRegistry](/docs/reference/0.13.2/api/roomregistry.md): Bounded route-local connection groups. Sockets normally use joinRoom, leaveRoom, and roomBroadcast; disconnect cleanup removes all memberships and reclaims empty rooms.\n\n- [SessionRegistry](/docs/reference/0.13.2/api/sessionregistry.md): Bounded, expiring ownership records for application-issued opaque session IDs. Redweb handles takeover and expiry; the application owns credential issuance and payload validation.\n\n- [ProtocolClient](/docs/reference/0.13.2/api/protocolclient.md): Dependency-free helper from redweb/client for opt-in versioned routes. It builds, sends, and validates stable envelopes from the same checked-in schema used by server constants and TypeScript declarations.\n\n- [SocketRegistry](/docs/reference/0.13.2/api/socketregistry.md): Small EventEmitter-backed list for socket-scoped entities (players, rooms, etc.). Emits `added` and `removed` events.\n\n- [BaseHandler](/docs/reference/0.13.2/api/basehandler.md): Abstract message handler. Provide a name in the constructor; clients send `{ type: name, ... }` to target JSON messages, while binary frames can be accepted and handled as raw Buffer payloads.\n\n- [sendJson](/docs/reference/0.13.2/api/sendjson.md): Utility to JSON.stringify data and send it over a `ws` socket.\n\n- [ERROR_CODES](/docs/reference/0.13.2/api/errorcodes.md): Stable framework error codes shared by protocol-enabled servers and redweb/client.\n\n- [SOCKET_OPTIONS](/docs/reference/0.13.2/api/socketoptions.md): Default WebSocket server options used by BaseSocketServer.\n\n- [HTTP_OPTIONS](/docs/reference/0.13.2/api/httpoptions.md): Frozen defaults used by the HTTP and HTTPS server constructors.\n\n- [ENCODINGS](/docs/reference/0.13.2/api/encodings.md): Supported request-body parser names for HTTP server configuration.\n\n- [METHODS](/docs/reference/0.13.2/api/methods.md): Lowercase HTTP verb helpers passed straight to Express route registration.\n\n- [BaseSocketServer](/docs/reference/0.13.2/api/basesocketserver.md): Shared lifecycle and route-composition base for SocketServer and SecureSocketServer. Extend the concrete servers for normal applications; use this type when building infrastructure integrations.\n\n- [LiveHtmlServer](/docs/reference/0.13.2/api/livehtmlserver.md): Decorator-first server rendering and realtime browser updates on Redweb’s existing HTTP and WebSocket stack. Pages can be connection-scoped or intentionally shared.\n\n- [LivePage and start](/docs/reference/0.13.2/api/livepage.md): A page is an ordinary decorated class; extending LivePage is optional. start() is the concise entry point that creates a LiveHtmlServer for one or more page classes.\n\n- [page, component, state, action, view](/docs/reference/0.13.2/api/livedecorators.md): Small TypeScript decorators declare routes, reusable component ownership, reactive server state, browser-callable actions, and collection item views.\n\n- [JSX rendering](/docs/reference/0.13.2/api/jsxruntime.md): Dependency-free server-side TSX that renders directly to HtmlFragment values. It provides readable components, fragments, arrays, automatic escaping, safe attributes, and existing html-fragment interoperability without React, a virtual DOM, or hydration.\n\n- [html, attribute, url, each, codeBlock](/docs/reference/0.13.2/api/safehtml.md): Safe composition primitives escape text and quoted primitive attributes by default. URL attributes additionally reject executable, protocol-relative, and malformed values. Arrays must contain trusted HtmlFragment values.\n\n- [defineSite](/docs/reference/0.13.2/api/definesite.md): Defines shared static-site CSS, metadata, caching, layout, canonical URLs, and export behavior once. Site pages are always runtime-free.\n\n- [exportStatic](/docs/reference/0.13.2/api/exportstatic.md): Renders non-live decorated pages to deterministic directory indexes and content-addressed CSS. It is intended for docs, marketing pages, and static hosting.\n\n- [HtmlRenderer](/docs/reference/0.13.2/api/htmlrenderer.md): Lower-level rendering utility behind Live HTML. Most applications should use page(), start(), defineSite(), and exportStatic(); this surface supports advanced integrations and tooling.\n\n- [defineSocketContract](/docs/reference/0.13.2/api/socketcontract.md): One shared Standard Schema contract validates wire payloads and infers client/server types. Route URLs choose the service; individual handler factories dispatch by message type.\n\n- [Complete public TypeScript declarations](/docs/reference/0.13.2/api-types.md): Exact shipped signatures, options, and public types; not standalone application snippets.\n",
|
|
7
|
+
"pages": [
|
|
8
|
+
{
|
|
9
|
+
"id": "getting-started",
|
|
10
|
+
"title": "Choose a starter and build a working app",
|
|
11
|
+
"summary": "Requirements, fit, development, tests, and production boundaries.",
|
|
12
|
+
"source": "docs/GETTING_STARTED.md",
|
|
13
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Build a site and its realtime backend together\n\nRedweb renders TypeScript/TSX on Node.js and connects server-owned state and actions to the browser through WebSockets. Use it for live dashboards, chat, collaboration, documentation sites, and socket services. You can use HTTP or WebSockets independently.\n\nIt is not React, a browser component framework, a database, an identity provider, or a managed multiplayer platform. Do not use React hooks or import `react/jsx-runtime`. An edge-only host without Node listeners cannot run a live Redweb server; exported static pages need no Node runtime.\n\n## Start with a complete recipe\n\nChoose one of these complete applications:\n\n- [Realtime counter](/docs/reference/0.13.2/recipes/realtime.md): the smallest live website, sharing server-owned state between visitors.\n- [Chatroom](/docs/reference/0.13.2/recipes/chat.md): reusable stateful components, messages, and live presence.\n- [Site](/docs/reference/0.13.2/recipes/site.md): non-live pages with a shared layout and stylesheet.\n- [Socket service](/docs/reference/0.13.2/recipes/socket.md): a typed `/match` route with separate join/move/resume handlers.\n- [HTTP and WebSockets](/docs/reference/0.13.2/recipes/http-ws.md): one listener, an HTTP health endpoint and a raw `/chat` route with an explicit cleanup owner.\n- [Private dashboard](/docs/reference/0.13.2/recipes/dashboard.md): persistent SQLite cards, account sessions and private live updates (Node 22.13+).\n\nEach generated recipe page contains its exact files, commands, limitations, and real HTTP/WebSocket acceptance tests. Follow that recipe's version-specific setup instructions rather than mixing an unreleased example with a published npm version.\n\nRequirements: Node.js satisfying the package's `engines` field and npm. Use a supported Node.js release in production. TypeScript and the development watcher are installed by the starter. No React, frontend bundler or broker is required. Only the dashboard starter uses a database; its native SQLite requirement is recipe-local.\n\nThe installation floor is not a security-support promise for old Node releases. See [runtime compatibility, release verification and provenance](/docs/reference/0.13.2/release-trust.md) before choosing a production version.\n\n## One development loop\n\nAfter initialization and installation, `npm test` compiles the project, copies assets, and runs the shipped network tests. `npm run dev` watches source/configuration files, rebuilds, and restarts. Served HTML pages on direct localhost access refresh when the replacement server is ready; detected edits instead produce a confirmation notice that keeps the current document until explicit reload. This is not autosave or browser hot-module replacement; in-memory state and old socket sessions reset on restart. See [development refresh and its guarantees](/docs/reference/0.13.2/development.md#browser-refresh).\n\nUse `.tsx` for markup and extend `redweb/tsconfig.json`. Colocate CSS with the decorated page/component or declare an explicit asset root. `npm run build` prepares `dist/`; `npm start` runs that compiled application.\n\nWhen setup fails, run `npx --no-install redweb doctor --json` from the application directory. Fix reported errors and examine unresolved warnings, then rerun the build and real tests. Doctor does not execute application code or prove application correctness. See [diagnostics and boundaries](/docs/reference/0.13.2/cli.md).\n\n## The mental model\n\n- A page is a decorated class. Its `render()` returns server-side TSX.\n- State is server-owned data. An ordinary TSX expression reading `@state()` updates automatically when that property is assigned. Replace arrays/objects rather than mutating them in place.\n- Only decorated actions are browser-callable. Validate and authorize every untrusted input; hiding a button is not access control.\n- A class component owns reusable state/actions and has its own update boundary. Function components are convenient presentation helpers.\n- Pages are connection-scoped by default. `shared: true` intentionally shares one page instance; do not put private visitor data there.\n- Shared in-memory state survives visitors and reloads, not server restarts. Durable cards/history require application-owned persistence. Multiple processes do not automatically share memory.\n- Socket URLs select routes; message `type` selects a handler. Do not add a second `message.action` dispatcher inside a catch-all handler.\n\nSee [rendering and lifecycle](/docs/reference/0.13.2/live-html.md) and [shared socket contracts](/docs/reference/0.13.2/socket-contracts.md) for exact semantics.\n\nFor private raw socket subscriptions, see [room authorization and shared request identity](/docs/reference/0.13.2/room-authorization.md). Keep authentication, subscription permission, and application-specific write permission explicit.\n\n## Deploy deliberately\n\nBuild first. Deploy `dist/`, the package manifest, and the lockfile, then install runtime dependencies with `npm ci --omit=dev`. The starters are tested with `src/` unavailable after compilation. Configure HTTPS/WSS and a proxy that supports WebSocket upgrades when using a reverse proxy.\n\nThese deployment commands require a verified release pair. `redweb@0.13.2` installs published `redweb-client@0.2.0` automatically through its dependency. Future unreleased Redweb changes require their matching tested tarball until a release containing them is published. The `npm link` workflow is local development only: a clean production install does not preserve that link.\n\nBefore public access, add authentication, authorization, trusted-origin policy, input/rate limits, application persistence where needed, and bounded shutdown. Treat reconnect/session tokens as credentials. Do not promise exactly-once delivery or durable sessions from an in-memory starter. See [operations](/docs/reference/0.13.2/operations.md) and [guarantees and limits](/docs/reference/0.13.2/production-contract.md).\n\n## Evidence and compatibility\n\nComplete recipe files are executable applications; shorter API snippets explain individual methods and may require surrounding application code. Type-check and test a complete recipe before adapting it. The package verifier runs the documented recipe files against an extracted tarball and actual listeners, not mocks.\n\nCoverage reports refer to instrumented library code. They do not prove exhaustive browser behavior, application security, all generated-example branches, or production capacity. Historical [verification evidence](/docs/reference/0.13.2/verification.md) applies only to its recorded revision/environment; consult the [current release checklist](/docs/reference/0.13.2/release-status.md) for remaining work.\n",
|
|
14
|
+
"url": "/docs/reference/0.13.2/getting-started.md",
|
|
15
|
+
"sha256": "6b3afbd06adbfbc93f85db041bea51d3515da17871e7778068c1d47096031bce"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "guides/realtime-dashboard",
|
|
19
|
+
"title": "Build a private realtime dashboard",
|
|
20
|
+
"summary": "Persistent SQLite cards, account-private updates and sign-out across tabs, with explicit single-process limits.",
|
|
21
|
+
"source": "docs/guides/realtime-dashboard.md",
|
|
22
|
+
"recipe": {
|
|
23
|
+
"template": "dashboard",
|
|
24
|
+
"file": "src/cards.tsx"
|
|
25
|
+
},
|
|
26
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Build a private realtime dashboard\n\nBuild a page where signed-in users create cards, see their other tabs update, and find the same cards after a server restart. Use the dashboard starter on **Node 22.13 or newer**; this application uses native SQLite. It is a complete application recipe, not a new database or authentication framework inside Redweb.\n\n## Explain it like I'm five\n\nThink of SQLite as a locked notebook and each browser tab as a window onto it. The server checks whose notebook you may open before reading or changing a card. After a successful change, it tells that account's other open windows to read their latest cards. The windows are not the notebook: closing them does not erase saved data.\n\n## Follow the design\n\n1. The setup command below provisions `alice` after installing dependencies. It prints a generated password once; save it privately. There is no default password. Open `http://127.0.0.1:8181/login` after development starts.\n2. `app.tsx` composes the store, session checks, protected page and shutdown. The `Cards` component below owns presentation and actions, not the database connection.\n3. `loading()` reads the current account's cards. `connected()` subscribes that browser connection to private updates; disconnect and disposal release the subscription.\n4. The add/remove actions validate form input before calling the store. The store rechecks the session and owner within each write transaction; a hidden input is not permission to delete someone else's card.\n5. `PrivateCards.publish()` refreshes only valid subscribers for that account. Assigning the new array to decorated state updates keyed TSX without a manual browser message handler.\n\nSee the complete [composition](/docs/reference/0.13.2/recipes/dashboard/files/src/app.tsx), [store](/docs/reference/0.13.2/recipes/dashboard/files/src/store.ts), [authentication](/docs/reference/0.13.2/recipes/dashboard/files/src/auth.ts), and [acceptance tests](/docs/reference/0.13.2/recipes/dashboard/files/test/app.test.cjs). The generated recipe supplies all of them together.\n\n## Check that it works\n\nSign in from two tabs, add a card in one, and confirm both show it. A different account must not see it. Restart the process with the same database path and confirm the card remains. Sign out all sessions and verify both tabs lose access. `npm test` exercises real HTTP, WebSockets, temporary SQLite data, account isolation, restart and expiry; it does not modify your application database. Run your own browser checks for the browsers you support.\n\n## Before deployment\n\nKeep `DASHBOARD_DATABASE` on a writable persistent volume and out of public assets, source control and logs. Follow the [recipe's origin, cookie, account-provisioning and backup instructions](/docs/reference/0.13.2/recipes/dashboard.md). Browser refresh is not persistence; successful storage and a retained database are what preserve cards.\n\nOn a compiled-only deployment, provision accounts with `node dist/admin.js alice` using the same database environment and volume, before starting the service. The development `npm run add-user` script rebuilds first and therefore needs development tooling; the compiled administrator command does not. Never copy a generated password into logs or deployment manifests.\n\nThis recipe uses **single-process** notifications and revocation. Multiple workers do not automatically exchange updates or logout events. It has no password reset, MFA or account recovery; use a dedicated identity integration when those are requirements. Do not replace server-side permission checks with a `shared: true` page containing private state. See [request and room authorization](/docs/reference/0.13.2/room-authorization.md) and [production boundaries](/docs/reference/0.13.2/production-contract.md).\n\n## Build and run the complete application\n\n```sh\nnpx --yes redweb@0.13.2 init my-dashboard --template dashboard\ncd my-dashboard\nnpm install --save-exact redweb@0.13.2\nnpm run add-user -- alice\nnpm test\nnpm run dev\n```\n\nThe [complete dashboard recipe](/docs/reference/0.13.2/recipes/dashboard.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.\n\n## Source walkthrough: src/cards.tsx\n\n```tsx\nimport { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';\nimport { z } from 'zod';\nimport { sessionToken } from './auth';\nimport { DashboardStore, MAX_CARDS, type Card } from './store';\n\nconst addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\\p{Cc}\\p{Cf}]+$/u) }).strict();\nconst removeInput = z.object({ id: z.string().uuid() }).strict();\nconst tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));\n\ninterface Subscriber { token: string; update(): void; close(): void; }\n\n/** Single-process notifications; SQLite remains the source of truth on every connection. */\nexport class PrivateCards {\n private readonly accounts = new Map<string, Set<Subscriber>>();\n constructor(readonly store: DashboardStore) {}\n\n allowed(context: LivePageRequestContext) {\n const session = this.store.session(tokenOf(context));\n return !!session && session.account === context.principal && !context.signal.aborted;\n }\n\n subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {\n const token = tokenOf(context);\n const session = this.store.session(token);\n if (!session || !this.allowed(context)) throw new Error('Sign in again.');\n let group = this.accounts.get(session.account);\n if (!group) this.accounts.set(session.account, group = new Set());\n let closed = false;\n const subscriber: Subscriber = {\n token, update: () => update(this.store.list(token)),\n close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },\n };\n const unsubscribe = () => {\n if (closed) return;\n closed = true;\n clearTimeout(expiry);\n context.signal.removeEventListener('abort', unsubscribe);\n group.delete(subscriber);\n if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);\n };\n const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));\n expiry.unref();\n group.add(subscriber);\n context.signal.addEventListener('abort', unsubscribe, { once: true });\n try { subscriber.update(); }\n catch (error) { unsubscribe(); throw error; }\n return unsubscribe;\n }\n\n publish(account: string) {\n for (const subscriber of this.accounts.get(account) ?? []) {\n try {\n if (this.store.session(subscriber.token)?.account === account) subscriber.update();\n else subscriber.close();\n } catch { subscriber.close(); }\n }\n }\n}\n\n@component()\nexport class Cards {\n @state() items: Card[] = [];\n private unsubscribe?: () => void;\n\n constructor(private readonly cards: PrivateCards) {}\n loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }\n connected(context: LivePageConnectionContext) {\n this.disconnected();\n this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });\n }\n disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }\n disposed() { this.disconnected(); }\n\n @action({ input: addInput })\n add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.add(tokenOf(context), title));\n }\n\n @action({ input: removeInput })\n remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.remove(tokenOf(context), id));\n }\n\n render() {\n return <section class=\"cards\" aria-label=\"Your saved cards\">\n <form rw-submit=\"add\">\n <label for=\"card-title\">New card</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"80\" required autocomplete=\"off\" />\n <button type=\"submit\" disabled={this.items.length >= MAX_CARDS}>Add card</button>\n </form>\n <p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>\n <ul class=\"card-grid\">{this.items.map(card => <li key={card.id} data-card-id={card.id}>\n <h2>{card.title}</h2>\n <form rw-submit=\"remove\">\n <input type=\"hidden\" name=\"id\" value={card.id} />\n <button type=\"submit\" aria-label={`Delete ${card.title}`}>Delete</button>\n </form>\n </li>)}</ul>\n {!this.items.length && <p>No cards yet. Add your first one above.</p>}\n </section>;\n }\n}\n```\n",
|
|
27
|
+
"url": "/docs/reference/0.13.2/guides/realtime-dashboard.md",
|
|
28
|
+
"sha256": "aec934f13460d4b90eb6f28cd8215df150b2141af3e63945c8a098cc37c2d425"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "guides/jsx-without-react",
|
|
32
|
+
"title": "Render JSX without React",
|
|
33
|
+
"summary": "TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.",
|
|
34
|
+
"source": "docs/guides/jsx-without-react.md",
|
|
35
|
+
"recipe": {
|
|
36
|
+
"template": "site",
|
|
37
|
+
"file": "src/app.tsx"
|
|
38
|
+
},
|
|
39
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Render JSX without React\n\nBuild a two-page TypeScript site with shared navigation, a stylesheet and per-page metadata. JSX is a markup syntax here: Redweb renders it on Node.js, without React hooks, hydration, or a browser component runtime. This is useful for documentation, content sites and server-rendered pages that do not need browser-side component execution.\n\n## Explain it like I'm five\n\nThe page class is a recipe and the server is the kitchen. `render()` prepares HTML before it reaches the browser. A shared layout adds the same navigation around each page, like putting different meals on the same kind of plate. The browser receives the finished document, not the kitchen.\n\n## Follow the design\n\n1. The initializer supplies `redweb/tsconfig.json` inheritance, TypeScript, the stylesheet and the entrypoint helper. Keep the file as `.tsx`; do not point its JSX settings at `react/jsx-runtime`.\n2. `defineSite()` supplies one layout and CSS declaration. Its page decorators register `/` and `/about`, with metadata beside each page.\n3. Each `render()` returns ordinary TSX. Function components can share presentation; page-specific data remains in your server code. Text and attribute values are escaped, and URL protocols are restricted.\n4. `createApp()` combines both pages on one listener. The standalone entrypoint owns bounded shutdown through the shared helper; importing the module starts nothing.\n\nKeep CSS in external files. The [rendering reference](/docs/reference/0.13.2/live-html.md) covers components, templates, assets and static export. For interactive pages, start from the [realtime counter](/docs/reference/0.13.2/recipes/realtime.md): assignments to decorated state update the browser through Redweb's runtime. Non-live site pages do not acquire that behavior just because their markup is JSX.\n\n## Check that it works\n\nOpen `http://localhost:8181/`, follow About, and confirm the navigation and styling stay consistent while the title and content change. View the response source: it is server-rendered HTML, not an empty mount point. The [shipped test](/docs/reference/0.13.2/recipes/site/files/test/app.test.cjs) checks actual HTTP responses, both pages, CSS and absence of the live-page runtime. The package gate repeats it with the source directory unavailable.\n\n`npm run build` produces compiled code and copied assets; `npm start` serves that output. `site.export()` is a separate static-export workflow, not what the starter's default build does. Static export cannot replace protected or live application requests.\n\n## When to choose another approach\n\nRedweb TSX is not React-compatible. Do not import React components or expect hooks, client effects, browser rendering or automatic support for browser-only libraries. Choose an appropriate browser framework when those are core requirements. Live Redweb applications require a Node host with long-lived listeners; exported static files have a different deployment model. See [compatibility and release verification](/docs/reference/0.13.2/release-trust.md).\n\n## Build and run the complete application\n\n```sh\nnpx --yes redweb@0.13.2 init my-site --template site\ncd my-site\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\nThe [complete site recipe](/docs/reference/0.13.2/recipes/site.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.\n\n## Source walkthrough: src/app.tsx\n\n```tsx\nimport { defineSite, start, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nconst site = defineSite({\n css: 'app.css',\n layout: content => <body><nav><a href=\"/\">Home</a> · <a href=\"/about\">About</a></nav>{content}</body>,\n});\n\n@site.page('/', { head: { title: 'My Redweb site', description: 'A server-rendered TypeScript site.' } })\nexport class HomePage {\n render() {\n return <main class=\"home\"><h1>Your server-rendered app is ready.</h1><p>Edit src/app.tsx to make it yours.</p></main>;\n }\n}\n\n@site.page('/about', { head: { title: 'About' } })\nexport class AboutPage {\n render() { return <main class=\"home\"><h1>About</h1><p>Shared layout, separate pages, no browser JavaScript.</p></main>; }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start([HomePage, AboutPage], { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n",
|
|
40
|
+
"url": "/docs/reference/0.13.2/guides/jsx-without-react.md",
|
|
41
|
+
"sha256": "cb508ca68c15fbc7c7471e2db992d1659f2b2c6dd96a7ac597d81b535429e1c0"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "guides/chatroom",
|
|
45
|
+
"title": "Build a chatroom with live presence",
|
|
46
|
+
"summary": "Reusable server-side components, validated forms and disconnect-aware presence, without custom browser socket glue.",
|
|
47
|
+
"source": "docs/guides/chatroom.md",
|
|
48
|
+
"recipe": {
|
|
49
|
+
"template": "chat",
|
|
50
|
+
"file": "src/chatroom.tsx"
|
|
51
|
+
},
|
|
52
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Build a chatroom with live presence\n\nBuild a shared chatroom where visitors choose a name once, send messages, and see who is online. The starter includes the canonical reusable `ChatroomComponent`, its stylesheet, input validation and real-network tests. You do not write a browser WebSocket handler for every message or member change.\n\n## Explain it like I'm five\n\nThe room is a noticeboard managed by the server. Each visitor gets a little window onto it. The room keeps the recent messages and the online list; each visitor's component keeps their name and what their window should show. When the noticeboard changes, the server updates the windows.\n\n## Follow the design\n\n1. The [application entrypoint](/docs/reference/0.13.2/recipes/chat/files/src/app.tsx) starts a page created by `createChatroomPage()`. The component source shown below is copied from the maintained example, not a separate implementation.\n2. `ChatRoom` owns shared message/member data. `ChatroomComponent` owns a participant's state, server-callable actions and view. Normal TypeScript conditions choose the join screen or conversation screen.\n3. Decorated join/send actions validate form values through the starter's Zod schemas. Redweb provides loading/error feedback; invalid input does not require custom browser glue to preserve the draft.\n4. `connected()` restores online participation when a retained participant reconnects. `disconnected()` removes online presence; later disposal releases retained identity. A name reserved briefly for reconnect does not mean the person is still online.\n5. State assignments and stable JSX keys update messages and members. A function that returns reusable markup alone would not replace this component's owned lifecycle and actions.\n\nDisplay names are not authenticated identities. Use the [private dashboard guide](/docs/reference/0.13.2/guides/realtime-dashboard.md) and [authorization reference](/docs/reference/0.13.2/room-authorization.md) when your application needs verified accounts and private data.\n\n## Check that it works\n\nOpen two tabs at `http://localhost:8181/`, choose different names and send a message. Both should see its sender and text. Close one tab and confirm its online presence disappears once the server observes the disconnect. Abrupt network loss is not necessarily detected immediately; heartbeat and network timing matter. Reconnect is not a promise of durable identity.\n\nThe [starter tests](/docs/reference/0.13.2/recipes/chat/files/test/app.test.cjs) exercise actual pages, sockets, server actions, escaped message delivery and disconnect behavior. The package gate repeats them after source removal. Test invalid inputs, browser focus, unsent drafts and reconnects on your supported browsers as well.\n\n## Before promising durable chat\n\nHistory is bounded to **100 messages in server memory**. It survives neither a process restart nor independent workers. Add application-owned persistence and a deliberate cross-process notification design before promising durable history or distributed rooms. Add authentication, membership authorization and abuse controls before exposing private or public rooms. The raw socket handler starter is a different abstraction; do not replace this component with a second browser message dispatcher merely to update its HTML.\n\n## Build and run the complete application\n\n```sh\nnpx --yes redweb@0.13.2 init my-chat --template chat\ncd my-chat\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\nThe [complete chat recipe](/docs/reference/0.13.2/recipes/chat.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.\n\n## Source walkthrough: src/chatroom.tsx\n\n```tsx\nimport { action, component, page, start, state, type ActionInput } from 'redweb';\nimport { z } from 'zod';\n\nconst MAX_VISIBLE_MEMBERS = 100;\nconst visibleText = (maximum: number) => z.string()\n .transform(value => value.normalize('NFKC').trim())\n .pipe(z.string().min(1).max(maximum).regex(/^[^\\p{Cc}\\p{Cf}]+$/u));\nexport const chatInputs = {\n join: z.object({ name: visibleText(40) }).strict(),\n send: z.object({ message: visibleText(500) }).strict(),\n};\n\ninterface StoredMessage { id: number; sender: string; text: string; }\ninterface RoomParticipant {\n readonly displayName: string;\n updateMessages(messages: readonly StoredMessage[]): void;\n updatePresence(members: readonly string[]): void;\n}\n\nclass ChatRoom {\n private history: readonly StoredMessage[] = [];\n private nextMessageId = 0;\n private readonly participants = new Set<RoomParticipant>();\n private readonly online = new Set<RoomParticipant>();\n\n join(participant: RoomParticipant) {\n const name = participant.displayName.toLocaleLowerCase();\n if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) return false;\n this.participants.add(participant);\n this.online.add(participant);\n participant.updateMessages(this.history);\n this.publishPresence();\n return true;\n }\n\n disconnect(participant: RoomParticipant) {\n if (this.online.delete(participant)) this.publishPresence();\n }\n\n leave(participant: RoomParticipant) {\n this.online.delete(participant);\n if (this.participants.delete(participant)) this.publishPresence();\n }\n\n send(participant: RoomParticipant, text: string) {\n if (!this.online.has(participant)) return false;\n this.history = [...this.history, { id: ++this.nextMessageId, sender: participant.displayName, text }].slice(-100);\n for (const member of this.participants) member.updateMessages(this.history);\n return true;\n }\n\n private publishPresence() {\n const members = [...this.online].map(participant => participant.displayName);\n for (const participant of this.participants) participant.updatePresence(members);\n }\n}\n\n@component()\nexport class ChatroomComponent implements RoomParticipant {\n @state() displayName = '';\n @state() feedback = '';\n @state() messages: readonly StoredMessage[] = [];\n @state() members: readonly string[] = [];\n\n constructor(private readonly room: ChatRoom) {}\n\n connected() { if (this.displayName) this.room.join(this); }\n disconnected() { this.room.disconnect(this); }\n disposed() { this.room.leave(this); }\n\n @action({ input: chatInputs.join })\n join({ name }: ActionInput<typeof chatInputs.join>) {\n if (this.displayName) return false;\n this.displayName = name;\n if (!this.room.join(this)) {\n this.displayName = '';\n this.feedback = 'That display name is already in use.';\n return false;\n }\n this.feedback = '';\n return true;\n }\n\n @action({ input: chatInputs.send })\n send({ message }: ActionInput<typeof chatInputs.send>) {\n return this.room.send(this, message);\n }\n\n @action()\n leave() {\n this.room.leave(this);\n this.displayName = '';\n this.feedback = '';\n this.messages = [];\n this.members = [];\n }\n\n updateMessages(messages: readonly StoredMessage[]) { this.messages = messages; }\n updatePresence(members: readonly string[]) { this.members = members; }\n\n render() {\n return <section class=\"chatroom\">{this.displayName ? this.roomScreen() : this.joinScreen()}</section>;\n }\n\n private joinScreen() {\n return (\n <section class=\"join-panel\">\n <p class=\"eyebrow\">Live room</p>\n <h1>Join the chatroom</h1>\n <p>Choose a name once, then chat in realtime with everyone currently in the room.</p>\n {this.feedback && <p class=\"form-error\" role=\"alert\">{this.feedback}</p>}\n <form rw-submit=\"join\" class=\"join-form\">\n <label for=\"display-name\">Display name</label>\n <div class=\"input-row\">\n <input id=\"display-name\" name=\"name\" maxlength=\"40\" autocomplete=\"nickname\" required autofocus />\n <button type=\"submit\">Join room</button>\n </div>\n </form>\n </section>\n );\n }\n\n private roomScreen() {\n const remaining = this.members.length - MAX_VISIBLE_MEMBERS;\n return (\n <div class=\"room-layout\">\n <section class=\"conversation\">\n <header class=\"room-header\">\n <div><p class=\"eyebrow\">Connected as</p><h1>{this.displayName}</h1></div>\n <button type=\"button\" class=\"quiet-button\" rw-click=\"leave\">Leave</button>\n </header>\n <ol class=\"message-list\" aria-live=\"polite\">\n {this.messages.length ? this.messages.map(entry => (\n <li key={entry.id}><strong>{entry.sender}</strong><p>{entry.text}</p></li>\n )) : <li class=\"empty-message\">No messages yet. Say hello.</li>}\n </ol>\n <form rw-submit=\"send\" class=\"composer\">\n <label class=\"sr-only\" for=\"chat-message\">Message</label>\n <input id=\"chat-message\" name=\"message\" maxlength=\"500\" autocomplete=\"off\" placeholder=\"Message the room…\" required autofocus />\n <button type=\"submit\">Send</button>\n </form>\n </section>\n <aside class=\"presence\" aria-label=\"People in the room\">\n <p class=\"eyebrow\">Online · {this.members.length}</p>\n <ul>\n {this.members.slice(0, MAX_VISIBLE_MEMBERS).map(member => <li key={member}>{member}</li>)}\n {remaining > 0 && <li class=\"more-members\">+{remaining} more</li>}\n </ul>\n </aside>\n </div>\n );\n }\n}\n\nexport function createChatroomPage() {\n const room = new ChatRoom();\n\n @page('/', { css: 'chatroom.css' })\n class ChatroomPage {\n chat = new ChatroomComponent(room);\n render() { return <main>{this.chat}</main>; }\n }\n\n return ChatroomPage;\n}\n\nif (require.main === module) start(createChatroomPage(), { port: 8080 });\n```\n",
|
|
53
|
+
"url": "/docs/reference/0.13.2/guides/chatroom.md",
|
|
54
|
+
"sha256": "a97760ac2ad37056c34186f150babf7c7d87752dfa5077988878c987e611ce31"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "guides/typed-websockets",
|
|
58
|
+
"title": "Share typed WebSocket contracts",
|
|
59
|
+
"summary": "One match route, separate join/move/resume handlers and validated client/server payloads from the same schema.",
|
|
60
|
+
"source": "docs/guides/typed-websockets.md",
|
|
61
|
+
"recipe": {
|
|
62
|
+
"template": "socket",
|
|
63
|
+
"file": "src/handlers.ts"
|
|
64
|
+
},
|
|
65
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Share typed WebSocket contracts\n\nBuild a `/match` service with independent join, move and resume handlers. A shared schema validates payloads and supplies TypeScript types to both sides. Use this when you need a raw socket protocol, such as a game client or a custom realtime client, rather than a server-rendered page.\n\n## Explain it like I'm five\n\nThe URL is the room's address. A message's `type` tells the receptionist which person should handle it. The shared contract is the form that says what information that person needs. Checking the form before handing it over prevents a movement handler from receiving a name where a coordinate should be.\n\n## Follow the design\n\n1. [The contract](/docs/reference/0.13.2/recipes/socket/files/src/contract.ts) declares `join`, `move`, `resume` and `state` once. It is safe to import into a browser bundle because it does not import the server application.\n2. [The route](/docs/reference/0.13.2/recipes/socket/files/src/app.tsx) binds `/match`, enables the contract protocol and registers `Join`, `Move` and `Resume`. There is no socket decorator layer and no inner `message.action` switch.\n3. The handlers below receive parsed payloads. `Join` creates an in-memory player session, `Move` changes its server-owned coordinates, and `Resume` reclaims it using a private bearer token.\n4. Each sends a validated `state` response. A client uses `match.client(socket)` to send and parse typed messages. That wrapper does **not** open or reconnect its transport; the application creates the WebSocket first.\n\nConnect to `ws://localhost:8181/match?redwebVersion=1` during local development. Follow the [complete recipe's client example](/docs/reference/0.13.2/recipes/socket.md) for opening a transport and handling responses; use WSS outside local development. The [contract reference](/docs/reference/0.13.2/socket-contracts.md) documents wire envelopes, validation and failure behavior.\n\n## Check that it works\n\nJoin with two independent clients, move one, then disconnect and resume it with its session token. The other player's state must remain independent. Send invalid coordinates and a malformed raw message to verify both client and server checks. The [real-socket acceptance test](/docs/reference/0.13.2/recipes/socket/files/test/app.test.cjs) covers those sequences, including server rejection that bypasses client validation.\n\n## This is not a complete game backend\n\nThe example bounds coordinate values; it does not prove a move obeys your game's speed, turn or collision rules. Add authoritative game rules and authentication. Keep session tokens private: possession permits resume and connection takeover. Sessions are capped at 100, expire 30 seconds after disconnect, and are lost on restart. They are not a durable or cross-worker identity store.\n\nPer-connection ordering is not exactly-once delivery. After a disconnect, a client may not know whether its last action completed; reconcile state before retrying side effects. Choose transport limits from measured load and read [operations](/docs/reference/0.13.2/operations.md) and [runtime retry boundaries](/docs/reference/0.13.2/runtime-diagnostics.md).\n\n## Build and run the complete application\n\n```sh\nnpx --yes redweb@0.13.2 init my-socket --template socket\ncd my-socket\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\nThe [complete socket recipe](/docs/reference/0.13.2/recipes/socket.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.\n\n## Source walkthrough: src/handlers.ts\n\n```ts\nimport { randomUUID } from 'node:crypto';\nimport type { RedWebSocket } from 'redweb';\nimport { match } from './contract';\n\nclass Player {\n readonly session = randomUUID();\n x = 0;\n y = 0;\n constructor(readonly name: string) {}\n}\n\nfunction requireUnjoined(socket: RedWebSocket) {\n if (socket.context?.session) throw new Error('Already joined.');\n}\n\nfunction currentPlayer(socket: RedWebSocket) {\n const session = socket.context?.session as { data?: unknown } | null | undefined;\n if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');\n return session.data;\n}\n\nexport const Join = match.handler('join', (socket, { name }, message) => {\n requireUnjoined(socket);\n const player = new Player(name);\n if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Move = match.handler('move', (socket, { x, y }, message) => {\n const player = currentPlayer(socket);\n player.x = x;\n player.y = y;\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Resume = match.handler('resume', (socket, { session }, message) => {\n requireUnjoined(socket);\n if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');\n return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });\n});\n```\n",
|
|
66
|
+
"url": "/docs/reference/0.13.2/guides/typed-websockets.md",
|
|
67
|
+
"sha256": "3e1dc54e666529281fd941cc5535a8e56ddc1f7888607f0ac4276220a014b5e9"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "guides/http-websocket",
|
|
71
|
+
"title": "Serve HTTP and WebSockets on one port",
|
|
72
|
+
"summary": "An Express endpoint and raw socket route share one listener with one explicit shutdown owner.",
|
|
73
|
+
"source": "docs/guides/http-websocket.md",
|
|
74
|
+
"recipe": {
|
|
75
|
+
"template": "http-ws",
|
|
76
|
+
"file": "src/app.tsx"
|
|
77
|
+
},
|
|
78
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Serve HTTP and WebSockets on one port\n\nBuild an Express endpoint and a raw WebSocket route on the same Node listener. Use this when an existing HTTP application needs socket endpoints without a second port or a separate web framework. This guide demonstrates server composition, not a rendered chat interface.\n\n## Explain it like I'm five\n\nImagine one front door with two signs. Ordinary HTTP visitors ask for a page or JSON response. WebSocket visitors ask to keep a conversation open. Both use the same door, but different route and handler classes decide what happens inside. One owner is responsible for closing the building.\n\n## Follow the design\n\n1. `HttpServer({ listen: false })` builds the Express application and Node server without opening a port. `/health` answers ordinary HTTP requests; `publicPaths: []` avoids exposing an incidental working-directory folder.\n2. Pass that Node server to `SocketServer`, alongside the `/chat` route. `listen: true` explicitly starts the supplied listener; `closeServerOnShutdown: true` assigns its cleanup to the socket service.\n3. The URL selects `ChatRoute`. A raw JSON message with `type: \"hello\"` selects `Hello`; there is no secondary action dispatcher.\n4. `createApp()` returns the one cleanup owner. Its `shutdown()` processes route failures and still closes the shared HTTP peers. The generated entrypoint helper adds bounded process shutdown without another handwritten signal policy.\n\nThe framework ordinarily leaves supplied listeners caller-owned. These explicit flags are a choice made by this starter, not a change to that default. Use [migration and ownership guidance](/docs/reference/0.13.2/migration.md) when adapting an existing application; do not let two independent services compete to close the same listener.\n\n## Check that it works\n\nRequest `http://127.0.0.1:8181/health` and expect `{\"ok\":true}`. Open a WebSocket to `ws://127.0.0.1:8181/chat`, send `{\"type\":\"hello\"}`, and expect `{\"type\":\"hello\",\"message\":\"Hello from the server!\"}`. An unknown socket path is rejected rather than sent to a catch-all handler.\n\nThe [shipped tests](/docs/reference/0.13.2/recipes/http-ws/files/test/app.test.cjs) use real HTTP and WebSocket clients on one ephemeral port. They also leave an HTTP request incomplete, repeat shutdown, and deliberately fail an application route's cleanup to confirm the listener still closes. Shared lifecycle tests cover process-level shutdown failures; the package gate repeats the compiled application checks with source removed.\n\n## Before public deployment\n\nThe starter deliberately binds loopback. Configure the deployment bind address and HTTPS/WSS termination, trusted origins, identity, authorization and capacity limits before exposing it. `/health` proves liveness, not readiness to accept game traffic or completion of durable work. Forced shutdown closes transports; it does not guarantee delivery, transaction completion or storage.\n\nFor shared validation and inferred payloads, use the [typed WebSocket guide](/docs/reference/0.13.2/guides/typed-websockets.md). For UI updates driven by server-side components, use the [chatroom guide](/docs/reference/0.13.2/guides/chatroom.md). See [operations and deployment boundaries](/docs/reference/0.13.2/operations.md) before adding proxies or multiple workers.\n\n## Build and run the complete application\n\n```sh\nnpx --yes redweb@0.13.2 init my-http-ws --template http-ws\ncd my-http-ws\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\nThe [complete http-ws recipe](/docs/reference/0.13.2/recipes/http-ws.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.\n\n## Source walkthrough: src/app.tsx\n\n```tsx\nimport { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nexport class Hello extends BaseHandler {\n constructor() { super('hello'); }\n\n onMessage(socket: RedWebSocket) {\n socket.sendJson({ type: 'hello', message: 'Hello from the server!' });\n }\n}\n\nexport class ChatRoute extends SocketRoute {\n constructor() {\n super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });\n }\n}\n\nexport function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {\n const http = new HttpServer({\n listen: false,\n publicPaths: [],\n services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],\n });\n\n return new SocketServer({\n port: options.port ?? Number(process.env.PORT ?? 8181),\n bind: options.bind ?? '127.0.0.1',\n logger: options.logger,\n server: http.server,\n routes: [ChatRoute],\n listen: true,\n closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.\n });\n}\n\nif (require.main === module) runApp(createApp);\n```\n",
|
|
79
|
+
"url": "/docs/reference/0.13.2/guides/http-websocket.md",
|
|
80
|
+
"sha256": "d13b3d59e156a90842196195bc890cb5a2cc28d9095b1b755ec9e093b0c33354"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "migration",
|
|
84
|
+
"title": "Upgrade an existing Redweb application",
|
|
85
|
+
"summary": "Historical socket defaults, asynchronous ownership, HTML migration and version-matched verification.",
|
|
86
|
+
"source": "docs/MIGRATION.md",
|
|
87
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Upgrade an existing Redweb application\n\nMatch the installed package to its versioned documentation. Redweb 0.13.2 contains the capabilities described by the 0.13.2 guides; a later development checkout may not match that release. See [release verification](/docs/reference/0.13.2/release-trust.md) and the changelog shipped with your selected package. Keep your lockfile and rollback artifact, and run your own real HTTP/WebSocket/browser tests after upgrading.\n\n## 0.8 migration notes\n\n- Unmatched WebSocket paths are rejected unless `fallbackToRoot: true` is configured.\n- Handler exception details are hidden unless `exposeErrors: true` is configured. Do not expose private exception messages in production.\n- Shutting down a WebSocket server no longer closes a caller-supplied HTTP/HTTPS server by default. Explicitly set `closeServerOnShutdown: true` only when handing cleanup responsibility to that socket server.\n- `bind` is honored by HTTP, HTTPS, WebSocket, and secure WebSocket listeners.\n- `shutdown()` is asynchronous; await it when deterministic cleanup matters. Awaiting a shutdown is not a delivery or persistence guarantee.\n\n## 0.9 migration notes\n\n- No migration is required when the new multiplayer options are disabled.\n- Production controls are route-local and opt-in; size them from measured capacity rather than copying example limits.\n- `ProtocolClient` is available from `redweb/client` for negotiated protocol routes without adding runtime dependencies. It wraps a transport; your application creates and reconnects that transport.\n- Node.js 18 is the installation/legacy-compatibility floor, not a recommendation to deploy an end-of-life runtime. Use a maintained LTS release with current security patches; check [runtime compatibility](/docs/reference/0.13.2/release-trust.md).\n\n## Live HTML migration\n\nThe executable `.htmx` sandbox and `enableHtmxRendering` option were replaced. Templates are ordinary `.html` files registered through decorated plain classes. Move calculations and imports into the page class, mark reactive fields with `@state()`, expose browser-callable methods with `@action()`, and start the page with `start(PageClass)`.\n\nFor server-rendered TSX, extend `redweb/tsconfig.json`; do not configure React's JSX runtime. `redweb init --existing` creates a missing root configuration without overwriting one you already have. Check the effective configuration with your installed CLI: `npx --no-install redweb doctor --json`. Review warnings and fix errors before compiling; preservation does not imply correctness.\n\nIn the reactive-rendering candidate, ordinary TSX expressions reading decorated state update after assignment. Replace arrays/objects instead of mutating them in place. Use stable JSX keys for lists. Existing explicit HTML bindings remain supported. See [rendering and lifecycle](/docs/reference/0.13.2/live-html.md) for owner isolation, component lifetimes and reconnect behavior, and [runtime diagnostics](/docs/reference/0.13.2/runtime-diagnostics.md) for failure categories and retry limits.\n\nShared page state is process-local, not durable or automatically private. Add explicit identity, authorization and persistence for your application. The [private dashboard recipe](/docs/reference/0.13.2/recipes/dashboard.md) demonstrates one single-process implementation; it is not a distributed session store.\n",
|
|
88
|
+
"url": "/docs/reference/0.13.2/migration.md",
|
|
89
|
+
"sha256": "fc5f7b46da45e359ea331d16c4ccceee761b997f65baea7917a070a86a6bf475"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "cli",
|
|
93
|
+
"title": "Initialization and diagnostics",
|
|
94
|
+
"summary": "Noninteractive setup, safe existing-project adoption, and structured repair guidance.",
|
|
95
|
+
"source": "docs/CLI.md",
|
|
96
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Redweb command-line tools\n\nUse the version installed in your project (`npx --no-install redweb`) when troubleshooting an existing app. The tool reports a warning if its version differs from the project's installed Redweb version.\n\n## Add pages, components, and socket routes\n\nThese commands are available in `redweb@0.13.2`.\n\n```sh\nnpx --no-install redweb add page dashboard\nnpx --no-install redweb add component notifications\nnpx --no-install redweb add socket-route match\nnpx --no-install redweb add page account-settings --dry-run --json\n```\n\nEach addition writes a named-export TypeScript module and a `.test.cjs` file. Pages and owned components demonstrate server state plus an exposed increment action. The socket route demonstrates a validated `ping` handler returning `pong`, without an inner action dispatcher; extend its contract and register additional handlers as needed. For complete join/move/resume behavior, use the existing socket starter instead.\n\nRun these commands in an existing project with Redweb declared as an installed runtime dependency and TypeScript installed. Declare/install `ws` explicitly (normally as a development dependency) for the generated network tests. Socket additions also require application-installed Zod as a runtime dependency. The generator reports missing prerequisites; it never installs dependencies or changes your manifest.\n\nThe default source location is the effective TypeScript `rootDir`, with `pages/`, `components/`, or `socket-routes/` beneath it. The default test directory is `test/`. An optional project directory follows the kind/name. Use `--config build.json`, `--source-dir features`, or `--test-dir checks` to select paths relative to that project. Names must be lowercase kebab-case, start with a letter, and contain at most 64 characters.\n\nThe command supports a single emitting TypeScript project using CommonJS, Node16 or NodeNext module settings and standard or legacy decorators. HTML additions require Redweb's automatic JSX runtime. Effective inherited configuration controls inclusion and emission; `--source-dir` chooses placement, **not** the compiler's `rootDir`. Ambiguous placement requires that option or an explicit `rootDir`. Project-reference roots, bundler-only pipelines, bundled output, disabled JavaScript emission, output outside the project, mismatched source/output package module types, and compiled test locations are rejected with guidance. Select the appropriate child project/configuration yourself rather than allowing the command to rewrite a monorepo.\n\nThe planner parses source and performs an in-memory TypeScript emit, without importing the application or writing build output. It checks the prospective module, its actual emitted path (including imported source dependencies), and whether an inferred root would relocate existing output. It rejects a test directory that TypeScript would compile when `allowJs` is enabled. This is not a replacement for a whole-project build or its existing tests. The virtual-file matcher uses a feature-checked TypeScript runtime API; unsupported compiler shapes fail explicitly rather than guessing glob behavior.\n\n`--dry-run` writes nothing; `--json` returns a versioned report with planned/created paths, source/output/test paths, a named import, `registration.status: \"pending\"`, and explicit build/test argument arrays. Human commands are quoted for PowerShell on Windows and a POSIX shell elsewhere. Run the reported build and then its test from the project root. The test imports **only the generated artifact**, starts an isolated loopback server on a temporary port, and exercises a real HTTP/WebSocket action or message exchange. It never imports the existing application entry point.\n\nRegistration is intentionally your next step. Add a page to the existing `start([...])` list; add a socket route to the server's route list. For components, create an owned field (`widget = new NotificationsComponent()`) and render `{this.widget}`. Adjust the report's project-root-relative named import to the file where you use it; Node-compatible imports use the emitted `.js` extension. No imports, registration lists, package scripts, manifests, or configuration files are rewritten. Add the new test to your project's normal test command yourself; a generated test is not claimed to be automatically registered.\n\nThe shared writer rejects any destination conflict before writing and creates files exclusively. It rejects path escapes, unsafe portable names, case aliases and symbolic-link ancestors. Concurrent failures report which files were completed and which path was attempted; writing is not transactional and does not lock the filesystem tree. Existing application files are never overwritten.\n\n## Initialize a project\n\nFollow a [complete recipe's version-specific setup](/docs/reference/0.13.2/getting-started.md#start-with-a-complete-recipe). Its commands initialize a new directory, install the matching release or packed artifact, run tests, and start development. The unreleased channel requires the same tarball for initialization and installation; ordinary `npx redweb` does not select this checkout.\n\nThe initializer creates missing files only. It does not install dependencies, run package scripts, or validate existing source code. A message saying initialization completed means the file operation completed, not that a preserved existing project is valid.\n\n`--template realtime|chat|site|socket|dashboard|http-ws` selects a complete runnable recipe. The default is `realtime`, a shared server-owned counter. `chat` includes the canonical reusable chat component, validated actions and its stylesheet; `site` has two non-live pages with a shared layout; `socket` exposes `/match` with separate `join`, `move`, and `resume` handlers, a shared Zod contract, and bounded in-memory sessions. The [dashboard](/docs/reference/0.13.2/recipes/dashboard.md) combines private live cards, SQLite persistence, explicit account provisioning, expiring sessions and account-wide sign-out. It requires Node 22.13+. The [http-ws starter](/docs/reference/0.13.2/recipes/http-ws.md) combines an HTTP health endpoint and a raw socket route on one explicitly owned listener. Each starter includes network tests, build/production instructions, and a development watcher. `--existing` and `--template` cannot be combined. The chat, socket and dashboard starters add Zod; Redweb itself does not require Zod or SQLite at runtime.\n\nDoctor also checks the application's declared `engines.node` minimum (for example `>=22.13.0`). An incompatible runtime produces `PROJECT_NODE_UNSUPPORTED`. More complex ranges produce `PROJECT_NODE_UNCHECKED`, not a guessed success; npm remains responsible for its full engine-range interpretation. CI runs the dashboard acceptance tests on Node 22; older core compatibility jobs explicitly skip that recipe's runtime execution.\n\nRun `npm test` for type checking, asset copying, and real HTTP/WebSocket tests on an ephemeral loopback port. `npm run dev` uses development-only Nodemon to rebuild and restart on changes to `src/` or `tsconfig.json`, enabling loopback-only browser refresh through its `REDWEB_DEV_REFRESH=1` environment. Clean HTML pages refresh automatically; detected edits keep the old document with a confirmation notice. This is not browser hot-module replacement or autosave. A type error prevents startup until corrected; outages alone do not trigger reload. See [development refresh](/docs/reference/0.13.2/development.md#browser-refresh) for draft, connection, hostname and production boundaries. `npm run build` produces runtime code and assets in `dist/`; production needs that directory and installed runtime dependencies, not TypeScript or `src/`.\n\nTemplates come from `recipes/`, with common configuration/test helpers maintained once. The package gate extracts a tarball, generates every template, runs each generated `npm test`, then removes access to `src/` and runs the network tests again to validate production asset resolution.\n\nFor an existing application:\n\n```sh\nnpx --no-install redweb init --existing --dry-run --json\nnpx --no-install redweb init --existing\nnpx --no-install redweb doctor --json\n```\n\n`--existing` creates only a missing `tsconfig.json`; it does not generate a new app, CSS, or package manifest. Adjust the generated source/output directories for your application. An existing `tsconfig.json` is never overwritten, even if it is incompatible.\n\n`--dry-run` does not create files or directories. `--json` reports a versioned result with `operation`, `root`, `created`, `skipped`, and `planned`. The shared file-plan writer preflights all destinations, including planned directory/file conflicts, case aliases and nonportable segments such as Windows device names, alternate streams and trailing dots/spaces. It rejects symbolic links/junctions in the destination's ancestor chain, including above the chosen project root. Exclusive creation prevents overwriting a file created concurrently.\n\nThis is not a transactional installer or a lock on the filesystem tree. An operating-system error during writing can leave completed files, a partial attempted file, or new directories; the error reports completed writes and the attempted destination. Inspect those paths before retrying. Rerunning preserves existing files rather than repairing their contents. Another process must not rename or replace destination directories while generation runs.\n\n## Diagnose without changing the project\n\n```sh\nnpx --no-install redweb doctor --json\nnpx --no-install redweb doctor --port 8181\n```\n\nThe current checks are explicit in the result's `checks` array:\n\n- Node version against the package's current minimum.\n- Redweb installation in the project or its ancestor workspace's `node_modules`.\n- Difference between the invoked CLI and installed library versions.\n- Installed TypeScript (5 or newer) and a root `tsconfig.json`.\n- Effective inherited JSX runtime configuration, syntax/config errors, and legacy-decorator settings.\n- Declared page CSS/templates and duplicate page/route/handler registrations in statically readable TypeScript source.\n- Literal `rw-click`/`rw-submit` names against the owning page/component's public `@action()` methods.\n- Optional temporary bind to `127.0.0.1` to check a TCP port, immediately released on success.\n\nEach finding includes `code`, `severity`, `file`, `message`, and `suggestion`. Source findings also include one-based `line` and `column` when attached to a specific declaration. Error findings produce exit status 1; warnings do not. JSON diagnostic reports go to stdout. Invalid CLI arguments and filesystem failures go to stderr, with exit status 1. `--help` and `--version` require no project.\n\nDoctor loads the installed TypeScript compiler to read configuration and parse source, but never imports or executes the application's modules. It does not perform a full type check, emit files, run application functions/plugins, or apply repairs. These checks do not prove full application correctness or validate every package's semver range. Port availability is a point-in-time loopback check, not a reservation or a test of an external proxy. Dependency discovery currently targets conventional npm-style `node_modules` installations.\n\n## Source checks and their boundaries\n\nThe `source` JSON object reports inspected file count, registration-group count, `mode: \"static-source\"`, and the number of unresolved/limited warnings. It is `null` when configuration or compiler problems prevent source inspection. `checks` lists `source-assets`, `source-routes`, `source-handlers`, and `source-actions` only when the source reader ran.\n\nSupported syntax includes named/namespace TypeScript imports from Redweb, imported local constants, literal strings, constant arrays/objects, known spreads, and simple handler/route constructors. The reader starts with the configuration's source files and follows relative source imports within the project. Declaration files and dependency implementation code are not inspected; an explicitly configured source outside the project can be read, but additional outside-project imports are not followed automatically.\n\nDuplicate paths are checked **within one registration group**, not across independent servers. The reader recognizes `start`, `exportStatic`, `site.export`, `LiveHtmlServer`, `SocketServer`, and `SecureSocketServer`. Handler names are checked in a `SocketRoute` configuration, including classes based on `BaseHandler` and contract handler factories. It does not evaluate arbitrary factory calls, CommonJS destructuring imports, custom boot wrappers, dynamic route additions, or application control flow.\n\nPage assets are checked for registered pages using their decorator's source directory, the owning site's shared-CSS directory, or a statically known explicit `templateRoot`. Shared stylesheet names are deduplicated with site-root precedence, like the runtime. `__dirname` is interpreted as the source directory for this source-only check. Missing assets, directory paths, path traversal, and links escaping the effective root are reported. This does **not** verify compiled/deployed asset copies: keep the starter's build/network tests and production checks.\n\n| Code | Meaning |\n| --- | --- |\n| `TYPESCRIPT_UNSUPPORTED` | Upgrade the project's compiler to TypeScript 5 or newer. |\n| `SOURCE_SYNTAX`, `SOURCE_UNREADABLE` | A configured source could not be parsed or read. |\n| `DUPLICATE_ROUTE`, `DUPLICATE_HANDLER` | A readable registration repeats a path or message type. |\n| `ASSET_UNAVAILABLE`, `ASSET_NOT_FILE`, `ASSET_OUTSIDE_ROOT` | A declared asset cannot be loaded from its effective source root. |\n| `SOURCE_UNRESOLVED` | Dynamic, mutated, escaped, or unsupported source cannot be determined safely. |\n| `SOURCE_LIMIT` | Source count/size or expression expansion exceeded the inspection budget. |\n| `ACTION_NOT_EXPOSED` | A literal binding has no matching public decorated instance method on its statically known owner. |\n| `ACTION_REFERENCE_INVALID` | The literal action name is empty, reserved, missing, or longer than 128 characters. |\n| `ACTION_REFERENCE_UNRESOLVED` | Action names, render output, method exposure, or component ownership cannot be established by the supported source checks. |\n\n### Repair an action binding\n\nIf a button says `<button rw-click=\"saev\">Save</button>` but the class exposes `@action() save()`, doctor reports `ACTION_NOT_EXPOSED` at the binding. Correct the name, run doctor again, then run `npm test`. Doctor never calls the action or executes the renderer to discover it.\n\nAction inspection recognizes decorator aliases, literal names (including imported string constants), inherited methods and overrides, method/function-field renderers, conditional literal returns, and returned JSX/`html` constants. Literal HTML templates use the runtime's lexical tag scanner, ignoring comments and raw-text bodies. External templates are inspected for registered pages at their source asset root and have a separate 1 MiB limit. Page and component owners are checked separately.\n\nThis is deliberately not a JavaScript evaluator or a full template type checker. JSX spreads (including constant objects), custom JSX wrappers, explicit component-scope attributes, HTML entities in action names, interpolated/dynamic HTML, unavailable inherited implementations, custom decorators, and potentially replaced instance methods produce warnings where encountered. Arbitrary function calls, dependency renderers and all runtime-produced nested markup cannot be proved by source inspection. A warning is a request for application/browser verification, not a hidden success. Keep real tests for reusable helpers, scoped components and dynamic output even when doctor exits successfully.\n\n`const` is not treated as proof that an array/object is immutable. Mutated aggregates, aliases that escape into unknown calls, runtime option spreads, custom class decorators, and constructor initialization that can overwrite names/paths produce warnings rather than guessed facts. A normal starter exposes runtime option overrides, so its `templateRoot` may correctly produce an unresolved warning. Green exit status means **no errors among the selected checks**, not that warnings were resolved or the application was proved correct.\n\nSource selection is limited to 256 files and 8 MiB; expression reading is limited to 50,000 operations and 4,096 entries per expanded array. Cycles and repeated spreads cannot expand without limit. The doctor is a read-only diagnostic, not a sandbox for untrusted installed compiler code or a substitute for tests.\n\nThe remaining release work is tracked in [the release acceptance checklist](/docs/reference/0.13.2/release-status.md).\n",
|
|
97
|
+
"url": "/docs/reference/0.13.2/cli.md",
|
|
98
|
+
"sha256": "b328caf4ec064f2dcf0f11bb921742bab1a322d0c1e0208cc7301e2178456c8d"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"id": "development",
|
|
102
|
+
"title": "Develop with browser refresh and safe inspection",
|
|
103
|
+
"summary": "Loopback-only refresh with an explicit edit guard, plus bounded metadata and reactive update inspection without application values.",
|
|
104
|
+
"source": "docs/DEVELOPMENT.md",
|
|
105
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Development refresh and inspection\n\nThis API is available in `redweb@0.13.2`. Use documentation matching the installed package before enabling it.\n\n## Browser refresh\n\nThe generated `npm run dev` command enables browser refresh while rebuilding and restarting your application. No extra application code is needed. For an existing Live HTML application, enable it explicitly:\n\n```ts\nconst app = start(CounterPage, { development: { refresh: true } });\n```\n\nAlternatively, set `REDWEB_DEV_REFRESH=1` only for your development process. Explicit `development: { refresh: false }` overrides that environment flag. Setting `NODE_ENV=development` alone enables neither refresh nor inspection. Both features are refused at construction under `NODE_ENV=production`; changing environment variables after construction is not a mode switch. `npm start` does not set the refresh flag. Keep it out of production environments.\n\nOn direct loopback access, the initial HTML embeds the serving process's revision. The browser polls the same listener sequentially, with a two-second request deadline and one second between completed attempts. Only a valid, different revision triggers refresh. Failed builds, unavailable listeners, malformed responses and redirects do not cause reload loops. A restart before the external script finishes loading is still detected against the revision embedded in the original document.\n\nClean pages reload automatically after a new revision appears. The edit guard conservatively keeps the current document if it observes input/change events, sees differing form defaults when it starts, encounters an editable element with focus, or finds contenteditable content. A native, keyboard-operable notice offers **Reload and discard drafts**. Ordinary untouched selects are compared against the browser's actual reset defaults, not simply their `selected` attributes. Once confirmation is required, resetting or submitting a form does not silently permit automatic reload.\n\nThis is not autosave or a precise unsaved-change detector. False positives are intentional; custom editors or programmatic changes without input/change events may not be detected. The helper does not persist or transmit form contents, write browser storage, replay actions, or restore files/passwords after reload. It keeps the current DOM while waiting for your decision. Manual navigation, browser termination, and confirmed reload can discard drafts.\n\nThe notice uses a shadow root so ordinary reactive root updates preserve it and the application controls' existing focus/draft behavior. Its script, stylesheet and revision fetch are same-origin external resources; your CSP must allow those resources. Navigation away stops polling. History restoration resumes it, including back-forward-cache restoration where the browser supports and chooses it.\n\n### Connections and server state\n\nKeeping the old document does **not** make its old page token/session valid on a replacement process. Existing reconnect rules still apply to a temporary connection outage on the same server. A process restart resets in-memory page/chat/counter state; persistent application data remains the application's responsibility. Actions in flight may have uncertain outcomes. Refresh does not retry them, guarantee completion or migrate state. Reload creates a new page session.\n\n### Access and resource boundaries\n\nRefresh is for direct `localhost`, literal `127.x.x.x`, or `[::1]` URLs at the listener's actual port. It verifies the actual loopback peer, Host, any supplied Origin, and Fetch Metadata. It does not trust forwarding headers or support custom hostnames, tunnels or reverse-proxy origins. Rejected requests receive no refresh bootstrap; this restriction does not make the rest of your application private. An application can still bind publicly unless you separately set `bind`.\n\nEnabled refresh reserves `/__redweb/development`, `/__redweb/development.js` and `/__redweb/development.css`. They reveal only a boot revision, fixed client code and styling—not inspection snapshots, application state or compiler output. Responses and decorated HTML are no-store. There is no extra listener or server timer. Served `live: false` pages support refresh without a live socket client; `exportStatic()` output remains script-free and never enables it from the environment. Raw `SocketServer`/`SecureSocketServer` do not accept the HTML `refresh` option.\n\nThe injected `rw-dev-refresh` element and `__redweb_dev` ID belong to this helper; do not reuse them in application markup. The repository's `npm run verify:development:browser` gate runs actual generated watchers and Chromium against real HTTP/WebSocket listeners, including edited-document confirmation and failed-build recovery. CI runs it separately from the production browser regression gate.\n\n## Inspection\n\nEnable inspection explicitly when starting a development application:\n\n```ts\nconst app = start(CounterPage, {\n port: 8181,\n development: { inspect: true },\n});\n\n// Read this in your development code, debugger, or integration test.\nconsole.dir(app.inspect(), { depth: null });\n```\n\n`SocketServer` and `SecureSocketServer` accept the same option and expose the same `inspect()` method. Without the option, `inspect()` returns `null`. Merely setting `NODE_ENV=development` does not enable inspection. Explicitly enabling it while `NODE_ENV=production` throws before routes or listeners are attached. The environment check occurs at construction; changing environment variables afterward is not a runtime mode switch.\n\nInspection itself is an in-process, read-only API. It does not create an HTTP/debugging route, listener, browser script, background timer, or automatic logger; browser refresh is a separate option and never exposes inspection data. Do not expose inspection results through an application endpoint in production. Your existing `redweb doctor --json` command remains the source/configuration checker; it does not inspect a running process.\n\n## What the snapshot means\n\n`inspect()` returns immutable, versioned JSON-compatible data:\n\n- `pages.registrations`: configured route paths, live/shared flags, class names, decorated action/state names, and descriptions of currently available owned components. It never constructs a page to discover its members. Standard decorator metadata and component fields may not exist before first construction, so `instanceMetadata: \"unobserved\"` means an incomplete inventory, not “this page has no actions.” Static or expired pages can have no current instance even when class metadata was observed previously.\n- `pages.connections`: separate counts for pending HTTP-created sessions, connected sockets, closing transports or disconnect hooks in progress (`detaching`), and disconnected sessions retained for reconnect. An attached socket that is no longer open is not yet a retained/reconnectable session. Shared pages appear once in each registration's instance list even when several visitors use them.\n- `pages.sessions`: bounded per-render descriptions using inspector-local numeric IDs. These are not page tokens, credentials, principal IDs or socket IDs. A reconnect to the same retained page session keeps its renderer ID.\n- `sockets.routes`: registered socket paths and handler names, registered connection counts, draining status, and room/session counts. Runtime-added routes and handlers appear on the next read. `pendingUpgrades` counts currently tracked handshakes. Room names, session identifiers and stored data are omitted. A registered raw connection is not a promise that every transport is currently open; lifecycle cleanup may be in progress.\n- `history`: the latest reactive state invalidations and flush attempts. A state invalidation lists the member/component name and affected render-owner IDs. An empty affected-owner list means no current reactive owner read that member. Several invalidations can be batched into one flush.\n\nPage-session and underlying socket counts describe overlapping resources—do not add them together as independent visitors. Counts remain available independently of truncated detail lists. A description failure yields `available: false` for its section without reflecting exception text.\n\n## Render history is not delivery tracing\n\n`flush-started` reports whether the attempt is a reconnect/attach snapshot and which owners were dirty. `flush-completed`, `flush-superseded`, and `flush-failed` describe that attempt, with elapsed milliseconds. Completion can mean unchanged HTML, no transport write, or a transport write that the peer did not receive. It is **not** a delivery acknowledgement. Supersession means a disconnect, disposal or generation change made the attempt obsolete.\n\nHistory does not attribute an invalidation to a particular action: timers, services and application code can also assign state. It does not retain action arguments, state values, HTML, request headers, cookies, query parameters, identities, socket contexts, or exception messages. It does not serialize getters or call action/render/lifecycle callbacks to produce a snapshot. Application accessors replacing declaration fields are skipped; standard action metadata excludes replaced accessor methods. This is not a security sandbox against hostile JavaScript Proxies or global monkey-patching.\n\nThe history observes the reactive TSX renderer's connected update path, starting with socket attachment. Initial HTTP rendering, static output, changes with no connected reactive renderer, and nonreactive template `redweb:state` transport messages are not traced. `pages.sessions[].reactive` distinguishes those sessions. Existing runtime failure diagnostics and application tests remain necessary.\n\n## Bounds and overhead\n\nHistory retains at most 256 entries per inspected server and only primitive metadata; each event's owner list retains at most 100 names. Current page/socket description lists retain at most 100 items each and share a separate 1,000-item budget per snapshot. Lists include `total` and `truncated`. Labels are limited to 128 UTF-16 code units. Names and route paths are application-defined declarations: do not put secrets in them. The local history's `total` is its sequence counter, including any contained recording failure; retained entries may therefore have gaps.\n\nInspection selects a specialized renderer once for the enabled server. Ordinary reactive invalidation, flush, and socket-message paths have no inspector callbacks or added inspection branches. Disabled servers retain the original renderer class. The only ordinary rendering seam is renderer-class selection when a page session is constructed. Enabled inspection deliberately does extra metadata work and allocations; its timings are diagnostic observations, not production benchmarks.\n\nShutdown removes page sessions and connections through the existing lifecycle. The bounded primitive history remains readable while you retain the server object; it does not keep disposed page instances alive. The inspector's local ID table uses weak keys.\n",
|
|
106
|
+
"url": "/docs/reference/0.13.2/development.md",
|
|
107
|
+
"sha256": "1562d7731b77d1da08ffed853b82bafa1a1b6fd96890a099dbd04a2dac34fd74"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "runtime-diagnostics",
|
|
111
|
+
"title": "Understand runtime failures before retrying",
|
|
112
|
+
"summary": "Safe authentication, authorization, validation and application errors, with actual retry and cancellation guarantees.",
|
|
113
|
+
"source": "docs/RUNTIME_DIAGNOSTICS.md",
|
|
114
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Understand failures before retrying\n\nStatus: included in `redweb@0.13.2`.\n\nAuthentication identifies a visitor. Authorization decides what that visitor may do. Validation checks an input's shape. An application failure means server code or a dependency failed; it is not evidence that the visitor supplied bad credentials.\n\nRedweb keeps these boundaries separate. It does not automatically retry failed actions, undo application writes, or guarantee exactly-once delivery.\n\n## Reading a failed connection\n\nBefore a WebSocket opens, Redweb sends an HTTP rejection with a fixed `Redweb-Error` header and `Cache-Control: no-store`. There is no error body and no callback exception text. Node's `ws` client can inspect it:\n\n```typescript\nimport WebSocket from 'ws';\n\nconst socket = new WebSocket('ws://127.0.0.1:8181/match?redwebVersion=1');\nsocket.on('unexpected-response', (_request, response) => {\n console.error(response.statusCode, response.headers['redweb-error']);\n response.resume();\n socket.terminate();\n});\nsocket.on('error', () => console.error('Connection did not open.'));\n```\n\nThis is a Node diagnostic example, not browser code. Native browser WebSocket JavaScript cannot inspect handshake status or response headers. Use the browser network inspector during development and your application's normal HTTP sign-in/status flow for user-facing guidance. A generic browser socket error alone cannot distinguish rejected credentials from networking, origin, protocol, or server failures. Proxies may replace or strip responses.\n\n| Code | HTTP status | Meaning and next step |\n| --- | --- | --- |\n| `REQUEST_INVALID` | 400 | The upgrade request cannot be represented safely. Correct the request or server middleware. |\n| `AUTHENTICATION_REQUIRED` | 401 | Identity was rejected, or the page session is missing, expired, already attached, or mismatched. Obtain valid credentials/a fresh page; do not retry the same rejected credentials in a loop. |\n| `ORIGIN_DENIED` | 403 | Browser origin was missing or not allowed. Correct the trusted-origin configuration; do not disable origin checks to hide the failure. |\n| `ACCESS_DENIED` | 403 | The page permission policy denied access. Obtain permission before retrying. |\n| `PLACEMENT_DENIED` | 403 | Placement explicitly rejected the connection. Follow application placement rules. |\n| `PROTOCOL_UNSUPPORTED` | 426 | Negotiation requires a supported version. `Redweb-Versions` lists supported versions; use a compatible client and contract. |\n| `AUTHENTICATION_FAILED` | 500 | The rendered-page identity callback failed. Investigate the application or identity provider. |\n| `ADMISSION_FAILED` | 500 | Admission/origin/placement code, a page upgrade policy, or the upgrade pipeline failed. This is not a bad-password response. |\n| `PLACEMENT_INVALID` | 500 | Placement returned an unsafe or disallowed redirect. Repair the server-side placement result/allowlist. |\n| `AUTHENTICATION_TIMEOUT`, `ACCESS_TIMEOUT`, `ADMISSION_TIMEOUT` | 503 | The relevant stage exceeded its deadline. Check the dependency and use bounded reconnect backoff only when appropriate. |\n| `AUTHENTICATION_CANCELLED`, `ACCESS_CANCELLED`, `ADMISSION_CANCELLED` | 503 | The relevant lifetime ended. Start a new permitted attempt rather than reusing a revoked page/session. A disconnected peer may receive no response. |\n| `ACCESS_CAPACITY`, `ADMISSION_CAPACITY` | 503 | Bounded authorization/admission work or connection capacity is exhausted. Wait and back off; do not retry in a tight loop. |\n| `SERVER_DRAINING`, `ROUTE_UNAVAILABLE` | 503 | The service is draining or the route is not ready. Reconnect to a ready instance according to the application's routing policy. |\n\nAccepted placement redirects remain HTTP 307 with the validated `Location`, no error code, and no-store caching. A redirect is not proof that the destination will admit the same credentials. Never forward credentials to arbitrary redirect destinations.\n\nRaw route authentication preserves its existing contract: only literal `false` rejects the identity; application-owned principal objects remain supported. Page authentication requires its documented primitive identity. Do not rely on a raw callback returning `undefined` to deny access.\n\n## Page requests\n\nPage HTTP failures return `{ \"error\": { \"code\": \"...\", \"message\": \"...\" } }` with `Cache-Control: private, no-store`. Authentication and authorization use the categories above. `PAGE_CAPACITY` is 503; `PAGE_FAILED` is a sanitized 500 for construction, loading, or rendering failures, including public pages. Unknown application error text and Express development stacks are not returned. Typed errors are reconstructed from the fixed catalogue rather than trusting mutable status/message fields.\n\nIf the response is already closed, Redweb does not write another response. If headers were already sent by application middleware, the connection is closed instead of appending a misleading JSON error. Redweb cannot retract content your middleware already sent or sanitize arbitrary HTTP routes you mount yourself.\n\n## Actions and established sockets\n\nOnce connected, failures use the existing protocol error envelope and request ID when available. Unversioned room-entry failures use `{ code, error }`; other legacy unversioned failures retain `{ error }` without a structured code. The client may still disconnect before receiving the response. Typed permission/input errors are normalized again at the final send boundary, so application catch/rethrow code cannot accidentally expose appended private exception text.\n\n| Boundary | Diagnostic | What Redweb guarantees |\n| --- | --- | --- |\n| Action input | `ACTION_INVALID_INPUT` | The action method was not invoked. Correct the form values. |\n| Action input lifetime | `ACTION_VALIDATION_TIMEOUT`, `ACTION_CANCELLED` | Validation did not complete within its lifetime; the action method was not invoked. |\n| Action/room permission | `ACCESS_DENIED`, `ACCESS_TIMEOUT`, `ACCESS_CANCELLED`, `ACCESS_CAPACITY` | That failed permission check did not commit room entry or invoke the guarded action. Existing memberships and prior actions are separate. These responses do not inherently close the socket. |\n| Browser send | `ACTION_OFFLINE`, `ACTION_CAPACITY` | This browser action was not sent. Reconnect or wait before deliberately trying again. |\n| Socket envelope/contract | `INVALID_MESSAGE`, `INVALID_PAYLOAD`, `UNKNOWN_HANDLER` | The requested handler callback was not invoked. Correct the message/contract. These paths generally close the connection; they are not automatic retry signals. |\n| Application/validator/output bug | `HANDLER_FAILED` | The operation failed; application effects may already have happened. Inspect authoritative state before resubmitting. |\n\nAn input validator, identity lookup, or permission callback can itself perform external work. A “method was not invoked” result does not promise those callbacks had no side effects. Keep validators and policies free of writes where practical; use explicit idempotency keys and durable transactions for application operations that may be retried.\n\nThose non-invocation guarantees describe failures produced by Redweb's validation and permission gates. They do not apply to application code deliberately throwing an internal typed error or sending the same diagnostic after its own work has begun.\n\n## Deadlines, cancellation, and disclosure limits\n\nRaw admission shares the bounded-operation implementation used by other policy/validation paths. It checks the deadline between origin, identity, and placement stages, so a timed-out or cancelled stage cannot start the next stage after eventually returning. It retains its actual evaluation promise in admission accounting until that evaluation settles. Synchronous JavaScript cannot be interrupted; a callback that blocks the event loop delays timeout observation, but an overdue result cannot admit a connection.\n\nPage identity and permission evaluation also have their own deadlines and session/revocation signals. The outer raw-admission deadline does not forcibly stop those nested callbacks or their external I/O. Cancellation of observation is not cancellation of database/network side effects. Honor available signals, set downstream timeouts, and never treat this as a sandbox for untrusted callback code.\n\nDefault handler responses are sanitized. Raw routes deliberately configured with `exposeErrors: true` opt into disclosing handler exception text; do not enable that in production. Existing application/logger hooks may receive original errors and client metadata, so logs require access controls and redaction. The new upgrade pipeline logs only fixed admission failure details, and a throwing logger cannot prevent upgrade rejection or reservation cleanup.\n\nSee [private rooms](/docs/reference/0.13.2/room-authorization.md), [socket contracts](/docs/reference/0.13.2/socket-contracts.md), and [operating socket services](/docs/reference/0.13.2/operations.md) for their complete limits. A successful `send` means accepted by the local transport, not acknowledged application delivery.\n",
|
|
115
|
+
"url": "/docs/reference/0.13.2/runtime-diagnostics.md",
|
|
116
|
+
"sha256": "edbb5c7a9d2aba30865c57a4e2fe32721949f7f4abf96f7cc07af3229a5cd25f"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "agent-access",
|
|
120
|
+
"title": "Optional read-only agent documentation access",
|
|
121
|
+
"summary": "Configure local MCP search and exact recipe retrieval without adding dependencies to Redweb servers.",
|
|
122
|
+
"source": "docs/AGENT_ACCESS.md",
|
|
123
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Optional Redweb documentation MCP adapter\n\nRead-only, local stdio access to one explicit Redweb documentation catalogue. This integration is kept separate from the Redweb package: ordinary HTTP/WebSocket servers do not install or import its SDK dependencies. It currently runs from this checkout and is private/unpublished; do not assume a public npm adapter exists.\n\n## Set up\n\nRequires Node 22 or newer and a Redweb source checkout containing `integrations/docs-mcp`. From the checkout root, run `npm ci --prefix integrations/docs-mcp`, then configure your MCP host to launch `node` with these arguments:\n\n```json\n{\n \"mcpServers\": {\n \"redweb-docs\": {\n \"command\": \"node\",\n \"args\": [\n \"/absolute/path/to/redweb/integrations/docs-mcp/src/main.mjs\",\n \"/absolute/path/to/redweb/docs/generated.json\"\n ]\n }\n }\n}\n```\n\nUse actual absolute paths, including a known Node executable path when the host does not inherit your PATH. Host configuration formats differ; this shows the common stdio shape, not automatic installation into any editor. Choose `docs/releases/<version>.json` instead to serve an immutable release snapshot. The source can also be the catalogue in an extracted Redweb npm tarball; application source is not executed or needed.\n\n## Tools\n\n- `search_docs`: bounded lexical search of titles, summaries, and Markdown; returns up to 20 results, with stable IDs and the selected version.\n- `read_doc`: retrieve Markdown by exact ID; also lists any embedded recipe filenames.\n- `read_recipe_file`: retrieve one of those embedded files, not a filesystem path.\n\nReads return up to 16,000 UTF-16 characters. Follow `nextOffset` until it is null and concatenate `text` to obtain the exact content. A page's Markdown URL is relative to the Redweb documentation site. Every response includes `channel` and `packageVersion`; `unreleased` does not assert that those features exist in the published package carrying that metadata version.\n\nThe adapter reads its explicitly selected catalogue once at startup. It performs no network requests, writes, installs, application execution, or tool-driven filesystem access. Restart it to select updated content. Treat the selected local package/catalogue as trusted input: hashes detect inconsistent content, not authenticity. Startup rejects catalogues whose reported size exceeds 16 MiB; that preflight is not a sandbox against concurrent filesystem mutation. Its SDK transport limits incoming stdio messages to 64 KiB. This is not a public multi-tenant MCP service.\n\nUse `npm run verify:docs:mcp` from the checkout root for unit checks and actual MCP client/server subprocess integration, including both legacy initialization and the pinned 2026-07-28 protocol. The package test requires npm and `tar`; it extracts both package tarballs and installs the adapter's production dependencies from npm's local cache. Run `npm ci --prefix integrations/docs-mcp` first to populate that cache. Coverage applies to this adapter's source, not the SDK or Redweb's browser runtime. Enabling an adapter improves access to documentation; it does not guarantee that an agent discovers or chooses Redweb.\n",
|
|
124
|
+
"url": "/docs/reference/0.13.2/agent-access.md",
|
|
125
|
+
"sha256": "fb075f277963df2cebf2363b8344796c05937c79ee37187e8a2d80c5c22928ab"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"id": "live-html",
|
|
129
|
+
"title": "Pages, components, state, actions, and CSS",
|
|
130
|
+
"summary": "Server-side TSX, automatic updates, keyed lists, lifecycle, and static export.",
|
|
131
|
+
"source": "docs/LIVE_HTML.md",
|
|
132
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Redweb Live HTML\n\nLive HTML is Redweb's decorator-first server-rendering layer. It uses the existing `HttpServer`, `SocketRoute`, admission, protocol, ordering, backpressure, and shutdown implementations rather than maintaining a second network stack.\n\n## TSX rendering\n\nNew pages can return TSX directly. Run `npx redweb init` for a starter project, or extend `redweb/tsconfig.json` from an existing project's root `tsconfig.json`. The preset makes builds and editors use Redweb's dependency-free JSX runtime consistently:\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\"\n },\n \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\"]\n}\n```\n\nRedweb renders TSX immediately to `HtmlFragment` values:\n\n```tsx\nimport { LivePage, action, component, page, state } from 'redweb';\nimport type { Child } from 'redweb/jsx-runtime';\n\nconst Panel = component((props: { title: string; children?: Child }) => (\n <section class=\"panel\">\n <h2>{props.title}</h2>\n {props.children}\n </section>\n));\n\n@page('/counter', { css: 'counter.css' })\nclass CounterPage extends LivePage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <Panel title=\"Server counter\">\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </Panel>\n );\n }\n}\n```\n\nIntrinsic elements, fragments (`<>...</>`), nested readonly arrays, and synchronous function components are supported. Strings, numbers, and attributes are escaped once; null, undefined, and boolean children render nothing. Safe existing `html` fragments compose in either direction.\n\nJSX remains a server renderer rather than a React compatibility layer: no React hooks, refs, hydration, client event functions, or object-style API. Live sessions retain owner-level HTML snapshots and state dependencies for automatic updates; static pages retain no reactive tree and ship no runtime. Use `rw-click`, `rw-submit`, `rw-bind`, and the other Redweb directives for server actions, and use `@page({ css })` or external assets for styling and scripts. Unsafe URL protocols, `on*`, dynamic `style`, `srcdoc`, `srcset`, children on void elements, and executable `<script>` or `<style>` children are rejected.\n\n## Automatic reactive TSX\n\nA decorated state read during `render()` subscribes that page or class component to the state. Changing the property rerenders the affected owners, batches synchronous assignments, and sends changed HTML only. Ordinary expressions such as `{this.count * 2}`, conditional branches, and `.map()` need no state-binding attributes. Function components participate in their enclosing owner's render; use a class `@component()` for an independently stateful boundary.\n\n```tsx\nrender() {\n return <ul>{this.cards.map(card => (\n <li key={card.id}><input name=\"title\" value={card.title} /></li>\n ))}</ul>;\n}\n```\n\nKeys must be stable strings or numbers (at most 256 characters), unique among siblings. Keyed elements and fragments preserve their DOM nodes during moves. Unchanged server values preserve unsent input; a changed server `value` or `checked` attribute intentionally updates the control. Focus and text selection are preserved when their node survives. Removing a keyed item removes its local input state. Unkeyed repeated items do not promise identity across reordering.\n\nSelect controls preserve surviving selected options whose values are unchanged, even when several options have the same value. Replaced options fall back to available matching values without selecting every duplicate. Changed server-authored `selected` defaults intentionally update the selection; reordering the same keyed defaults does not discard a different unsent choice. Use stable option keys when option identity matters.\n\nUpdates use `redweb:patch` with owner patches and any explicit state bindings in one frame. Existing non-TSX pages continue using `redweb:state`. Explicit `data-rw-state` and `rw-bind` directives can coexist with TSX; do not combine a direct binding with a different derived expression on the same element. The runtime reconciles HTML rather than executing browser components. Internal HTML comments delimit components/keys without introducing layout wrappers, including inside table bodies and selects.\n\nState changes remain assignment-driven. Mutating an array or object in place is not observed; assign a new value. `render()` must be side-effect-free with respect to decorated state (writes during rendering throw). Loading, connections, timers, and persistence belong in lifecycle hooks or actions, which are not rerun for UI patches. Hiding an element is not an authorization boundary for its actions.\n\nEach HTTP/page session retains its own request context and snapshots, even when the underlying page state is shared. Reconnect sends a current root snapshot. Disconnect discards unfinished update results; session disposal aborts its render signal and releases snapshots. Async rendering has a five-second limit, and a snapshot tree is bounded to 1 MiB of retained HTML and 1,024 owners. These bounds include nested snapshots, not just visible document size. As with ordinary JavaScript, synchronous application code cannot be preempted; async work should honor cancellation and avoid unbounded operations. A failed update is logged and closes the affected connection instead of emitting partial HTML.\n\nThis layer deliberately owns page concerns only: `@page`, `@state`, `@view`, and `@action`. It does not clone jax.on's `@get`/`@post` controller API. Continue using Redweb's `services` option for ordinary HTTP APIs; a unified controller decorator surface is a separate compatibility decision rather than hidden behavior in the rendering layer.\n\n## Page model\n\nEvery page is a plain class registered with `@page(path, options)`. Extending `LivePage` remains compatible but is not required:\n\n```ts\n@page('/profile', { template: 'profile.html', css: 'profile.css' })\nclass ProfilePage {\n @state()\n displayName = 'Guest';\n}\n```\n\nThe decorators support both TypeScript's current standard decorator emit and the legacy `experimentalDecorators` ABI.\n\nPages use connection scope by default: each rendered browser page receives its own instance. `shared: true` creates one instance shared by every visitor to that page class and is appropriate for intentionally shared state such as a bounded chatroom history. `scope: 'shared'` remains available as the explicit equivalent.\n\n`start(PageClass)` creates the Live HTML server. `@page()` captures its source directory when the module is evaluated, so colocated templates and styles work for unexported classes, CommonJS, ESM, and barrel exports without module scanning. Pass `templateRoot` explicitly only when page assets live in a different directory. Template and stylesheet traversal outside that root is rejected.\n\n## Colocated CSS\n\nDeclare a stylesheet on the same decorator—no Express static middleware or manual `<link>` is required:\n\n```ts\n@page('/profile', { template: 'profile.html', css: 'profile.css' })\nclass ProfilePage {}\n```\n\nFor composed styles, use `css: ['base.css', 'profile.css']`. Paths resolve from the same captured source directory as the template and cannot traverse outside it. Redweb reads each file once at startup, injects stylesheet links into the server-rendered document, and serves the CSS from a content-addressed URL with the correct content type and immutable caching. Remote URLs and static asset hosting remain under the application's control.\n\n## Declarative HTML templates\n\nTemplate files use the ordinary `.html` extension and contain no executable server code:\n\n```html\n<h1>{{ displayName }}</h1>\n<input rw-bind=\"displayName\">\n```\n\n`{{ property }}` creates an inline text binding. For context-safe container updates, bind an existing element; this is especially useful when a value contains several list or table children:\n\n```html\n<ul data-rw-state=\"messages\"></ul>\n```\n\nDuring SSR Redweb fills the bound element with the current property value, and subsequent assignments to a decorated `@state()` property update the same element.\n\nOrdinary values are escaped during SSR and applied with `textContent` in the browser. The `html` tagged template returns an explicit `HtmlFragment`; its interpolations are escaped, while the resulting fragment may be applied as HTML.\n\n## Rendering collections\n\nKeep collection data as an ordinary array and decorate the method that renders one item:\n\n```ts\n@state()\ncards = [{ title: 'Sword' }, { title: 'Shield' }];\n\n@view('cards')\ncard(item: { title: string }) {\n return html`<article class=\"card\"><h2>${item.title}</h2></article>`;\n}\n```\n\nPlace the collection in the template with `<section rw-each=\"cards\"></section>`. Redweb server-renders every item, escapes interpolated values, and replaces the collection contents when the array is reassigned. View methods are synchronous and must return an `HtmlFragment`. Arrays of fragments also compose naturally inside `html`, such as ``html`<div>${items.map(renderItem)}</div>` ``.\n\nFor a small, auditable safety model, primitive values may be interpolated into quoted attributes. URL-bearing attributes additionally pass through Redweb's safe-URL policy. The explicit `attribute()` and `url()` wrappers remain supported when they improve intent. Interpolation in event handlers, inline styles, `srcdoc`, `srcset`, `<script>`, and `<style>` remains prohibited.\n\n## Reusable components\n\nFor stateless snippets, pass a render function directly to `component()`:\n\n```ts\nconst Badge = component((properties: { label: string }) =>\n html`<strong class=\"badge\">${properties.label}</strong>`\n);\n```\n\nFunction components are synchronous and must return `html`. Use a decorated class when a component needs state, actions, or lifecycle hooks.\n\nDecorate a plain class with `@component()` to give a reusable HTML snippet its own server state, actions, and lifecycle. Store component instances in page fields and interpolate them like any other safe HTML fragment:\n\n```ts\nimport { action, component, html, page, start, state } from 'redweb';\n\n@component()\nclass Counter {\n @state()\n count = 0;\n\n constructor(private readonly label: string) {}\n\n @action()\n increment() {\n this.count += 1;\n }\n\n render() {\n return html`\n <article>\n <h2>${this.label}</h2>\n <output data-rw-state=\"count\">${this.count}</output>\n <button rw-click=\"increment\">Increment</button>\n </article>\n `;\n }\n}\n\n@page('/')\nclass Dashboard {\n primary = new Counter('Primary');\n secondary = new Counter('Independent');\n\n render() {\n return html`<main>${this.primary}${this.secondary}</main>`;\n }\n}\n\nstart(Dashboard);\n```\n\nThe field path is the component's public protocol namespace, so both counters can expose `count` and `increment` without collisions. Browser events carry that visible namespace and the server resolves it through its component registry; client-supplied object paths are never evaluated. It is routing metadata, not an authorization boundary—component actions must enforce the same application authorization as page actions. Components may contain other decorated components, and state updates retain the complete nested namespace.\n\nComponent instances are owned by exactly one construction-time page or component field. Their synchronous `render(context)` method may return a safe `HtmlFragment` or a declarative template string; request context is propagated per render, including on concurrent shared pages. Components receive the same `loading`, `connected`, `disconnected`, and `disposed` hooks as their page, including the authenticated principal and cancellation signal where applicable. Disposal starts every child and owner cleanup together and preserves every settled failure, so one broken sibling cannot starve later hooks.\n\nRedweb scopes only elements that carry a state, binding, or action directive; it does not add layout wrappers or inline styles. Components therefore remain valid in restricted contexts such as tables and selects and work with strict `style-src` policies.\n\n### Safe attributes and links\n\nDynamic document navigation remains explicit:\n\n```ts\nimport { attribute, html, url } from 'redweb';\n\nconst section = { id: 'socket-server', name: 'SocketServer' };\nconst markup = html`\n <article id=\"${attribute(section.id)}\">\n <a href=\"${url(`#${section.id}`)}\">${section.name}</a>\n </article>\n`;\n```\n\n`attribute()` accepts primitive values and is valid only inside a quoted non-URL attribute. `url()` explicitly brands URL-bearing attributes such as `href`, `src`, and `action`; direct string values receive the same validation. Redweb permits relative URLs plus HTTP, HTTPS, mail, and telephone URLs, while rejecting control characters, protocol-relative URLs, and executable schemes. Both wrappers are escaped when rendered and are rejected in element text.\n\n### Nested components and code\n\nPlain functions returning `html` fragments are reusable server components. `each()` validates and joins arrays of those fragments, including nested lists:\n\n```ts\nimport { codeBlock, each, html } from 'redweb';\n\nconst method = (entry: Method) => html`\n <section>\n <h3>${entry.name}</h3>\n <p>${entry.description}</p>\n ${codeBlock(entry.usage, { language: 'ts', label: 'TypeScript' })}\n </section>\n`;\n\nconst reference = each(apiSections, section => html`\n <article>\n <h2>${section.name}</h2>\n ${each(section.methods, method)}\n </article>\n`);\n```\n\n`codeBlock()` escapes strings by default. It may also receive an explicit `HtmlFragment`, or a `highlight(source, language)` callback that returns one, allowing a server-side highlighter to compose safe token spans without accepting arbitrary HTML strings.\n\nAn `HtmlFragment` returned from `render()` is already fully composed and is never reparsed for `{{ bindings }}` or directives. This keeps code samples literal and prevents escaped documentation text from becoming executable template syntax. Return a string or use `template` when Redweb should process declarative bindings.\n\nState observation is deliberately shallow. Assigning a new value publishes an update; mutating a nested object or array in place does not. Reassign after nested changes:\n\n```ts\nthis.players = [...this.players, player];\n```\n\n## Browser actions and input\n\nOnly methods decorated with `@action()` may be invoked by the browser:\n\n```ts\n@action()\nsave(form: { displayName: string }) {\n this.displayName = form.displayName;\n}\n```\n\n```html\n<form rw-submit=\"save\">\n <input name=\"displayName\">\n <button>Save</button>\n</form>\n```\n\n`rw-click=\"action\"` prevents default navigation and invokes an action without arguments. `rw-submit=\"action\"` prevents submission, passes form fields as the first argument, preserves duplicate field names as arrays, and resets only an unchanged, still-connected form after the server acknowledges success. `rw-bind=\"property\"` sends text values or checkbox state only when that property was declared with `@state({ writable: true })`.\n\nWhen an HTML-valued component state renders new actions or bindings, Redweb automatically scopes those directives back to that component. A component can therefore replace a join form with a composer—or swap any other interactive view—without manual component IDs or browser glue.\n\nThe document emits `redweb:connection` events as transport state changes and `redweb:error` events when an interaction fails. Interactions require an open connection; they are not queued during initial connection or reconnect. Actions use request/response operations and are never automatically replayed.\n\nNames such as `constructor`, `prototype`, and `__proto__` are rejected. Arbitrary methods and undeclared state cannot be reached through the Live HTML protocol.\n\n## Lifecycle\n\nPages can implement these optional hooks:\n\n- `loading(context)` runs before SSR and receives the portable page request, params, query, body, and shutdown `signal`.\n- `connected(context)` runs after the page's authenticated socket connects and receives the socket and cancellation signal.\n- `disconnected(context)` runs when that socket closes and may be asynchronous.\n- `disposed()` runs once when a connection-scoped page expires or the server shuts down and may be asynchronous.\n\nTimers and subscriptions created by a page should be owned by that page and stopped in `disconnected()` or `disposed()`. `dispose()` is idempotent.\n\nShutdown aborts the render signal and waits up to `shutdownTimeoutMs` (one second by default) for active `loading()` and `render()` hooks. If a hook ignores cancellation, Redweb disposes its page, force-closes the affected HTTP connection, completes the remaining cleanup phases, and then reports the timeout.\n\nLive HTML shuts down sockets, page resources, and its owned HTTP listener in successive phases. `shutdownTimeoutMs` bounds phases rather than imposing one total wall-clock deadline. The final HTTP phase also waits up to this duration before destroying remaining TCP peers, including incomplete HTTP requests and unfinished TLS handshakes. This applies to both static and live pages, even when native listener close has already started. Successful forced transport closure does not prove that application work completed, data was persisted, or a response reached its client. Cleanup failures remain reported after the other phases are attempted. Applications must separately close their database handles, workers, and other resources; arbitrary synchronous work cannot be preempted by a JavaScript timer.\n\nHTTP rendering produces an unpredictable page ID. The browser presents it during a same-origin, versioned WebSocket upgrade. Pending and disconnected sessions expire, the registry is bounded by `maxSessions`, and a page ID cannot own two active sockets simultaneously.\n\nFor authenticated pages, provide `authenticate(request)`. It runs for both the HTTP render and WebSocket upgrade and must return the same stable primitive identity (commonly a user ID) for both requests. A missing, rejected, changed, or object identity is denied, preventing a copied page token from crossing authentication boundaries. The identity is available as `context.principal` in page hooks and actions.\n\n## Validated action inputs\n\nUse the same Standard Schema v1 validators supported by socket contracts to validate a form once, at the server boundary. Redweb adds no runtime schema-library dependency; install your chosen validator in the application (`npm install zod` for this example).\n\n```tsx\nimport { action, page, start, state, type ActionInput } from 'redweb';\nimport { z } from 'zod';\n\nconst input = z.object({\n amount: z.string().regex(/^\\d+$/).transform(Number).pipe(z.number().int().min(1).max(1000)),\n}).strict();\n\n@page('/')\nclass AmountPage {\n @state() total = 0;\n\n @action({ input })\n save(value: ActionInput<typeof input>) {\n this.total += value.amount;\n }\n\n render() {\n return <form rw-submit=\"save\">\n <label>Amount <input name=\"amount\" /></label>\n <button type=\"submit\">Add</button>\n <output>{this.total}</output>\n </form>;\n }\n}\n\nstart(AmountPage);\n```\n\nThe browser sends form values as one object (repeated names become arrays). The schema converts `amount` from its submitted string to an integer between 1 and 1,000, rejecting overflow and out-of-range values after conversion. `ActionInput<typeof input>` describes that transformed result; TypeScript cannot infer a method parameter annotation from its decorator. An optional second `LivePageConnectionContext` parameter receives trusted server context, never a caller-supplied replacement. Both standard and legacy TypeScript decorators are supported, including scoped component actions. A validated action accepts exactly one submitted argument; ordinary `@action()` retains its existing argument behavior.\n\nInvalid input produces `ACTION_INVALID_INPUT`, does not invoke the method, and leaves the socket open for correction. The browser reports it through `redweb:error` and does not reset the failed form. Validator exception details, submitted values, and raw schema issues are not returned to the browser. A throwing validator or malformed validator result remains a sanitized `HANDLER_FAILED` server error, not a recoverable user mistake.\n\nValidation has a five-second default deadline; override it with `@action({ input, validationTimeoutMs: 500 })`. A validation deadline produces `ACTION_VALIDATION_TIMEOUT`; an interrupted validation produces `ACTION_CANCELLED` if the connection can still receive a response. Neither invokes the action. Disconnects and disposal prevent an outstanding validation result from starting application code later. These limits apply to validation, not to an action that has already started: Redweb cannot undo its side effects, preempt synchronous JavaScript, or stop external work inside a validator. It does not automatically retry actions. Prefer pure validators, and implement application-specific cancellation/idempotency where needed.\n\nThe same bounded validation implementation is shared with socket contracts. Their existing `INVALID_PAYLOAD` contract error behavior is unchanged. Automatic action feedback reports safe form-level messages, not raw validator issues or field-level messages.\n\n## Action authorization\n\nIdentity and permission are separate: the server's existing `authenticate(request)` hook establishes `context.principal`; an action policy decides whether that identity may perform this operation. In Redweb 0.13.2, add `authorize` to the action decorator instead of repeating permission checks inside each method:\n\n```tsx\n// Inside a page/component; `input` is the amount schema from the example above.\n@action({\n input,\n authorize: (context, value) => context.principal === 'owner' && value.amount <= 10,\n})\nsave(value: ActionInput<typeof input>) {\n this.total += value.amount;\n}\n```\n\nThe policy receives **trusted context first, transformed input second**. Only `true` permits invocation. The check runs after validation on every invocation, so a permission change during asynchronous validation is visible to the policy. Both standard and legacy decorators and component-scoped actions follow the same path. The literal owner check above illustrates the API, not an authentication system: applications must verify real credentials in `authenticate`, query their own current permissions, and enforce database ownership/transaction rules.\n\nFor a button without a schema, use `@action({ authorize: context => context.principal === 'owner' })`. Such methods use the fixed signature `run(input: unknown, context: LivePageConnectionContext)`; with no submitted payload, `input` is `undefined`. A caller can supply at most one untrusted input, never replace the second context argument. Use a schema whenever you inspect submitted values. Ordinary `@action()` keeps its existing variadic behavior.\n\nPolicies may be asynchronous. `authorizationTimeoutMs` defaults to 5,000 ms and requires an `authorize` callback. This deadline is separate from `validationTimeoutMs`; neither bounds a method that has started. The policy's `context.signal` aborts when the connection closes or the permission deadline expires. Pass it to cancellable application operations. Redweb cannot preempt synchronous code, cancel work that ignores the signal, undo policy side effects, or make a policy check and later database write atomic; keep policies read-only and enforce transactional authorization in storage where required. An overdue or cancelled result cannot invoke the action later. Page disposal also prevents invocation, but does not itself stop ongoing external policy work.\n\nDenial returns recoverable `ACCESS_DENIED`; timeout returns `ACCESS_TIMEOUT`; connection cancellation returns `ACCESS_CANCELLED` when a response can still be delivered. None invokes the action. Built-in feedback shows safe text and retains the draft. A thrown/rejected policy is a sanitized `HANDLER_FAILED` application failure, not a permission denial, and must be investigated rather than blindly retried. Authentication/permission secrets and submitted values are never included in these protocol errors.\n\n**An action policy protects action invocation only.** It does not protect HTTP rendering, loading hooks, writable state, room publication, or passive subscriptions. Use the page policy and explicit session revocation below for page access. Shared page state is shared across identities, not private per user. Durable dashboard and room-policy recipes remain separate acceptance items.\n\n## Protected pages and shared request identity\n\nIn Redweb 0.13.2, a page can declare `authorize(context)` alongside its route. This is an API pattern for an application that already supplies the server's `authenticate(request)` hook, not a standalone login system:\n\n```tsx\n@page('/account/:id', {\n authorize: ({ principal, params }) => principal === params.id,\n authorizationTimeoutMs: 500,\n})\nclass AccountPage {\n render(context: LivePageRequestContext) {\n return <h1>Account {context.params.id}</h1>;\n }\n}\n```\n\nRedweb reserves render capacity, captures the request, resolves identity, and checks permission **before constructing this page or running its loading hooks**. Only `true` allows access. Protected pages require connection scope; `shared: true`/`scope: 'shared'` are rejected because a shared mutable instance is not private per identity. Keep public shared counters/chat state separate from private account state. Page policies are checked again on socket admission/reconnect, immediately before actions (after input validation and action authorization), and before browser-writable state changes. Returning false denies that operation; it does not automatically disconnect idle viewers.\n\n`authenticate(request)` still receives the real HTTP/upgrade request, so it can use the application's existing cookie/session/token implementation. It must verify credentials and return a primitive identity: string, finite number, bigint, or `true`. False/null/undefined and objects/functions/symbols/non-finite numbers are rejected. An upgrade must authenticate as the same identity that rendered its page token. `authenticationTimeoutMs` defaults to 5,000 ms and requires an authentication hook. A timeout prevents later admission, but cannot stop external work inside that hook. Redweb does not provide credential storage, login endpoints, or distributed session invalidation.\n\nLoading/rendering, connected/disconnected hooks, and actions share the original HTTP page's `request`, `params`, `query`, `body`, and `principal`. `LivePageConnectionContext` extends `LivePageRequestContext` and adds `socket`; its signal belongs to the current connection. The request does not become the upgrade URL when reconnecting. It is a deep-frozen copy of supported fields, not an Express request: path, URL, method, headers, params, query, JSON-compatible body, and a case-insensitive header `get()`. It never retains an Express response/socket graph or freezes application-owned objects. Nested data has a depth limit of 16 and a conservative 64 KiB aggregate budget, including per-value overhead; arrays are additionally limited to 8,192 entries. Dates, functions, and other unsupported body values must be normalized by application middleware. Header values, including credentials, remain private server-side data; do not render or log them unnecessarily.\n\nDenied HTTP authentication returns `AUTHENTICATION_REQUIRED` (401); authentication timeout/cancellation return `AUTHENTICATION_TIMEOUT`/`AUTHENTICATION_CANCELLED` (503); authentication hook failures return sanitized `AUTHENTICATION_FAILED` (500). Page permission denial is `ACCESS_DENIED` (403), with bounded policy timeout/cancellation at 503. Broken policies or protected-page application errors return sanitized `PAGE_FAILED` (500). Protected responses, including errors and non-live pages, are `private, no-store` and never use conditional 304 responses. `exportStatic()` and `defineSite().export()` reject authorized pages before construction or final output writes.\n\n## Explicit session revocation\n\nAfter invalidating a credential or changing permissions in your own authority, call `await server.revoke(principal)` before publishing further private updates. `server` is the object returned by `start()`. This revokes matching rendered page tokens, live connections, and unfinished renders in this process. It is not a permanent identity denylist: a later HTTP request may establish a new session only if your authentication and page policy still allow it. Coordinate revocation across every application instance yourself.\n\nAll affected lifetimes are marked unavailable and their transports stopped synchronously, before application-visible abort listeners or cleanup hooks run. Therefore an abort listener cannot publish a final framework state update to another affected connection. Old page tokens cannot reconnect, and late authentication, policy, loading, connection-hook, validation, or render completions cannot restore them. In-flight identity lookups whose principal is not yet known are conservatively cancelled too; an unrelated in-progress login may need retrying. The returned number counts affected page sessions/render operations, including those unresolved lookups, not unique people or sockets.\n\nApplication disconnect/disposal cleanup is awaited up to `shutdownTimeoutMs`. `REVOCATION_CLEANUP_FAILED` means access has already been revoked but cleanup rejected or exceeded the deadline; it never restores access. Revocation cannot retract data already sent/buffered, roll back application side effects that already started, or cancel external work that ignores its signal. Ordinary network disconnect cancels connection work but preserves eligible session state for reconnect; explicit revocation permanently invalidates that page token. Abandoned HTTP requests cancel their render lifetime and release framework capacity even if a loading hook ignores cancellation.\n\nUse `LiveHtmlStartOptions` for wrappers around `start()`; it preserves the authentication/timeout constraints without writing `Omit<LiveHtmlServerOptions, 'pages'>`. The starter recipes use this shorter public type.\n\n## Automatic action feedback\n\nExisting `rw-click` buttons and `rw-submit` forms show **Working…**, **Done.**, or a safe error message without custom browser JavaScript. Redweb inserts a plain-text status span at the end of a form or immediately after a click control, with `role=\"status\"` and `aria-live=\"polite\"`. The control and its status have `data-rw-status=\"pending\"`, `\"success\"`, or `\"error\"` for application CSS. Native form constraints still run before submission.\n\nFor deliberate placement, supply a slot in the same component (or page scope):\n\n```tsx\n<form rw-submit=\"save\">\n <label>Amount <input name=\"amount\" /></label>\n <button type=\"submit\">Save</button>\n <output rw-status=\"save\" />\n</form>\n```\n\nThis replaces the automatic span for that action. Slots receive text, never HTML. Redweb preserves authored accessibility attributes; use an `output`, or add an appropriate live-region role to another element. Slots are component-scoped, including wrapper-free/nested components. If several controls in one scope share a slot, the most recently started invocation owns that slot; an older completion cannot overwrite it. Do not combine `rw-status` with a server-rendered state binding on the same node.\n\nEach control allows one pending invocation; repeated clicks/submits from that same DOM node are ignored until it settles. Other controls remain independent, with a fixed page-wide maximum of 32 outstanding actions. This is UI duplicate suppression, not authorization, server rate limiting, or an exactly-once guarantee. Inputs stay editable and controls keep their authored accessibility/disabled attributes. A successful form resets only if its node, action binding, submitted values, and input/change revision are unchanged. New drafts, failed forms, and replacement forms are never cleared by an old response. Use stable JSX keys to preserve the intended node identity during reordering.\n\nFeedback follows surviving nodes through server patches, including replacement status slots; removed controls release their generated status nodes and clear slots they still own. A replacement control does not inherit an old invocation's outcome. The most recently started invocation keeps ownership when controls share a slot, so late results cannot overwrite its feedback.\n\nDisconnected actions are not queued, and actions are never automatically retried. The browser reports a known-unsent action separately from an unconfirmed result. A lost connection, response timeout, or application failure can occur after side effects; the message asks the user to check before trying again. Successful completion confirms the response, not durable persistence. Applications remain responsible for transactions, idempotency, and durable storage. Browser state writes are also not queued while disconnected. `redweb:error` remains available for application-level reporting, and `data-rw-connection` on the document element reflects the current client connection state.\n\n## Browser transport\n\nThe injected module uses the published `redweb-client` package served by the same Redweb listener. It derives `ws:` or `wss:` from the current page, negotiates protocol version `1`, uses one socket per page, delegates DOM events at the document level, and opts into bounded reconnection attempts. Every initial connection and reconnect receives an authoritative state snapshot. Supplying the normal `ssl` option runs both the page and socket over HTTPS/WSS.\n\n## Options\n\n`start(PageClass, options)` accepts normal HTTP options plus the following Live HTML controls. `new LiveHtmlServer({ pages, ...options })` remains available for explicit composition:\n\n- `pages`: non-empty array of decorated class constructors when using `LiveHtmlServer` directly.\n- `templateRoot`: optional root for all `.html` templates and CSS files; when omitted, each page uses the source directory captured by its `@page()` decorator.\n- `livePaths.css`: optional internal URL prefix for generated stylesheet routes; defaults to `/__redweb/css`.\n- `sessionTtlMs`: pending/reconnect session lifetime; defaults to 30 seconds.\n- `maxSessions`: maximum pending plus active page sessions; defaults to 1,000.\n- `maxConcurrentRenders`: maximum simultaneous HTTP page renders, independent of live session occupancy; defaults to `maxSessions`.\n- `shutdownTimeoutMs`: phase-local render/route drain and final owned-HTTP cleanup timeout, not a total application shutdown deadline; defaults to one second.\n- `heartbeat`: optional `{ intervalMs, timeoutMs }` WebSocket liveness policy. Live HTML defaults to a 15-second ping interval and 10-second pong timeout so half-open browsers are disconnected and component `disconnected()` hooks update presence promptly. When a pong first expires, one unreferenced timer gives it an additional `timeoutMs` grace window to reach JavaScript. Pong handling, detach/reattach, and shutdown cancel that owned timer; a peer that remains silent is terminated when it fires. Scheduler latency means `timeoutMs` is a liveness threshold, not a hard wall-clock deadline; use connection and queue limits as the resource bounds.\n- `authenticate`: optional HTTP/WebSocket identity function for binding page sessions to an authenticated principal.\n- `origins`: optional exact origin list or predicate for deployments behind a trusted proxy. Without it, Redweb requires a scheme-and-host match (`http`/WS or `https`/WSS).\n- `livePaths`: optional `{ socket, client, runtime }` internal path overrides.\n\nThe internal paths and application page paths must be unique.\n\n## Verification examples\n\n- `examples/live-html/counter.ts` uses `@page()`, colocated CSS, and `@state()` to prove a connection-owned server timer can update browser state and is stopped on disconnect.\n- `examples/live-html/chatroom.tsx` uses a connection-scoped `@component()` backed by a room service created by `createChatroomPage()`, so separate server instances cannot leak history or names. Visitors join once, receive a stable dedicated composer, see a capped presence list with the total online count, share bounded history, and recover their identity and missed messages after reconnect. Join/send use `@action({ input })` with shared Zod text schemas for normalization, bounds and inferred `ActionInput` types; invalid input gets automatic form feedback before the method runs. The chat starter includes Zod as an application dependency, not a new Redweb runtime dependency.\n- `examples/live-html/cards.ts` uses a shared decorated page, `@view()`, and `rw-each` to prove server-rendered collection SSR, realtime replacement, and persistence across reloads and reconnects while the server is running.\n- `examples/live-html/components.ts` uses two instances of one `@component()` class to prove reusable markup, isolated server state, scoped actions, and component CSS composition.\n- `examples/live-html/jsx-page.tsx` uses Redweb's automatic JSX runtime, a function component, decorated state, and a server action without HTML template strings.\n\nRun the examples immediately with `npm run example:counter`, `npm run example:chatroom`, `npm run example:cards`, `npm run example:components`, and `npm run example:jsx`. Their checked-in JavaScript artifacts are generated from the decorated TypeScript or TSX sources, and every test and package build rejects stale output. The artifacts are launched unchanged by `tests/integration/live-html.integration.test.js` over real loopback HTTP and WebSocket connections. Run the focused gate with `npm run verify:live-html`, or the complete 100% coverage suite with `npm test`.\n\nThese commands assume the cloned repository's development dependencies are installed. For the packed chat example used directly in another application, install `zod` there first; the generated chat starter already declares it. Core Redweb and the counter example remain usable without a validator library.\n\n## Static pages and documentation export\n\nSet `live: false` when a page needs server rendering but no realtime session:\n\n```ts\nimport { exportStatic, page } from 'redweb';\n\n@page('/docs', {\n template: 'docs.html',\n css: ['base.css', 'docs.css'],\n live: false,\n head: {\n title: 'Redweb API reference',\n description: 'HTTP, WebSocket, multiplayer, and Live HTML APIs.',\n canonical: 'https://example.com/docs',\n image: 'https://example.com/og.png',\n robots: 'index,follow',\n },\n cache: { maxAge: 300, staleWhileRevalidate: 3600 },\n})\nclass DocsPage {}\n\nawait exportStatic(DocsPage, { outDir: 'dist' });\n```\n\nNon-live pages contain no page token or browser runtime. When served by `start()`, Redweb skips its WebSocket route, emits an ETag, honors `If-None-Match`, and applies the declared public cache policy. Interactive pages are always sent with `private, no-store`.\n\n`exportStatic()` accepts one decorated class or an array. It requires `live: false`, maps `/` to `index.html` and `/docs` to `docs/index.html`, emits content-addressed CSS beside the pages, and returns frozen lists of written files. It never deletes or cleans the output directory.\n\nFor several pages, define shared defaults once:\n\n```ts\nimport { defineSite, html } from 'redweb';\n\nconst docs = defineSite({\n origin: 'https://example.com',\n css: 'site.css',\n head: { description: 'Redweb documentation' },\n cache: { maxAge: 300 },\n layout: (content, context) => html`\n <body data-path=\"${context.request.path}\">\n <nav>Redweb</nav>\n <main>${content}</main>\n </body>\n `,\n});\n\n@docs.page('/docs', { head: { title: 'Documentation' } })\nclass DocsPage {\n render() { return html`<h1>Documentation</h1>`; }\n}\n\nawait docs.export(DocsPage, { outDir: 'dist', publicDir: 'public' });\n```\n\n`defineSite()` creates runtime-free page decorators, merges and deduplicates shared CSS, inherits head/cache/layout defaults, and derives canonical URLs from `origin`. Shared and page-local styles resolve from the modules that declare them. Layouts receive a trusted page fragment plus the normal render context, run synchronously, and must return `html`. `site.export()` stages a validated, link-free `publicDir` with the rendered pages before touching the destination, rejects case-insensitive public/generated path collisions, never cleans existing output, and includes copied files in its returned `assets` list.\n\nThe request exposed to `loading()` and `render()` is deliberately the portable `LivePageRequest` surface: `path`, `url`, `method`, `headers`, `params`, `query`, `body`, and `get(name)`. HTTP rendering supplies these from Express; static export supplies deterministic empty headers, parameters, query, and body values. Framework-specific Express request methods are not part of the page contract.\n",
|
|
133
|
+
"url": "/docs/reference/0.13.2/live-html.md",
|
|
134
|
+
"sha256": "1f763912362b633ee7e9c42f3fa2a4503385e5cb8c00f059d9711f21d8ce4502"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "socket-contracts",
|
|
138
|
+
"title": "Typed socket routes and handlers",
|
|
139
|
+
"summary": "Shared validation, client/server types, protocol errors, and join/move/resume.",
|
|
140
|
+
"source": "docs/SOCKET_CONTRACTS.md",
|
|
141
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Shared socket contracts\n\nStatus: included in `redweb@0.13.2`.\n\nA contract declares message payloads once. The same schema supplies runtime validation and inferred TypeScript types for senders and handlers. The URL still selects the route (`/match`), and the envelope's `type` selects an individual handler (`join`, `move`, `resume`). No socket decorators or second action dispatcher are required.\n\nStart with `npx --yes redweb@0.13.2 init my-match --template socket`. The complete maintained example lives in [the socket recipe](/docs/reference/0.13.2/recipes/socket.md): [contract](/docs/reference/0.13.2/recipes/socket/files/src/contract.ts), [handlers](/docs/reference/0.13.2/recipes/socket/files/src/handlers.ts), [server](/docs/reference/0.13.2/recipes/socket/files/src/app.tsx), and [real-network tests](/docs/reference/0.13.2/recipes/socket/files/test/app.test.cjs).\n\nSession ownership is separate from room fan-out. For authenticated group delivery,\nsee [room authorization](/docs/reference/0.13.2/room-authorization.md) and the complete\n[shared page/private-room example](/docs/reference/0.13.2/examples/room-access.md).\n\n## One schema, two sides\n\nImport `defineSocketContract` from `redweb/contract` for a shared module, or from `redweb` in server-only code. The standalone entry does not import the HTTP server or Node socket listener. Browser consumers need a bundler capable of consuming the CommonJS package; this is not a native browser script URL or a React integration.\n\n`defineSocketContract(version, schemas, options?)` accepts an object mapping message names to [Standard Schema v1](https://standardschema.dev/) validators. Zod is used by the starter, but is not a Redweb runtime dependency. Use your existing compatible schema library. The version must match the negotiated protocol version, and `error` is reserved for protocol errors. Contracts support 1–256 types, names up to 256 characters, and versions up to 64 characters.\n\n- `contract.handler(type, callback)` returns a `BaseHandler` subclass accepted by `SocketRoute.handlers`. The callback receives `(socket, payload, message)` after validation. Payload and message types are inferred from the schema output. Register one handler per inbound type; declaring an outbound type does not expose a handler for it.\n- `contract.protocol` supplies the immutable `{ versions: [version] }` route configuration. The route requires negotiation: a browser URL can use `?redwebVersion=1`. Contract handlers refuse a socket negotiated to a different version.\n- `contract.client(socket)` wraps an existing browser or Node WebSocket-like object with `send(data)`. It neither opens the connection nor reconnects it. Wait for the socket to open before sending.\n- `client.send(type, payload, metadata?)` validates and sends a JSON envelope. `client.envelope(...)` validates and returns the envelope without sending it. Senders use schema **input** types; receivers get schema **output** types. Metadata supports the existing `requestId` and `sequence` fields.\n- `client.parse(frame)` decodes and validates a response. It accepts text, byte arrays, ArrayBuffers, or a message event containing them. The result is a type-discriminated message union or protocol error. Catch parse failures in asynchronous message listeners.\n- `contract.send(serverSocket, type, payload, metadata?)` validates server output and uses the existing `sendEvent` transport path, preserving backpressure behavior. Its boolean result means the transport accepted the send, not that a peer received or acknowledged it.\n- `contract.parse(type, unknownPayload)` runs validation directly without sending. It returns inferred output; this method alone does not JSON-serialize its argument.\n\n## Validation and wire behavior\n\nSocket payloads use JSON. Declare ISO strings rather than `Date` objects on the wire, and encode bigint values as strings. Top-level `undefined`, bigint, and cyclic values cannot be sent. JSON conversion occurs before sender validation, so the validator sees the representation that a receiver will actually get.\n\nSender validation uses an isolated copy. The transmitted payload remains the original JSON input snapshot even if a validator mutates its argument. Receiver validation produces transformed output for the application. Validators execute on both sending and receiving sides; use deterministic validators and avoid side effects such as writing to a database inside a transform.\n\nValidation accepts asynchronous validators and awaits thenable outputs within the same error boundary/deadline. The default `validationTimeoutMs` is 5,000; configure a positive integer no greater than 2,147,483,647. Overdue validation is rejected, including synchronous work that finishes after its deadline. **This does not preempt synchronous JavaScript or cancel a validator's external work.** Validators are trusted application code, not a CPU sandbox. Keep expensive work out of validation and enforce transport payload/queue/rate limits separately.\n\nInvalid inbound payloads never reach the handler callback. The peer receives sanitized `INVALID_PAYLOAD` and closes with code 1008. Validator diagnostics are not exposed because they may contain private data. Unknown inbound handler types retain `UNKNOWN_HANDLER`; incompatible versions are rejected during negotiation. Ordinary uncontracted routes keep their existing behavior.\n\nInvalid output from `contract.send()` rejects locally. If that rejection escapes a handler, it is an application failure (`HANDLER_FAILED`, close 1011), not a client policy violation. Handle intentional application rejections explicitly if you want a recoverable protocol response; schema validation does not replace authentication, authorization, or game rules.\n\nThe match recipe uses private, in-memory bearer sessions solely to demonstrate join/move/resume. Read its security, restart, expiry, and scaling boundaries before adapting it.\n",
|
|
142
|
+
"url": "/docs/reference/0.13.2/socket-contracts.md",
|
|
143
|
+
"sha256": "9449da796284a5d7a449ddc1fa9770f08ec6ee57735a0dc3c0fdc046be77db1a"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"id": "room-authorization",
|
|
147
|
+
"title": "Private rooms and shared request identity",
|
|
148
|
+
"summary": "Explicit entry policies, bounded authorization, trusted context, publication and revocation.",
|
|
149
|
+
"source": "docs/ROOM_AUTHORIZATION.md",
|
|
150
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Private rooms without custom socket plumbing\n\nA room is a list of subscriptions, not an identity provider. Authentication answers **who is this connection?** A room policy answers **may that identity enter this room?** Only a literal `true` grants entry.\n\nConfigure `rooms.authorize(context, roomId)` on a `SocketRoute`, then use `await socket.enterRoom(roomId)` in the corresponding handler. URL paths still choose routes and message `type` still chooses handlers; there is no second action dispatcher or socket decorator system.\n\n## A complete shared-identity example\n\nThe [runnable page and room example](/docs/reference/0.13.2/examples/room-access.md) uses one authentication function for the rendered page and `/team` socket route. It generates a fresh local-demo bearer credential on each run, has a protected page, exposes a `join` handler, and revokes page sessions and room memberships together. Its exact source is also shown on the generated examples page. The library's package gate compiles it with both standard and legacy decorators, removes its TypeScript source, and verifies real HTTP/WebSocket access, publication, and revocation against the extracted package.\n\nTo run locally, save that file as `src/app.tsx` in an initialized realtime starter, then run `npm run build` and `npm start`. Send the printed `Authorization: Bearer …` header to `http://127.0.0.1:8181/` or `ws://127.0.0.1:8181/team`; the socket accepts `{\"type\":\"join\"}`. The existing starter's counter tests describe a different application, so do not treat them as acceptance tests for your modified app. Never publish the printed token or use this demonstration as a production credential service. A normal browser WebSocket cannot set an Authorization header; use your application's secure cookie/session integration for a browser product. The [dashboard recipe](/docs/reference/0.13.2/recipes/dashboard.md) demonstrates real cookies, persistent accounts and sign-out.\n\n## Entry, publication, and revocation\n\n- `await socket.enterRoom(id)` and `await route.rooms.enter(id, socket)` perform the bounded policy check and then commit membership synchronously. They resolve to `false` when the connection is no longer eligible or a membership limit prevents entry.\n- Existing `socket.joinRoom(id)` and `route.rooms.join(id, socket)` remain synchronous for unprotected rooms. They throw for protected rooms, with guidance to use asynchronous entry; they cannot bypass the guard. Use `enterRoom` for new guarded application code.\n- Denial, timeout, cancellation, and authorization capacity exhaustion reject entry with safe diagnostic codes. The normal handler boundary sends these to the client without disconnecting it. Broken policies remain sanitized application failures, not disguised permission denials.\n- On protected rooms, `socket.roomBroadcast` and `rooms.broadcastFrom(socket, …)` require the sender to be a current, live member. That is a membership check, **not** an application-specific write-role policy. Validate and authorize actions such as moderator announcements separately.\n- `route.rooms.broadcast` is privileged server publication. Do not expose a client-selected room through it without your own authorization. It publishes only to current eligible members and rechecks membership after serialization.\n- A grant lasts until leave, disconnect, replacement, clear, or shutdown. Changing a policy does not automatically unsubscribe existing readers. Invalidate credentials/permissions first, then call `leaveRoom`, `rooms.leave`, or `rooms.leaveAll` on affected connections. Those operations also cancel pending entry. A late policy completion cannot silently rejoin the connection.\n- `leaveAll` removes all of that connection's memberships before firing policy cancellation callbacks. `clear` removes every membership before cancellation. Nested cancellation/clear cannot reopen entry while the outer operation is still cancelling work.\n\nRedweb's `LiveHtmlServer.revoke(principal)` manages its own page lifetimes; it does not automatically revoke custom raw socket routes. The complete example explicitly invalidates its shared credential, removes the raw route's memberships, then revokes page sessions. Application storage and cross-process invalidation remain application-owned.\n\n## One request-context shape\n\n`RequestContext` and `RedWebRequest` are shared public types. Page callbacks receive `LivePageRequestContext`; enabled raw socket features expose `socket.context` as `RedWebConnectionContext`. Both provide a selected request snapshot, `principal`, and cancellation `signal`.\n\nThe request snapshot is captured before raw-route admission code runs. It contains path, URL, method, headers, params, query, body and a case-insensitive `get(name)` helper—no HTTP response, transport, or framework object graph. Its data is deeply frozen and bounded to 64 KiB/16 nesting levels. Raw upgrade paths and repeated query parameters are parsed from the URL. It is not an Express request, and forwarded headers do not become trusted identities automatically.\n\nSocket identity/request/protocol references cannot be replaced; application `metadata` and resumable `session` fields remain mutable. Existing raw admission object identities remain supported and application-owned, not deeply frozen by Redweb. Page identities retain their existing primitive identity contract. Never derive a trusted identity from a client message's `principal` field or mutable application metadata.\n\nEach relevant raw connection has its own signal, cancelled on disconnect, replacement, or route draining. An operation policy receives a separate bounded signal that also cancels on leave or its deadline. Socket context remains optional when all features requiring it are disabled. In Live HTML callbacks, use the supplied callback context for the application identity: the underlying transport's internal page-session principal is not that callback identity.\n\n## Resource limits and failure meanings\n\nProtected rooms retain the existing room/member/name limits and add:\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `authorizationTimeoutMs` | 5000 | Maximum time to wait for one policy. |\n| `maxPendingAuthorizations` | 128 | Underlying policy work across the registry. |\n| `maxPendingPerConnection` | 4 | Underlying policy work for one connection. |\n\nThese options require `authorize`. Concurrent pending requests for the same connection/room share one check. No room or membership is reserved while permission is pending; final insertion rechecks all capacities. Timed-out or cancelled policy work stays charged until the **actual application promise settles**, so an uncooperative policy cannot spawn unlimited background work. Honor the signal and use bounded downstream I/O; policies that never settle can exhaust capacity until corrected/restarted. JavaScript's synchronous execution cannot be preempted.\n\n`ACCESS_DENIED`, `ACCESS_TIMEOUT`, `ACCESS_CANCELLED`, and `ACCESS_CAPACITY` indicate no membership was committed by that failed entry. They do not assert that an external policy had no side effects. Protocol routes return the standard error envelope and request ID; unversioned routes return `{ code, error }`. A policy exception becomes sanitized `HANDLER_FAILED` and follows the normal application-error close behavior. Nothing automatically retries entry or promises exactly-once application delivery.\n\nUse HTTPS/WSS, trusted origins for browser credentials, real session expiry, input limits, and persistent application authorization before public deployment. Process-local rooms and grants do not become distributed merely because a socket route has a distribution adapter.\n",
|
|
151
|
+
"url": "/docs/reference/0.13.2/room-authorization.md",
|
|
152
|
+
"sha256": "306ba26edabca0f9c6bdf60eadf9646731c72a75c93f589a6fea13c52e60bb7b"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "operations",
|
|
156
|
+
"title": "Deploy and operate socket services",
|
|
157
|
+
"summary": "Readiness, shutdown, capacity, reconnection, and distributed boundaries.",
|
|
158
|
+
"source": "docs/MULTIPLAYER_OPERATIONS.md",
|
|
159
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Multiplayer operations\n\nRedweb exposes small composition points and leaves deployment policy to the game. These examples are deliberately infrastructure-neutral.\n\n## Readiness and shutdown\n\nExpose `socketServer.isReady()` from the HTTP stack used by the orchestrator. On termination, call `beginDrain()` first, stop external placement to the node, then call and await `shutdown()`. New upgrades receive `503` after draining begins.\n\n```js\napp.get('/ready', (_request, response) => {\n response.sendStatus(socketServer.isReady() ? 200 : 503)\n})\n\nprocess.once('SIGTERM', async () => {\n socketServer.beginDrain()\n await socketServer.shutdown()\n})\n```\n\nIf `drainHandlers` is enabled, handlers should observe `socket.context.signal` and return promptly. Set the platform termination grace period above the application's maximum cooperative handler time plus `shutdownTimeoutMs`.\n\n## Placement and partitions\n\nThe admission `place(principal, request, context)` hook can return another node's `ws`/`wss` URL before upgrade. Keep placement decisions short-lived and retryable. A redirect is not a reservation: the destination must still authenticate, enforce capacity, and reject stale placement.\n\nTreat the distribution adapter as ephemeral fan-out. During broker or network partitions, pause affected matches, continue from one authoritative owner, or reconcile from durable application state. Do not treat adapter delivery or its bounded deduplication window as persistence.\n\nAdapter lifecycle and publish methods receive an optional final `AbortSignal`. Observe it in broker clients that support cancellation. Redweb compensates late startup and subscription completion, but a publish that the broker has already accepted cannot be recalled.\n\n## Capacity signals\n\nAlert on rejected connections, rate-limited messages, full queues, handler failures, active connections, and readiness. Metrics intentionally contain only the route label. Join high-cardinality player, room, and match diagnostics in application logs or traces under the studio's own privacy and retention policy.\n\nSize `maxConnections`, `maxBufferedBytes`, `maxPendingMessages`, room limits, session limits, adapter event limits, and codec byte limits from measured budgets. Run the included verification scripts on the deployment's Node version and instance class before changing those ceilings.\n\nRedweb limits the number and lifetime of session records, but it deliberately does not inspect application session data. Keep that data small, schema-validated, and free of authoritative state that belongs in durable storage.\n\n## Verification\n\nRun these commands from the matching Redweb source checkout. `npm test` includes unit, real HTTP/WebSocket/WSS integration, fuzz, type-generation, and enforced 100% coverage of the declared library scope; browser, package and verifier-source coverage have separate commands. The additional production gates are:\n\n```bash\nnpm run verify:load\nnpm run verify:memory\nnpm run verify:recovery:server\nnpm run verify:soak\nnpm run verify:overhead -- /path/to/prepared-release-baseline\n```\n\nThe soak defaults to 60 minutes. Shorter durations are useful for CI smoke checks but are not hour-soak acceptance. Its 99% delivery allowance is not a lossless guarantee; inspect actual sent, received and missing counts as well as all resource trends.\n\nPrepare and identify the intended comparison release separately, using the same machine, Node runtime and controlled environment as the candidate. The overhead command does not choose a baseline version for you. The 3% throughput and 5% p99 regression limits remain unchanged; historical 0.8 comparisons do not certify a newer candidate against its previous release.\n\n### Blocking server recovery\n\n`npm run verify:recovery:server` uses the approved `server-steady-v1` contract with separate coordinator, server and native load-generator processes. It preconditions with 1,200 connections, warms with 200, then runs five storms of 1,200 in batches of 50: 7,400 exact exchanges. Phase samples settle for 400 ms and collect twice. Every storm must retain at most 110% of the **same** warmed server heap; client heap is diagnostic, not subject to that server budget.\n\nExact client sends/replies and server receives must reconcile. Measured registries must be empty, input fingerprints unchanged, logs complete, and workers must exit normally with closed output pipes. Forced cleanup cannot produce a pass. A bad middle storm still fails even if the final storm recovers. This finite workload is not proof of an indefinite memory plateau or a resolution of historical shared-process failures.\n\nThe server gate rejects workload overrides, Node flags, nonempty `NODE_OPTIONS` and `NODE_V8_COVERAGE`, including `REDWEB_RECOVERY_*` variables. It creates an exclusive report directory under `coverage/`; an optional absolute, nonexistent destination can follow `--`. Do not use instrumented or snapshot runs as clean memory evidence. CI bounds this command at two minutes and retains available evidence after success or failure.\n\n## Original recovery diagnostic\n\n`npm run verify:recovery` remains a visible **non-blocking** CI diagnostic. It measures server and load-generator work together, so its heap ratio is not the server-focused measurement above. It retains its own exit status and logs and runs in CI only after server acceptance confirms worker cleanup. A diagnostic failure or skip is not reported as a pass.\n\nThe original command defaults to the versioned `steady-v2` protocol: one fixed 1,200-connection preconditioning workload, 200 warm connections, then five 1,200-connection storms, in batches of 50. After each phase it waits 400 ms for expiry, collects twice, and requires empty client/room/session registries. Every storm must retain at most 110% of the **same** shared-process warm baseline. It never moves the baseline, subtracts compiled-code bytes, or repeats a failed run until one passes.\n\nFor this original diagnostic only, `REDWEB_RECOVERY_WARM_CONNECTIONS`, `REDWEB_RECOVERY_STORM_CONNECTIONS`, and `REDWEB_RECOVERY_BATCH_SIZE` select positive safe-integer workload sizes; preconditioning always uses the selected storm size. `REDWEB_RECOVERY_STORM_ROUNDS` can increase the five-round minimum. Reports include the selected protocol, phase heaps, counts and every storm's ratio. Smaller custom traffic is useful for functional checks but is not the fixed server acceptance workload.\n\nSet `REDWEB_RECOVERY_PROTOCOL=cold-v1` to reproduce the earlier unpreconditioned protocol (200 warm connections and one storm by default). Its recorded Node 20 failures remain failures; later steady-protocol results do not rewrite them. The revised warm-up is supported by native heap diagnostics showing substantial compiled-code growth after the earlier baseline and by fixed repeated-storm experiments. See the [acceptance work log](/docs/reference/0.13.2/release-status.md) for exact environments, measurements and outstanding release gates.\n\nFor investigation only, `REDWEB_RECOVERY_DIAGNOSTICS=1` adds native V8 space/code statistics. Combining it with an absolute `REDWEB_RECOVERY_HEAP_DIRECTORY` creates exclusive private warm/recovered snapshot files in an existing directory. Snapshots may contain secrets and introduce additional GC/work: use an isolated process/environment, never upload the raw files, and do not treat snapshot runs as acceptance. `scripts/diagnostics/recovery-heap-summary.cjs` accepts the two files and emits only fixed-label numeric aggregates; delete private snapshots after investigation.\n",
|
|
160
|
+
"url": "/docs/reference/0.13.2/operations.md",
|
|
161
|
+
"sha256": "5773a73a09b484ad321fd5eaa3ed81635ebd471a370c57991468a3bb0e268ee3"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"id": "production-contract",
|
|
165
|
+
"title": "Production guarantees and limits",
|
|
166
|
+
"summary": "Resource ownership, delivery semantics, compatibility, and release gates.",
|
|
167
|
+
"source": "docs/PRODUCTION_READINESS.md",
|
|
168
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Multiplayer production-readiness contract\n\nRedweb remains a small transport foundation. Applications own game rules, authoritative state, matchmaking, databases, and identity providers. Redweb owns bounded connection admission, delivery, grouping, lifecycle, and optional composition points.\n\n## Compatibility invariants\n\n- Every production feature is opt-in.\n- Existing route, handler, and service subclasses require no source changes.\n- The default route, strict routing, IP collision policy, handler dispatch, error hiding, listener ownership, and shutdown behavior remain compatible with 0.8.\n- Disabled multiplayer features create no timers or per-connection queues.\n- No global mutable registry or mandatory infrastructure dependency is permitted.\n- Every timer, listener, queued task, membership, session lease, and adapter subscription has one deterministic cleanup owner.\n- User hooks may be synchronous or asynchronous and may not escape as process-level failures.\n- Cleanup is bounded, idempotent, best-effort, and continues after individual failures. Owned listeners also terminate incomplete HTTP peers at the route deadline; borrowed listeners remain application-owned.\n\n## Delivery claims\n\nWebSocket provides an ordered byte stream while a connection remains healthy. Redweb does not claim exactly-once delivery. Reconnection, distributed adapters, and application retries can introduce loss or duplication; protocol users must use explicitly scoped sequence identifiers when those cases matter.\n\n## Roadmap gates\n\n1. **Bounded transport:** pre-upgrade admission, origin policy, rate limits, slow-consumer enforcement, bounded ordered processing, payload limits, and route-level heartbeat.\n\nThe first observation of heartbeat expiry starts one unreferenced `timeoutMs`\ngrace timer, allowing already-dispatched pong handling to win after a server\nstall; a peer that remains silent is terminated when that timer fires. Pong,\ndetach/reattach, and shutdown cancel the owned timer.\n`timeoutMs` is therefore a liveness threshold subject to event-loop scheduling,\nnot a hard wall-clock resource limit. The grace period owns at most one timer\nper expired connection; bound connections and queues independently.\n2. **Multiplayer grouping:** route-scoped rooms, atomic membership cleanup, bounded session resumption, fixed-step services, and vendor-neutral metrics.\n3. **Horizontal composition:** draining/readiness, adapter lifecycle, loop prevention, bounded validation, placement hooks, and documented partition behavior.\n4. **Protocol and clients:** version negotiation, stable envelopes and error codes, generated client-facing types, binary replication hooks, and operational examples.\n\n## Release gates\n\n- Existing tests and documented examples run unchanged.\n- New behavior has unit tests and mock-free HTTP/WS/WSS integration tests.\n- Coverage remains 100% for statements, branches, functions, and lines.\n- Disabled-feature throughput regression is at most 3%; p99 latency regression is at most 5% on the same machine and Node version.\n- Heartbeat uses one scheduler per route, never one interval per connection.\n- Every queue, retained session, room, adapter backlog, and deduplication window is finite.\n- Broadcast serializes once and remains O(n) in selected recipients.\n- Slow clients cannot grow framework-owned memory without bound.\n- A 60-minute soak shows no monotonic growth in timers, listeners, rooms, sessions, or queues.\n- The blocking `server-steady-v1` reconnect gate requires every storm to retain at most 110% of the same warmed **server** heap after expiry and forced collection. Exact delivery, empty measured registries, unchanged inputs, complete logs and normal worker cleanup are mandatory. Client heap is reported separately; the original shared-process diagnostic remains visible and non-blocking, without relabelling its failures.\n- Readiness becomes false before draining and shutdown completes within its documented bound.\n\nThe independent senior-review gate rejects releases that weaken any invariant, hide ambiguous delivery semantics, add mandatory brokers or identity libraries, or substitute coverage percentages for race, load, soak, and failure evidence.\n\nSee [operations verification](/docs/reference/0.13.2/operations.md#verification) for the current commands and the distinction between server acceptance and the original diagnostic. These are required gates, not a statement that every release has passed them; consult the version's [release checklist](/docs/reference/0.13.2/release-status.md) for recorded results and remaining limitations.\n\n## Horizontal composition contract\n\n- Placement runs before upgrade within the admission timeout. Redirects must use `wss`, contain no credentials or fragment, and may be restricted with `allowedPlacementOrigins`. Plain `ws` placement requires the explicit `allowInsecurePlacement` escape hatch for private development networks.\n- Readiness becomes false before shutdown work begins. New upgrades receive `503`; existing connections stop accepting messages.\n- `drainHandlers` is opt-in. When enabled, every connection context shares the route drain signal and shutdown awaits tracked work. Application handlers remain responsible for observing the signal; non-cooperating promises cannot be forcibly cancelled.\n- Distribution adapters have no framework backlog. Publish and inbound concurrency are finite; publish failure returns `false`; startup, subscription, unsubscription, draining, and close are bounded. Adapter operations receive an optional `AbortSignal`, and late startup/subscription settlement is compensated. Adapters must observe the signal when their external side effects are not otherwise reversible.\n- A failed publish marks a `required` adapter unhealthy, makes the route unready, and causes new upgrades to receive `503`; a later successful publish restores health. Best-effort adapters do not affect route readiness.\n- Event IDs are deduplicated only inside a finite TTL/size window. Source-node events are ignored to prevent reflection loops.\n- Broker partitions and process failure can lose events. Redweb makes no exactly-once or durable-delivery claim; applications own authoritative persistence, reconciliation, tick/sequence semantics, and partition policy.\n\n## Protocol contract\n\n- Negotiation is opt-in and happens before upgrade. Unsupported clients receive `426` plus the finite supported-version list.\n- JSON events use `{ v, type, payload, requestId?, sequence? }`. Error events use `{ v, type: \"error\", error: { code, message }, requestId? }`.\n- `requestId` correlates a request and response; `sequence` expresses application ordering. Neither implies acknowledgement, durability, or exactly-once delivery.\n- Stable framework codes are generated from `src/ws/protocol-schema.json`; the client declarations and runtime constants share that source.\n- Binary replication is a codec hook, not a codec dependency. Size is checked before decode and after encode, and outbound data uses the normal backpressure ceiling.\n- Protocol-disabled routes retain their 0.8 wire shapes and allocate no protocol context.\n\n## Resource ownership\n\n- `maxPendingUpgrades` bounds authorization work before a socket is accepted.\n- Timed-out admission hooks that ignore cancellation retain their reservation until they actually settle, preventing repeated timeout waves from accumulating unbounded application work.\n- Fixed-step services clamp retained lag with `maxRetainedLagMs`; dropped time is observable rather than replayed forever.\n- Session count, ID length, and lifetime are bounded by Redweb. Session `data` is application-owned, so applications must validate and cap its shape and byte size before storing it.\n- Fully enabled idle routes have a 2 KiB framework-metadata budget per connection. Disabled features retain the legacy path; the performance gate compares against an explicitly prepared, identified release baseline. Historical 0.8 evidence does not establish performance against a newer baseline.\n",
|
|
169
|
+
"url": "/docs/reference/0.13.2/production-contract.md",
|
|
170
|
+
"sha256": "fc90bc92240bc29d2241f030f7acb7ee7e98d651720252bd0c483dfee2502d64"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "verification",
|
|
174
|
+
"title": "Recorded verification evidence",
|
|
175
|
+
"summary": "Historical measurements and their exact scope; not proof of a newer release.",
|
|
176
|
+
"source": "docs/VERIFICATION_EVIDENCE.md",
|
|
177
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# 0.9.0 verification evidence\n\nThis is historical evidence for the 0.9.0 candidate, not certification of a newer release. Current commands, measurement scopes and unresolved gates are recorded in [operations](/docs/reference/0.13.2/operations.md#verification) and the [development release checklist](/docs/reference/0.13.2/release-status.md).\n\nRelease-candidate measurements were taken on Windows x64, Node 22.21.0, and an AMD Ryzen 7 7800X3D. Performance numbers are machine-specific; the scripts and thresholds are the durable contract.\n\n## Automated correctness\n\n- 290 unit and mock-free integration/fuzz tests pass on Node 18, 20, and 22.\n- Statements, branches, functions, and lines are each 100% covered.\n- Type declarations compile and generated protocol declarations match their schema.\n\n## Resource and failure gates\n\n- Real-socket load: 32 concurrent clients, 3,200 request/response messages, 6,874 messages/second, 5.87 ms p99, with a paused slow consumer disconnected by the outbound-buffer policy.\n- Reconnect recovery: 200 warm connections followed by 1,200 storm connections; retained heap recovered to 104.33% of warm baseline and connection, room, and session registries returned to zero.\n- Fully enabled idle-route metadata: 1,756.93 bytes per connection across the median of three 500-connection trials, below the 2,048-byte gate.\n- Disabled-feature comparison with Redweb 0.8: throughput improved 1.09% and p99 regressed 2.00% (limits: 3% and 5%), using five alternating 20,000-message trials at concurrency 128.\n- `npm audit` reports zero vulnerabilities after upgrading Express to 4.22.2, `ws` to 8.21.3, and patched transitive dependencies.\n- The corrected 60-minute soak sent 2,099,717 messages across 64 rotating clients and received 2,099,565 responses (99.993%). Steady-state clients, rooms, sessions, in-flight work, queued work, listeners, and Redweb-owned timers showed no sustained growth; late-window heap was 3.24% above the early window, within the 10% gate. Final heap was 100.04% of the warmed baseline; all registries returned to zero; active handles stayed within the one-handle allowance.\n\nAll measurements above were rerun on the final release candidate. Shortened soak smoke runs are not counted as release evidence.\n",
|
|
178
|
+
"url": "/docs/reference/0.13.2/verification.md",
|
|
179
|
+
"sha256": "3a4bee71aa270cec51d6a5de0961ffb7f62b25433cd041b799c87739e73733c4"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "release-status",
|
|
183
|
+
"title": "Release status",
|
|
184
|
+
"summary": "Acceptance checklist, completed increments, and remaining publication or deployment boundaries.",
|
|
185
|
+
"source": "docs/AGENT_READY_ACCEPTANCE.md",
|
|
186
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Developer and agent experience release: acceptance checklist\n\nThis is the full implementation checklist for the requested improvements. Unchecked items are not release-complete; green coverage alone is not evidence for them.\n\n## Discovery and honest positioning\n\n- [x] Package repository, homepage, issue tracker, and meaningful search keywords.\n- [x] README leads with the current integrated site/socket workflow, a runnable example, fit/non-fit guidance, and no stale release-specific introduction.\n- [ ] Website presents the same supported capabilities and release version.\n\n## Complete starters and repair loop\n\n- [x] `redweb init` offers realtime, chat, site, and socket templates with no undefined application placeholders.\n- [x] Existing-project initialization does not generate a second application or overwrite user files; conflicts are reported honestly.\n- [x] Explicit noninteractive options, dry-run, machine-readable output, actionable errors, and safe filesystem handling.\n- [x] One development command rebuilds/restarts on changes, one test command verifies behavior, and production startup is documented.\n- [x] `redweb doctor --json` inspects effective JSX configuration, package/tool versions, assets, duplicate routes/handlers, and an optional port without executing user application code or silently repairing it.\n\n## Automatic reactive rendering\n\n- [x] Ordinary TSX expressions over decorated state update automatically, including derived expressions and conditionals.\n- [x] Owner-scoped component/page boundaries; batched changes; unchanged HTML sends no patch.\n- [x] Stable keyed list updates preserve appropriate DOM identity, input state, and focus.\n- [x] Reconnection snapshots, connection/shared state isolation, nested components, cancellation, bounded work, and disposal remain correct.\n- [x] Existing explicit bindings remain usable without duplicate/conflicting updates.\n- [x] Counter and multi-client chat pass real browser + HTTP/WebSocket tests using the simpler public syntax.\n\n## Shared socket contracts\n\n- [x] One contract defines payload validation and inferred client/server types.\n- [x] `/match` routing and join/move/resume handler dispatch stay separate, without socket decorators or a secondary action dispatcher.\n- [x] Invalid payloads never enter handlers; errors are stable; existing uncontracted routes still work.\n- [x] Type-negative tests and real-network positive/negative protocol tests cover the public contract API.\n\n## Documentation and executable recipes\n\n- [x] Plain Markdown per topic, compact `llms.txt`, versioned release docs, task-oriented recipes, and clear prerequisites/filenames/commands/results (implementation and local serving verified; publication remains a separate gate).\n- [x] Human pages, agent docs, and code snippets derive from one maintained recipe/content source.\n- [x] Recipes compile against a packed npm release and pass real HTTP/WebSocket acceptance tests.\n- [x] Read-only documentation access can be exposed through an optional MCP adapter without enlarging the normal runtime or claiming automatic agent selection (separate private/unpublished integration).\n\n## Trust and release verification\n\nCurrent checkpoint: the original split coordinator/worker coverage maps are\ncomplete in the maintained 161-test gate, all-four 100%. Full Windows `df58f94`\nregression passed 1,943 tests with five skips, but its Node 24 PR job failed\nthe ten-second soak delivery assertion at 98.47715736040608%. The original\nraw measurement was not retained. Failure-time evidence preservation is now\ncorrected; real-socket controls establish a possible pending-reply rotation\nloss mechanism, not the historical cause. No acceptance limit changed. See\n`SPLIT_RECOVERY_COVERAGE.md` and `SOAK_ROTATION_OBSERVATION.md`.\n\nSplit-runner correctness follow-up: three reproduced failure-channel defects are\ncorrected using the shared error normalizer and existing request cleanup. Forty-seven\nunit/native regressions pass; one fresh normal server recovery run reconciles\nall 7,400 replies, peaks at 108.18707393318519% of warm server heap and exits\nnormally without forced cleanup. Client growth remains diagnostic. This does not\nitself close the two runner coverage maps or historical release failures; see\n`SPLIT_RECOVERY_ERROR_HANDLING.md`.\n\nOriginal recovery follow-up: the maintained 45-test scope passes with all-four\n100% authored coverage. Exact integer bytes correct false rejection at the\n110% equality boundary without relaxing the limit, final/intermediate checks,\nor workload. Real socket tests now observe session release at its lifecycle\nboundary and await expiry, including a delayed-observer regression. The earlier\nfull Windows run remains failed (two npm certificate-trust timeouts and the\nsession assertion); focused passes do not replace full-suite evidence. Both\nhosted runs at the preceding `0e3e257` passed, but predate these changes. See\n`ORIGINAL_RECOVERY_VERIFICATION.md`; the later authored-map closure is above,\nand release remains open.\n\nFrozen Live HTML tool coverage follow-up: 52 tests pass in 27.384s with all-four\n100% of the unchanged authored tool (336 statements / 86 branch outcomes /\n47 functions / 282 lines). One native CLI test runs the existing full browser\nworkload; 51 explicit boundary units cover rejection and cleanup paths without\nclaiming simulated observations are DOM evidence. Windows Node 18 passes the\n51 units and explicitly skips the native dashboard workload under its canonical\nengine requirement; this does not claim Node 18 dashboard support. Native CLI\nexit, its banner and absent known temporary directories do not independently\nprove all descendant termination. Characterized frozen cleanup defects remain\nunchanged and documented in the coverage audit. The last enumerated frozen-tool\nmap gap is closed, not the broader release requirements or historical failures.\n\nBrowser-evaluator coverage follow-up: the maintained control gate now covers\nboth the unchanged validator and its browser evaluator, reusing the original\neleven-case real Windows browser matrix once. Four new native CLI cases cover\nbuild/startup/HTTP failures and require the owned application to be gone;\nseparate browser/process boundary units cover deadlines, reporting and cleanup\nfaults. A status-specific oracle prevents unrelated network failures from\ncounting as the expected 404 rejection. Original command errors and owned\nevidence survive unexpected outcomes. See the coverage audit for exact scopes,\nmeasurements and platform limits; the subsequent Live HTML tool result is above.\nThe combined run passed 44 tests in 67.556s with all-four 100% of the evaluator\n(237/57/41/174) and validator (24/14/4/19); it is not whole-release completion.\nPreparation (five tests), process/sealing (40 tests, one skip) and trial-runner\n(23 tests) regressions also pass with their original 100% map hashes unchanged.\n\nControl-validator coverage follow-up: the unchanged eleven-case protocol matrix\nnow runs through a maintained direct-coverage gate. Real Windows Chromium checks\naccept four working controls and reject seven broken variants; explicit unit\nfaults cover report creation, immutable output, and failed result validation.\nUnsupported platforms must exit normally with the exact interface refusal, not\na timeout or cleanup error containing the same text. Unexpected outcomes retain\nboth the original command error and owned workspace. The validator's authored\n24/14/4/19 scope is 100% (17 tests, 60.969s); this is not new Redweb release/agent acceptance or a\nresolution of historical Linux cleanup and throughput failures. See the coverage\naudit for measured evidence and remaining frozen-verifier gaps.\n\nTrial-wrapper browser-profile follow-up: two additional filesystem regressions\nreproduced deletion of a leftover profile, with and without a saved report. The\nwrapper now preserves the evaluator's known profile directory as well as its\nexecution directory; it does not infer cleanup from error-message text. The full\ntrial gate passes 23 tests in 9.559s with the unchanged 100% runner map. This\nsource-derived wrapper correction leaves the frozen evaluator untouched.\n\nTrial-runner coverage follow-up: 21 tests pass in 10.367s at all-four 100% of\nthe unchanged runner (66/14/6/53). Native checks cover archives, build failures,\ninput/source tampering and the evaluator's actual browser protocol control;\nexplicit dependency-boundary and fixture-retention units remain separate.\nThe reviewer caught an outer-test cleanup path that could erase retained trial\nevidence; the wrapper now preserves it and the original error, with filesystem\nregressions. The process/sealing regression still passes 40 tests/one skip at\n100%. These are synthetic checker fixtures, not new agent trials or a new\nRedweb release acceptance result. Remaining verifier scopes stay open.\n\nCandidate-preparation coverage follow-up: five tests pass in 12.289s at all-four\n100% of the unchanged preparation tool (25/8/3/21). Real plain/instrumented CLI\nruns produce identical archive/catalogue/commit identities; a real failed npm\nlaunch creates no success manifest. Three subprocess-boundary units remain\nexplicitly separate from native checks. Shared test-only instrumentation also\npasses the existing 40-test process/sealing regression (one skip, 8.054s) with\nunchanged 100% maps. This is tool coverage, not a new agent trial; remaining\nfrozen browser/trial verifiers and release requirements stay open.\n\nShutdown coverage follow-up: the b2ca53a PR Node 18 job passed 1,770 tests but\nfailed the global coverage gate, missing the render-drain timeout path in\n`PageManager`. New zero-budget unit and real-HTTP cases assert that specific\ncause, alongside the unchanged 20ms scenario. No runtime or timer APIs changed.\nThe complete focused selection passed 125 tests on actual Windows Node 18.20.8\nand 22.21.0, with all-four 100% of PageManager (462/303/87/372). Exact evidence\nand the limits of the timing explanation are in `COVERAGE_SCOPE_AUDIT.md`.\n\nIsolated-harness correction: both 665be56 CI workflows failed; Node 22/24 and\nlifecycle logs identified the missing `finishVerificationSummary` helper in the\ncopied browser harness. The fix adds that unchanged helper, not a checkout\nfallback. A new literal-relative dependency regression failed before the fix;\nall 12 harness tests now pass (1.483s), with 53/11/9/49 at all-four 100%.\nThe complete isolated-package gate also passed 71 tests in 266.225s, using registry\nclient 0.2.0, real Chromium/HTTP/WebSockets and source-free generated applications.\nIts coordinator/report-helper maps remain all-four 100%; the browser report\nidentifies 211 package files and 27 unchanged harness inputs. Exact identities\nand the prior failures are preserved in `COVERAGE_SCOPE_AUDIT.md`. These are local\ncorrection results, not certification of pending CI or broader release completion.\n\nFrozen-tool coverage follow-up: the unchanged evaluation process and byte-sealing\ntools now have maintained all-four 100% direct coverage (52/25/12/36 and\n23/7/7/20 respectively). Forty tests passed with one platform skip in 7.634 seconds,\ncombining explicit OS-boundary units with actual process/filesystem/CLI/listener\nchecks. Test-only in-memory instrumentation preserves original checker identity;\nneither frozen sources nor sealed evaluations changed. This is not a new agent\ntrial or resolution of the original Linux cleanup failure. Exact source/map\nidentities and remaining frozen-tool gaps are in `COVERAGE_SCOPE_AUDIT.md`.\n\nBrowser-coordinator follow-up: shared terminal-report handling corrects late\npublication failures and preserves retained-workspace identity; failed application\nshutdown releases its listener reference while retaining cleanup uncertainty.\nTen reporting regressions failed before the fix. The final combined gate passed\n109 unit/native tests in 81.961 seconds at all-four 100% across the coordinator\nand four runtime/refresh helpers; the coordinator alone is 187/69/35/143. Runtime\nand refresh browser maps remain complete. The installed-client-only diagnostic\nstill fails its own incomplete coverage gate; its regression now requires a fresh\nreport and no additional error. Linked-source CLI integration is explicit opt-in\nand passed locally; ordinary registry CI does not require unpublished checkout\nfiles. See `COVERAGE_SCOPE_AUDIT.md` for exact identities, scope distinctions and\nthe earlier failed measurements. Remaining release requirements stay unchecked.\n\nPackage-coordinator follow-up: sixteen regressions reproduced swallowed rejection\nvalues, incomplete server cleanup and premature success output. The fixes reuse\nshared error handling and one small local server owner. The maintained package\ncoverage gate passed 71 unit/filesystem/native tests in 265.812 seconds, with\n100% authored coverage of the coordinator (173/69/12/162) and report helper\n(21/6/3/16). The native workflow uses the actual packed consumer, registry\nredweb-client 0.2.0, Chromium and source-free applications; separate real-listener\ntests assert cleanup before rescue. See `COVERAGE_SCOPE_AUDIT.md` for identities,\nplatform bounds and the earlier failed coverage measurements. CI retains both\nmaps and packed-browser evidence without a duplicate standalone invocation.\nThis does not close remaining coverage, performance or release-alignment items.\n\nPackaged-example verification now has bounded acquisition/action/cleanup and\nstrict command output, with installed-transport isolation preserved. Its maintained\nthree-script scope passes 40 unit/native tests at all-four 100%; the original\nproduction-only counter, explicitly installed chat validator, development resources\nand generated additions remain actual consumer checks. See\n`PACKAGED_EXAMPLE_VERIFICATION.md` for exact scope, failure history and identities.\n\n- [x] Reproducible reconnect, disconnect, slow-client, memory, isolation, authentication, and compatibility evidence, with limits and environment recorded.\n- [x] Fresh-agent tasks using only public documentation measure first-pass success and repair effort against objective application checks (one narrowly scoped counter/chat case; not an adoption success rate).\n- [x] Full 100% statement/branch/function/line coverage of the maintained library and explicitly scoped authored verification modules, type gates, real-network/browser tests, load/recovery/memory gates, package checks, and audit; generated V8 decorator counters and diagnostic-only measurements remain labelled rather than presented as this authored-source gate.\n- [ ] README, changelog, examples, website, and evidence agree on shipped behavior; final requirement-by-requirement audit proves every checkbox.\n\n## Follow-on application ergonomics and adoption\n\n- [x] Typed, validated action inputs eliminate repeated form parsing while preserving explicit exposure and safe failure boundaries.\n- [x] Statically readable literal action references are checked for typos without executing application code; dynamic/unsupported references are reported honestly rather than guessed correct (supported syntax and limits in `CLI.md`).\n- [x] Consistent action loading, success, and error states need no custom browser glue.\n- [x] HTTP and socket identity share a clear request/session context, with explicit authorization hooks for pages, actions, and rooms.\n- [x] Complete persistent cards and authenticated dashboard recipes demonstrate durable data and private updates without adding a database framework to Redweb.\n- [x] Runtime diagnostics distinguish validation, authentication, authorization, and application failures without leaking secrets; retry guidance states actual guarantees.\n- [x] Fresh-agent evaluations separately measure successful use when assigned Redweb and discovery/selection when not instructed to choose it; record first-pass success, repair attempts, time, and independent correctness checks. The assigned counter/chat pass is preserved in `evaluations/2026-08-30-01`; the category-first public-search trial in `evaluations/2026-08-30-02` selected Socket.IO, with no implementation claim. Timing/search sequence are self-reported where not independently observed; host metadata prevents full blinding, and one sample is not a discovery rate.\n- [x] Package provenance and release support/compatibility guidance are documented and verified where available; no claim of provenance as proof of security. Published 0.13.0 signatures were verified; no provenance attestation was returned, and future trusted-publishing/security-reporting policy remain maintainer actions rather than claimed capabilities.\n\n## Additional developer-experience follow-through\n\n- [x] Safe incremental `redweb add page`, `redweb add component`, and `redweb add socket-route` workflows reuse canonical templates, respect existing projects, preview changes, reject overwrites, and include working tests.\n- [x] Optional development inspection shows registered pages/components/actions/socket handlers, connection state, and reactive invalidation/flush causes without exposing a production debugging endpoint or adding observer branches/callbacks to disabled update paths; documented scope excludes initial SSR, nonreactive messages and delivery acknowledgement.\n- [x] Development rebuilds refresh the browser with explicit, tested draft-preservation/reconnection behavior; no implied preservation across server restarts without evidence.\n- [ ] Searchable task-oriented landing guides demonstrate the published realtime dashboard, server-rendered JSX, chat, and shared validated socket contracts; runnable files/tests and honest fit/non-fit guidance stay generated from canonical sources.\n- [x] Clean-package consumer commands use managed subprocess timeouts, terminate the running descendant tree where possible, retain/report uncertain cleanup, and preserve the primary failure if temporary-workspace cleanup also fails.\n\n## Client-owned frontend follow-through\n\n- [x] Move reusable generated frontend behavior into `redweb-client` where it simplifies Redweb, with one maintained implementation and no duplicated transport/lifecycle logic (`redweb-client@0.2.0` published and integrated; `redweb@0.13.0` published from the verified merge commit).\n- [x] Verify the hardened client and Redweb together from matching packed artifacts, including counter/chat rendering, cancellation, reconnect, and existing browser regressions; preserve unit and no-mock integration coverage requirements and honest publication boundaries (both local candidate and published-client pairs verified; the separate standalone V8 command remains unresolved).\n\n## Work log\n\n- `0.13.2` release-candidate checkpoint (Windows, Node 22.21.0, 2026-09-01): package and lock metadata, README setup commands, generated catalogue and the immutable `docs/releases/0.13.2.json` snapshot all identify 0.13.2. A new `prepublishOnly` check fails closed when the catalogue channel, README, package version or snapshot differs; its actual subprocess tests cover missing/stale snapshots, unreleased channels, invalid option combinations and successful release checks. The final full run passed all 178 suites: 2,018 tests passed, five platform cases were skipped, every pretest/type check passed, and the maintained Redweb library scope reported 100% statements/branches/functions/lines. Unit tests and no-mock HTTP/WebSocket/browser/clean-installed-consumer integrations remain distinct. The complete packed-package gate passed all counter, chat/reconnect/disconnect, generated TypeScript addition, dashboard, browser-runtime, refresh, source-free recipe and initialized-application checks.\n- Two verifier defects exposed during repeated full runs were corrected without changing runtime behavior or application limits. Generated dashboard tests retain their own five/seven-second process deadlines while the aggregate supervisor now allows 60 seconds under full-system load; five isolated dashboard repetitions, the complete six-starter run and the final full suite passed. The hostile-frame test no longer mistakes a Windows `ws` client lingering in `CLOSING` for a Redweb containment failure after the server has already handled close and emptied its route registry. It now requires exact server registration/release before reconnect and owns local client cleanup; the unchanged two-second containment limit passed 100 consecutive no-mock fuzz runs and the final full suite.\n- Final resource and release gates passed: 3,200 messages/32 clients at 3,454.66 messages/s with p99 56.59 ms and slow-consumer containment; three 500-connection memory trials measured 1,892.608 framework bytes/connection under the 2,048-byte limit; five recovery storms delivered all 7,400 replies and ended at 97.2814% of warmed server heap; Live HTML expired 200 renders with 110 live clients and 8,166,992 bytes heap delta; JSX rendered 10,000 component rows in 51.8 ms with 0.6 MiB retained. A current 30-second smoke soak delivered 17,592/17,596 replies (99.9773%), ended at 93.7786% of warm heap, passed every trend and emptied all final registries. The earlier 3,600-second soak predates the final heartbeat implementation and is not relabelled as current-code evidence. Audit found zero vulnerabilities; 580 installed dependencies had verified registry signatures and 67 had verified attestations. The pre-evidence dry run produced the expected 218-file 0.13.2 archive; the final archive is rechecked after this canonical evidence update rather than recording a self-invalidating hash inside itself. No npm publication or website deployment occurred.\n\n- Published-release audit (`redweb@0.13.0`, published 2026-09-01 UTC): npm\n records `gitHead` `7196d504ee65dfaf5ac869ea4bda66d7cf86d015`, the\n verified merge commit on `main`. All 215 registry files match that commit's\n content: two byte-for-byte and 213 after Windows CRLF normalization. The\n per-file hashes are retained in `docs/releases/audit-0.13.0.json`.\n A clean exact installation with lifecycle scripts disabled\n passed `npm audit signatures` with no invalid or missing signatures and\n `npm audit --omit=dev` with zero vulnerabilities; registry metadata contains\n no provenance attestation. The immutable tarball retains a documentation-only\n publication defect: its generated catalogue and changelog retain prerelease\n labels, while its setup guidance and README also describe the release as a\n development tarball/unreleased. Repository/site sources correct those labels,\n but a future patch release is required to correct bundled package\n documentation. Runtime code and public declarations are unaffected.\n\n- `18b1dfd` regression/performance checkpoint (Windows, Node22.21.0,\n 2026-08-31): with system certificate authorities and TLS verification retained,\n all178 suites passed:2,017 tests, five platform skips, every pretest/type gate\n and all-four100% maintained library coverage. An earlier run passed2,016 tests\n but its first clean-consumer npm install stalled without output at the unchanged\n 120-second deadline; the isolated failure reproduced while a direct registry\n query also stalled. The isolated and complete reruns passed once Node used the\n Windows trust store; no source, timeout or assertion changed. Both PR and push\n CI runs for `18b1dfd` passed every Node18/20/22/24 and lifecycle job. Exactly\n two preregistered five-pair comparisons against one fresh exact0.12.0 baseline\n completed exact delivery: throughput/p99 regressions were-0.3677%/-5.3204%\n and-1.7901%/-8.2032%, within unchanged3%/5% limits. This is a bounded local\n pass, not a capacity guarantee or explanation of the retained historical\n 4.7850% failure; see `BENCHMARK_VERIFICATION.md`.\n\n- Corrected-hour heartbeat checkpoint (Windows, Node22.21.0, 2026-08-31): the\n immutable `11d1dcc` candidate completed3,600 seconds with64 clients and720\n samples. It delivered2,106,745 of2,106,821 replies (99.9963926693%), finished\n heap at97.6036274356% of warm, passed all eight trends, emptied every final\n registry and stayed within the one-handle allowance. The owned one-shot task\n exited0 without forced cleanup, all112 raw inputs and the independently\n recomputed582-file digest matched, stdout equalled the report bytes, and stderr\n was empty. The task was removed only after independent validation. The critic\n then required stronger second-ping, pending-at-shutdown and escaped-close\n diagnostics regressions;71 heartbeat tests and94 soak-tool tests pass their\n maintained all-four100% scopes. That later review increment changes a soak tool,\n so the hour is attributed only to `11d1dcc`; see `SOAK_ROTATION_OBSERVATION.md`.\n\n- Development-verifier follow-through (2026-08-31): nine new fault units\n reproduced unguarded port errors, lost primary/cleanup failures and skipped\n releases. Page acquisition now uses the existing bounded owner through late\n settlement; both templates and controls share it. Final coverage gate combines\n 31 explicit boundary units and four real Chromium/HTTP/WebSocket/process tests:\n 35 passed in 71.327 seconds, 217 statements/39 branch outcomes/38 functions/\n 170 lines at 100%. Native rebuild, input/draft and process-exit assertions remain\n intact. Independent review approved the corrections; exact scope and hashes\n are in `COVERAGE_SCOPE_AUDIT.md`. CI retains this coordinator map separately.\n\n- Previous-head `ad1684f` verification: the isolated package gate and production\n audit passed after canonical regeneration; see ignored receipt\n `coverage/verification-ad1684f.json`. Its PR Node 24 job passed 1,620 tests/five\n platform skips/151 suites in 683.299 seconds with all-four 100% library coverage.\n All four PR matrix jobs passed; the separate lifecycle job was still running\n at this observation. Downloaded Linux refresh-helper coverage\n `coverage/ci-browser-ad1684f/refresh-verifier/coverage-final.json` has SHA-256\n `b1c0984a7dd805f659323c8efbbe2169b005149accb879ee05722fcd71131f21`\n and retains 395/104/60/297 at 100%. Native Chrome 151 observed cache restoration\n in both modes. This does not relabel the failed 48 prepack/CI run, the original\n Linux cleanup failure, or the default throughput failure as passing. Current\n coordinator changes still require their own hosted checks; site catalogue\n synchronization and deployment remain separate.\n\n- Post-commit package check of `48b3f3f` failed before packing: the final\n acceptance-log edit changed the canonical `release-status` topic after the\n preceding generator check. This was stale generated documentation, not a\n consumer/runtime failure. The catalogue is regenerated from canonical content;\n a new package run must pass before package verification is claimed for this\n increment. The preceding successful generated/type check is not relabelled as\n a post-edit check. Site content remains a separate local sync/deployment step.\n\n- Refresh verification follow-through (2026-08-31): corrected three reproduced\n failure-boundary defects using shared error handling: swallowed falsy errors,\n release errors replacing earlier failures, and upload errors lost when controls\n also fail. The maintained browser command now includes direct authored coverage\n of both refresh helpers, without duplicating its canonical refresh workload.\n Final gate: 27 collector, five runtime-helper and 52 refresh tests pass; both\n helpers cover 395 statements/104 branch outcomes/60 functions/297 lines at\n 100%. Native Chromium/HTTP tests remain separate from explicitly doubled unit\n boundaries. Independent review required and confirmed a bounded close in the\n new standalone test. All generated/type checks and eleven documentation/CI\n units pass. Exact maps, source identities and boundaries are retained in\n `COVERAGE_SCOPE_AUDIT.md`; frozen tooling/runtime/limits are unchanged.\n\n- Completed full verification of unchanged `4fe0145`: Windows passed 1,577\n tests, three platform skips and 150 suites in 868.646 seconds; the 91-file\n library scope covers 5,449 statements/4,046 branch outcomes/978 functions/\n 4,468 lines at 100%. The command used the system CA trust store, without\n disabling TLS or changing global configuration. Retained JSON\n `coverage/final-polish-4fe0145-full-results.json` has SHA-256\n `742e88e003b65f76dc1c088b0c9da3aa5f97587df92eb03bd05c94caf2a08ad7`;\n map `coverage/final-polish-4fe0145-full/coverage-final.json` has SHA-256\n `5e0ea2c59c941a63606623a0f5056fb526416fc6302ffcfadd05fc5221237e42`.\n These full-suite counts precede the refresh increment above. Both workflows\n (PR 33406107275, push 33406101028) completed successfully, including all four\n Node versions and the Linux escaped-descendant negative control. The downloaded\n Linux dashboard map (`coverage/ci-dashboard-4fe0145/coverage-final.json`, SHA-256\n `ba2193d92f43562483ad2216c2ff5d7e7adc3ac104b064737c85b9942f6cc7c3`)\n independently retains 101/14/14/82 at 100%. These passes do not prove the cause\n of the original `08348fb` cleanup failure or waive failed throughput evidence.\n\n- Public release reconciliation (2026-08-31, 15:10 UTC): registry versions remain\n Redweb 0.12.0 and redweb-client 0.2.0. Actual certificate-verified HTTP requests\n to the configured public origin found `/docs/` still titled “Redweb 0.9 API\n reference”; `/llms.txt` returned homepage HTML rather than agent-readable text.\n Local site/build success is not public alignment. Ignored observation receipt:\n `coverage/public-release-4fe0145.json` (decoded-body hashes, not raw response\n archives). No deployment, npm publication, merge or new hour soak was performed.\n\n- Public example correction: independent review and an actual-socket probe found\n that the older match example joined a room but then failed on its missing\n authenticated principal (sanitized error and close 1011); it also never issued\n a session for resume. The stable example URL now renders the canonical typed\n socket handlers. The socket guide links their contract/route and the separate\n working private-room example; the generated README uses its actual `src/` paths.\n No duplicate handler implementation or runtime change was\n added. Deployment guidance distinguishes published client 0.2.0 from unreleased\n Redweb. Two new content regressions failed before correction; a third caught\n broken source-relative links in generated README output during review. All seven\n documentation units and two selected real source-free socket/room integration\n cases pass. The complete preceding `7a0297e` regression passed 1,488 tests/142\n suites with 91-file library coverage at all-four 100%; the three new units are\n additional, not included in that count. See `FEEDBACK_COMMAND_VERIFICATION.md`\n for the full-run identities. Hosted and final release acceptance remain separate.\n\n- Refresh command follow-up: the real disconnected-browser regression reproduced\n a command hang through the 60-second supervisory watchdog. Generated-app refresh\n and refresh controls now reuse the shared 15-second adapter, with weak identity\n tracking to prevent duplicate wrappers. All 17 maintained unit/native cases pass\n at all-four 100% for the adapter and feedback driver; unchanged runtime/refresh\n native coverage also passes. Actual peer cleanup is checked before rescue.\n Linked-client authored coverage, generated-app refresh and the complete\n isolated-package gate with registry client 0.2.0 pass. The critic approved all\n eleven actual remote implementation files at `82156ee`.\n Raw acquisition and complete refresh-helper coverage remain separate. See\n `FEEDBACK_COMMAND_VERIFICATION.md` for hashes, scope and budgets.\n\n- Original-phase performance diagnostic: after the `bf01c2a` full regression\n passed 1,486 tests/142 suites with unchanged all-four 100% library coverage,\n one fixed ten-worker profiling series used the original 20,000-message phase.\n Every worker delivered all 2,000 warm-up and 20,000 measured replies; inputs\n stayed unchanged and cleanup passed. Coarse profiles show overlapping timings,\n not a demonstrated cause or a performance acceptance pass. The original\n 4.78495% throughput failure remains visible. See `BENCHMARK_VERIFICATION.md`.\n\n- Development-refresh ownership follow-up: guarded browser acquisition now retains\n uncertain launch cleanup, bounds shutdown to 15 seconds and independently\n preserves fallback failures. Seven initial explicit boundary units reproduced\n failures before correction; all 15 expanded units pass afterward, including the\n real shutdown watchdog. Native generated-app refresh remains a separate gate.\n No public API/frozen helper changed; full direct coordinator coverage and inner\n template/helper ownership remain open. See `BROWSER_OWNER_VERIFICATION.md`.\n\n- Full feedback-driver follow-up: two additional real Chromium cases verify\n successful acceptance and an exact cleanup-only rejection, including listener\n closure and pending-waiter resolution. The maintained scope now passes 15 tests\n across four suites, covering both the driver and adapter at all-four 100%\n (176 statements, nine branches, 14 functions, 162 lines). Watchdog-late failures\n remain visible; no runtime/browser/server API is replaced. The preceding full\n regression for `69dcbf8` passed 1,469 tests/140 suites with two POSIX-only skips\n and unchanged all-four 100% library coverage. These two later-added cases pass\n separately, not retroactively as part of that run. Acquisition ownership and\n other tooling scopes remain separate; see `FEEDBACK_COMMAND_VERIFICATION.md`.\n\n- Feedback-command follow-up: a real closed Chromium debugging connection left\n the verifier pending and its server listening after 20 seconds. A shared\n 15-second command adapter now allows server cleanup without mutating the raw\n tab or duplicating the coverage caller's adapter. Thirteen unit/native checks\n pass with 100% of the branch-free adapter; runtime/refresh browser gates pass.\n Linked-client authored coverage and the complete isolated-package gate also\n pass, including source-free consumers and all copied browser phases. The critic\n approved all 16 actual remote implementation blobs at `e3b4902`.\n Raw page acquisition remains a separate open boundary. See\n `FEEDBACK_COMMAND_VERIFICATION.md`; no application-socket defect is claimed.\n\n- Coverage-counter follow-up: independent review identified the validation gap;\n a local probe reproduced fractional counters manufacturing a complete browser\n report. Both collectors now share\n the authored validator, checking paths/maps/keys/branch arity and nonnegative\n safe-integer counts before any merge. The maintained three-file scope passes\n 27 unit/native checks at all-four 100%; real runtime/refresh and linked-client\n authored browser gates retain their unchanged 100% scopes. The isolated harness\n includes the same helper. The full isolated-package gate also passes with registry\n client 0.2.0, source-free consumers and all browser phases; the critic approved\n all 17 actual remote implementation blobs at `659f638`. The subsequent full\n regression passed 1,459 tests/138 suites with two POSIX-only skips and unchanged\n 91-file library coverage. See\n `COVERAGE_COUNTER_VALIDATION.md` for the reproduced\n defect, exact evidence and remaining boundaries; no runtime change or performance\n waiver is implied.\n\n- Browser-owner follow-up: both browser coordinators share ownership of original\n page-opening promises and late tabs, retain uncertain cleanup and independently\n attempt every cleanup/fallback action. Falsy primary failures remain failures.\n The maintained two-file scope passes 42 tests/four suites at all-four 100%\n (131 statements, 26 branches, 20 functions, 103 lines), including actual Chromium\n counter/chat acceptance. Native runtime and refresh coverage gates also pass at\n unchanged all-four 100%. The critic approved the shared ownership design.\n The full isolated-package gate also passed with registry client 0.2.0 and matching\n bundle identities, including all source-free starters and executable docs.\n Checkout and installed-package evidence remain distinct. The completed root\n regression passes 1,456 tests/138 suites, with two POSIX-only skips and all-four\n 100% over the unchanged 91-file library scope. The linked-client gate passes\n 77 tests per mode plus native browser acceptance at authored all-four 100%.\n See `BROWSER_OWNER_VERIFICATION.md` for exact report identities. Preceding\n `377f029`, `1697f33` and `726b9a3` hosted workflows passed; this head's hosted\n result remains separate. No runtime changes or performance waiver occurred.\n\n- Lifecycle-verifier follow-up: a real generated source-free run passed 13 tests\n while c8 produced an empty report. The gate now requires the exact deployed\n helper, nonempty metrics and all-four 100%. It measures emitted JavaScript by\n removing only the temporary trailing source-map comment, preserving original\n and measured bytes; authored-TypeScript coverage is unchanged. Twenty-six\n unit/native checks pass at all-four 100% of the coordinator, and the ordinary\n CLI passes all 13 lifecycle tests with complete nonempty emitted coverage.\n The critic approved the metadata-only correction. These cases follow the\n 1,388-test full inventory; see `STARTER_LIFECYCLE_VERIFICATION.md` for exact\n identities, failure history and Windows signal-test boundaries.\n\n- Starter-coordinator follow-up: measurement fingerprints inputs before execution\n and rejects mutations afterward; both runners share bounded best-effort terminal\n reporting that preserves failure and uncertain-cleanup metadata. The maintained\n direct scope passes 56 tests/three suites at all-four 100% over 180 statements,\n 34 branches, 25 functions and 148 lines. Explicit unit faults complement actual\n child/filesystem and generated-application mutation tests. The critic approved\n the final source and report identities. Both full six-application CLI workflows\n then completed: 104 actual tests per mode, authored all-four 100% over unchanged\n counts, 96 retained process maps, diagnostic V8 gaps still visible. Full regression\n then passed 1,388 tests/132 suites with two POSIX-only skips and all-four 100%\n over the unchanged 91-file library scope; subsequent lifecycle tests are not\n included. The critic approved all 13 actual remote blobs at `377f029`; its hosted\n workflows remain separate. See `STARTER_COORDINATOR_VERIFICATION.md`. Both preceding `7a25d48` and\n `1eddee0` hosted workflows passed. No runtime change or benchmark waiver occurred.\n\n- Starter-report retention follow-up: both coverage runners preserve available\n raw reports before parsing/collection and temporary workspace removal. The\n authored runner retains all individual process maps. A shared helper reserves\n exclusive destinations and preserves command plus copy failures without\n certifying partial bytes as complete. Fifteen unit/native checks pass at\n all-four 100% of that helper; 31 collector-compatibility checks also pass.\n Both actual six-application runners complete: V8 measurement remains diagnostic,\n while authored-source coverage passes 104 tests per mode at unchanged all-four\n 100%, with all 96 process maps retained. See `STARTER_REPORT_RETENTION.md`.\n These ten added cases are separate from the preceding full regression count;\n whole-coordinator coverage and broader release gates remain open.\n\n- Action-input verifier follow-up: bounded listening/upgrade, immediate native\n socket ownership, disabled probe reconnect, preserved client errors and\n independent confirmed cleanup reuse existing helpers. Uncertain cleanup retains\n the owned workspace. Both source-free decorator modes and all twelve original\n action checks remain. Forty-two scoped unit/native tests pass at all-four 100%;\n the final maintained rerun includes corrected outer Jest budgets and the critic\n approved all eleven actual remote changed blobs at `1049ff8`. The complete\n isolated package gate passes against published client 0.2.0. Full regression\n passes 1,322 tests/128 suites in 706.908 seconds, with two POSIX-only skips and\n unchanged all-four 100% across 91 library files. Hosted checks remain in progress.\n Exact scope and failure boundaries are recorded in\n `ACTION_INPUT_VERIFICATION.md`; no runtime/client API or acceptance limit changed.\n\n- Packaged-example checkpoint (`449a369` / `551a905`): 1,292 tests/126 suites\n passed in 690.807 seconds with two POSIX-only skips and unchanged all-four\n 100% across exactly 91 library files. The new 40-test private scope also passes\n all-four 100%; its VM map stays outside the library denominator. Full isolated\n package/browser/starters/docs, clean load/memory/HTML/JSX/server recovery,\n 30-second soak and production audit passed. The soak recorded five missing\n replies out of 4,368, within the existing allowance, not lossless. The critic\n approved all 18 actual remote blobs; both implementation-head workflows passed.\n Exact identities and limits are in `PACKAGED_EXAMPLE_VERIFICATION.md`. Neither\n this checkpoint nor the narrowly corrected room-phase mechanics test waives\n the historical throughput benchmark or closes the remaining release checklist.\n\n- Verified soak local checkpoint: full regression selected at `31fa9b2` passed 1,246 tests/119 suites in 653.569 seconds, with two POSIX-only skips and all-four 100% across the unchanged 91-file library scope. The five later recorder units passed separately, not retroactively included in that inventory. Pretest/types and generated documentation pass. The critic approved all 15 actual remote blobs; hosted checks remain pending. Exact map/inventory hashes and retained short-soak outcomes are in `SOAK_VERIFICATION.md`. Remaining private-tool coverage, performance acceptance and release alignment stay open.\n\n- Application-recorder coverage: the unchanged exit hook now has nonvacuous original-source coverage of all six statements/lines, two branches and its one function. Six selected tests pass (five new isolated recorder units plus an existing actual instrumented-pipeline integration); eight unrelated cases are filtered. Native process/file checks preserve absent-data behavior, exact reports, child identities and visible write/serialization failures. The initial native converter omitted the anonymous function from its denominator; raw V8 execution and the limitation were independently verified, and that map remains diagnostic only. The critic approved the maintained authored scope and restoration/CI budgets. These five new units are not retroactively included in the preceding full run. See `APPLICATION_RECORDER_VERIFICATION.md`.\n\n- Soak verifier correction: a native original run passed with only initial/final samples, using its final heap as its own warm baseline. The revised policy rejects vacuous sampling and unsafe derived timer/counter capacities. The owner validates each socket's pending ticks, rejects duplicate/malformed replies and unexpected closes, retains partial acquisition and closes every resource independently after guarded timer failures. Exact integer comparisons preserve the 99% delivery / 110% heap boundaries without floating-point false failures. Eighty unit/native tests pass at all-four 100% across three tool modules; final clean 30-second/16-client evidence records 4,368 sent, 4,365 received, three missing, seven samples, all eight trends stable, zero registries and handles 1→2. This is not lossless or a new one-hour result. Full/hosted verification of the increment remains pending; evidence is in `SOAK_VERIFICATION.md`.\n\n- The preceding `556160a` JSX checkpoint passed both hosted workflows (PR33368832717 and push33368829409), including all Node 18/20/22/24 and lifecycle/package/browser checks. The critic approved all 12 remote blobs. These completed results remain separate from later soak changes.\n\n- HTML checkpoint full regression: 1,152 tests/113 suites passed in 631.578 seconds, with two POSIX-only skips and all-four 100% of the unchanged 91-file library scope. The run selected its inventory at `d15b1a3`; 14 later JSX-verifier tests passed separately and are not included in that count. Generated-content/types pass. A clean JSX command after test exit passed 10,000 rows in 48.8 ms / 0.6 MiB retained against unchanged limits; the validator allocation change is not a runtime improvement claim. Exact hashes are retained in the two verifier reports. Hosted checks and broader release requirements remain open.\n\n- JSX verifier correction: the original predicate accepted duplicated indexes and severely malformed markup. One short-lived independent oracle now validates every row outside the timed render; the 10,000-row workload, two GC calls, reference clearing and 5-second/32 MiB limits are unchanged. CI externally bounds synchronous work. Fourteen separate unit/native CLI tests pass at all-four 100% of the verifier; these tests were added after the preceding full suite selected its inventory. The critic approved the scope. Exact hashes and untimed-allocation caveat are in `JSX_PERFORMANCE_VERIFICATION.md`; broader release acceptance remains open.\n\n- Live HTML load-verifier correction: reproduced malformed bootstrap JSON escaping as an uncaught exception with an unsettled promise against a real HTTP peer. Bounded non-pooled HTTP ownership, actual socket retention/confirmed closure, strict bootstrap/patch checks, explicit GC, settled parallel acquisition and combined operation/client/cleanup failures now replace that path. Success follows shutdown.54 unit/native HTTP/WebSocket/process tests pass at all-four100% across three tool modules; a clean default200-render/110-client run passed6,824,576-byte heap growth. Workload/GC sampling/24MiB limit remain unchanged; non-pooled HTTP is explicitly a harness change. The critic approved after test-budget and hidden-concurrent-error findings were fixed. Exact boundaries are in `LIVE_HTML_LOAD_VERIFICATION.md`; full/hosted verification remains pending and the separate throughput discrepancy stays open. No deployment, npm publication or merge occurred.\n\n- Verified benchmark checkpoint `43c6d73`: full pretest/types and1,098 tests/110 suites passed in614.552s with two POSIX-only skips and all-four100% library coverage. Both PR33365382012 and push33365378641 passed every Node18/20/22/24 and lifecycle/package/browser job. Sequential load, memory, server recovery, HTML/JSX,30ssoak and audit passed; three missing soak replies are explicitly retained, not called lossless. The critic approved all21 actual remote file blobs. Fixed diagnostic controls and one long CPU-profile pair did not establish the cause of the default throughput failures; no speculative runtime optimization or threshold relaxation followed. Exact evidence is in `BENCHMARK_VERIFICATION.md`. Sitecaa166f sync/build/HTTP/coverage passed locally. Performance acceptance, remaining private-tool coverage and publication alignment stay open; no deployment, npm publication or merge occurred.\n\n- Benchmark-hardening increment: exact warm-up/measured replies, stable entry/manifest identities, finite complete worker results, bounded execution and owned cleanup now fail closed. Six benchmark modules reach all-four100% through52 unit and real-network/process tests; the updated shared owner scope passes71 tests/two POSIX-only skips. Defaults and3%/5% limits remain unchanged, but ID allocation/accounting make this a new harness revision. First default registry comparison passed; the second failed throughput at4.7850% versus3%. Both are retained in `BENCHMARK_VERIFICATION.md`; the cause and final performance acceptance remain open. Documentation-only4267db1 passed all hosted jobs. No publication, deployment or merge occurred.\n\n- Completed `d576278` checkpoint: full pretest/type/regression passed1,045tests/105suites in570.020s with two POSIX-only skips and all-four100% library coverage. Both PR33362263127 and push33362261457 passed every Node18/20/22/24 and lifecycle job. Sequential final load, memory, server recovery, HTML load, JSX and30ssoak gates passed; production audit found zero vulnerabilities with TLS verification enabled. The original shared-process diagnostic's failure remains visible, not waived or labelled resolved. Packed published-client/browser/source-free checks passed; exact archive-versus-documentation boundaries are recorded in `COVERAGE_SCOPE_AUDIT.md`. The critic approved the actual20-file PR increment. Site20f56dd updates canonical docs locally and passes98page/154asset HTTP/build/rollback checks and its seven-module coverage scope. Disabled-feature benchmark validation/ownership is the next independently audited correction; broader private-tool coverage and final release agreement remain open. No publication, deployment or merge occurred.\n\n- Load/helper follow-up: fixed non-finite limit false passes, exact per-client reply accounting, partial-acquisition cleanup, premature forced-close completion, synchronous cleanup exceptions and error masking. Maintained unit/real-network coverage passes41tests/six suites at all-four100% across four modules; defaults and clean performance gates remain separate. The critic's three findings were corrected. The cross-platform memory fix passed both hosted coverage runs; a separate PR packaged-browser failure exposed unsafe heading-readiness expressions. Native-browser negative/positive controls now pass with unchanged generated-refresh100% coverage. Exact hashes, qualified diagnosis, interim resource results and the still-failed non-blocking shared-process diagnostic are retained in `COVERAGE_SCOPE_AUDIT.md`. Final regression/hosted verification remains pending; no publication, deployment or merge occurred.\n\n- Memory checkpoint `7e94e99`: corrected the zero-client false pass with validated workloads/results, bounded strict-output worker commands, owned peer/server cleanup and visible nested failures. The default 500-client/three-trial gate passed 1,881.792 bytes/connection against the unchanged 2,048 limit. Full Windows regression passed 1,001 tests/98 suites in 506.606s with pretest/types and all-four100% library coverage. Node18/20/22/24 hosted jobs passed, but both lifecycle jobs exposed a Linux coverage gap masked by Windows-only file-lock tests. Portable failure units and actual POSIX permission cases correct the gap without weakening thresholds; the revised scoped Windows gate passes70 tests with two POSIX skips, all-four100% across four tool modules. Hosted follow-up is pending. Exact source/report identities, failed CI links and boundaries are in `COVERAGE_SCOPE_AUDIT.md`. The critic approved both increments. Load-verifier malformed-limit/reply accounting is next; no publication, deployment or merge occurred.\n\n- Generator checkpoint `ba4a0bc`: fixed ignored compiler options, Windows malformed-config diagnostics and successful example builds without emitted JavaScript. One shared ordinary/instrumented CLI runner now requires complete original-source coverage for example, documentation and protocol generators, with exact per-command reports and failure-evidence negative controls. The senior critic approved the actual PR. Full local regression passed 951 tests/95 suites in 517.317s with pretest/types and all-four 100% library coverage; the three scripts separately reach all-four 100%. Exact hashes and scoped counts are in `COVERAGE_SCOPE_AUDIT.md`. Hosted checks remain in progress. A zero-client memory-gate false pass was reproduced and is the next correction; prior valid 500-client evidence remains separate. No publication, deployment or merge occurred.\n\n- Checkpoint `6018807`: closed all eight initially audited shipped-source coverage gaps using shared original-source instrumentation and unchanged real-network acceptance, with explicitly separate launcher/policy/failure unit tests. Fixed independently reviewed room-verifier cleanup and a terminal-interruption defect reproduced after Node24 CI failure. Full regression passed940tests/92suites with all-four100% library coverage; clean package/published-client/browser, sequential load/memory/rendering/server-recovery, audit and all Node18/20/22/24 hosted jobs passed. Exact scope, hashes and historical failure are retained in `COVERAGE_SCOPE_AUDIT.md` and `ADMISSION_TIMEOUT_VERIFICATION.md`. Private-tool coverage and release/site alignment remain open; no new60-minute soak, publication, deployment or merge is claimed.\n\n- Recovery adoption: after the reviewed `server-steady-v1` candidate passed Windows and all four Ubuntu runtimes, the maintainer authorized continued implementation without routine approval pauses. CI now makes `verify:recovery:server` blocking, retains the unchanged original command as a named non-blocking diagnostic, and preserves raw outcomes/logs on success or failure. A failed server gate cannot launch the second measurement after uncertain cleanup. The reviewed workload, 110% server budget and report schema are unchanged. Full prior regression passed 918 tests/86 suites at all-four 100% library coverage; exact evidence and historical contrary CI failures remain in `SERVER_RECOVERY_CANDIDATE.md`. Final adopted-policy CI and explicit release/coverage-scope audit follow; no publication or deployment is claimed.\n\n- Full regression after diagnostic compatibility fix `daacdac`: `npm test -- --runInBand --silent` passed all 863 tests/83 suites and pretest/types in 419.852 seconds. Instrumented-library coverage remains 100% for all 5,445 statements, 4,044 branches, 978 functions and 4,464 lines. Diagnostic capture coverage is separately scoped; exact report hashes are in `docs/DIAGNOSTIC_COMPATIBILITY.md`. Final-head hosted CI and the recovery acceptance decision remain pending; no merge/publication or threshold change occurred.\n\n- Diagnostic compatibility follow-up: reproduced older-Node flag incompatibilities and the upstream heap-snapshot destroy-callback defect. Trace flags now match the runtime; Node 18 code logging fails closed before workers/output because it cannot suppress prohibited metadata. A read-only legacy snapshot adapter preserves modern pipeline behavior, and the output-limit regression snapshots a small owned child. Focused real-process/network tests pass across Node 18/20/22; final native legacy/modern capture tests reach all-four 100% for that capture module only. The senior critic approved the narrow change. Exact scope, hashes, rejected candidates and remaining CI/release boundaries are in `docs/DIAGNOSTIC_COMPATIBILITY.md`. Original recovery workloads/limits and the pending acceptance decision are unchanged.\n\n- Bounded Ubuntu comparison (2026-08-31 UTC): the declared original and split baseline ran once at `41915b0` on Ubuntu 24.04.4 / Node 22.23.2. Original peak was 109.741622% of warm; split server 108.495964%, load generator 113.110894%. Exact split delivery reconciled all 7,400 replies with empty registries and confirmed descendant cleanup. Input/output identities and preflight status propagation passed; the critic independently verified the downloaded evidence. Contrary ordinary Node 22 CI at `69ea1fb` failed at 111.015778%; Node 20 also has an unexplained trace-worker EPIPE test failure. The temporary comparison trigger was removed, no workload repeated, and an explicit decision was requested before developing/replacing a server-focused release gate. Full results, receipts, environment differences and unchanged acceptance boundaries are in `docs/RECOVERY_COMPARISON.md`. Website sync `a56112b` separately passed its 98-page/154-asset HTTP/build and six scoped-coverage tests; deployment remains manual. None of this closes the final release checklist.\n\n- Published-client integration: registry `redweb-client@0.2.0` is now available with both entry points, and all four runtime bundles match the tested build. Redweb `f3c91e9` updates the dependency/lock and shares installed-client identity checks and full browser acceptance between registry and candidate package modes. The complete registry gate passed without an override or consumer link, including counter/chat, reconnect/disconnect, all-six-starter/source-free checks and all-four 100% runtime/refresh coverage. The subsequently simplified printed quickstart and optional contributor link workflow each passed real isolated installation plus 14 generated application tests. Documentation coverage is all-four 100%; generation and types pass. Exact archives, report hashes and sequencing boundaries are in `docs/CLIENT_POLISH_VERIFICATION.md`. Historical publication blockers below are superseded for the client only; final-head CI, website alignment, Redweb publication and the bounded Ubuntu recovery decision remain open.\n\n- Current package/performance checkpoint (2026-08-30): Redweb `28f9c62` and client `a8b6a9f` passed the complete isolated candidate package gate, including actual counter/chat/dashboard browser acceptance, runtime/refresh coverage, generated applications and source-free consumers. The full linked core regression passed 858 tests/83 suites with all-four 100% library coverage. After package cleanup, default load, three-trial memory overhead, Live HTML load and JSX performance gates all passed sequentially; production audit reported zero vulnerabilities. Exact archives, report hashes, limits and results are recorded in `docs/CLIENT_POLISH_VERIFICATION.md`. Neither these Windows results nor the candidate's unchanged 0.1.0 version identify a compatible published release. Client publication/dependency integration, latest-head CI, website alignment, and the bounded Ubuntu recovery decision remain outstanding; no recovery rerun, threshold waiver, deployment or merge occurred.\n\n- Default client test polish (2026-08-30): `npm test` now delegates to the existing complete `npm run check` (linkage, build, types, original-source Node/Chromium coverage); no second harness or recursion is introduced. The actual default command passes all 77 tests in both modes and all-four 100% authored coverage. The old Node-only V8 command remains unchanged as `npm run test:v8`, including its known failure and thresholds; changing the default is not a claim that V8 coverage was fixed. The README's stale test counts and obsolete source-coverage failure were corrected. Exact evidence is in `docs/CLIENT_POLISH_VERIFICATION.md`; the senior critic approved the command/measurement boundary. No runtime, dependency, lockfile, publication or recovery-gate changes occurred.\n\n- Starter coverage closure (2026-08-30): all six generated applications now pass the existing original-TypeScript gate on Windows/Node 22.21.0/TypeScript 5.9.3: 104 tests in each of plain and instrumented execution, with all 600 statements, 299 branches, 160 functions and 472 lines covered. The only new case is explicitly labelled unit fault injection: the dashboard genuinely closes its SQLite database, then an injected cleanup error verifies that its fire-and-forget listener-error observer prevents an unhandled rejection while retaining the same rejected shutdown promise for the application owner. Cleanup runs once and the database can reopen. The manual event and scoped mock are not presented as a real OS/SQLite failure or mock-free IT; existing actual HTTP/WebSocket/SQLite integration cases are unchanged. The senior critic approved the test and retained production observer. No runtime refactor, exclusions, threshold changes or new coverage framework were needed.\n- Starter verification evidence: `coverage/starter-source/15f3404e-1f32-49da-a238-42fc91ff9d28/summary.json` (SHA-256 `bc6bc211ab24f5120fde37bb5df4bb9d914b813a66d35cce57604ef072295f1f`), completed normally at `2026-08-30T22:54:04Z`; the dashboard coverage report SHA-256 is `ee08cf1e53581e847e9f472d3628df72d0d51b52c978e9bc22111a9e63179b28`. The runner records identical original source/test inputs across both modes and retains the separate V8 reports; authored coverage is not a claim that compiler-generated V8 function counts reach 100%. Five collector tests also pass with all-four 100% helper coverage. This closes the known recipe gap, not every release requirement. A fresh read-only registry/PR check still finds client 0.1.0 without `./live-html`, Redweb 0.12.0 depending on `^0.1.0`, and draft PR #16 at older head `f86f47e`; publication, final-head CI and recovery disposition remain outstanding.\n\n- Client verification-command polish (2026-08-30): client `ee74017` now makes `npm run check` verify matching linkage before building, then run build/types and the existing complete original-source Node/Chromium gate. Redweb `45a34d5` provides canonical-path preflight and explicit expected-checkout validation; a fresh linked checkout does not need `dist` to reach its first build. Twenty-one unit/actual-process tests pass with all-four 100% collector/report-helper coverage; the actual client command passes 77 tests in both execution modes, real browser checks and all 791/521/125/659 source counters. The senior critic approved after the unbuilt-checkout issue was corrected. No duplicated browser harness, runtime change, lowered threshold or excluded source was added. The old separate Node-only V8 `npm test` remains unchanged and is not retroactively claimed green. Full Redweb regression immediately before this command-only increment passed 853 tests/82 suites and all-four 100% library coverage; later focused verification and exact report hashes are recorded in `docs/CLIENT_POLISH_VERIFICATION.md`. Remaining release checks, publication and bounded recovery decision are still open.\n\n- Client lifecycle/package polish (2026-08-30): fixed a demonstrated disposed-client bug in `redweb-client` commit `859487b`: sends/requests no longer enter a permanently unusable outbound queue after disposal. Unit and real-WebSocket regressions failed before correction and pass afterward; queued FIFO/cancellation/no-replay behavior is also verified. Removing the provably unreachable empty-entry branch simplifies the dense private queue. The final combined original-source Node/Chromium gate passes all 791 statements, 521 branches, 125 functions and 659 lines, with all 77 client tests passing in plain and instrumented execution. The complete isolated candidate package gate passed counter/chat/reconnect/disconnect, full browser acceptance, rendering/refresh coverage, all six generated applications, executable documentation, source-free execution and consumer checks. Client/server archives and reports are identified in `docs/CLIENT_POLISH_VERIFICATION.md`; the critic independently verified source coverage and packed-browser hashes. Production audit reports zero vulnerabilities with TLS verification retained. This closes the matching packed-pair verification item, not registry publication, the separate failing standalone Node-only V8 command, remaining application/tool coverage, memory acceptance or other open release requirements. No runtime research, recovery/CI threshold change, dependency/lockfile change, deployment or merge occurred.\n\n- Release-priority decision (2026-08-30): at the maintainer's request to get past open-ended recovery research, exact V8 invalidation/code-lifetime attribution is deferred to `docs/RECOVERY_FOLLOWUP_SPIKE.md`. No additional heap/tracing work is required merely to explain every runtime byte before completing implementation. The next recovery-specific release decision is bounded to one original and one split-process baseline run in the failing Ubuntu/Node 22.23.2 environment, followed by an explicit disposition; this is not a repeatability certificate or automatic replacement of the existing gate. Historical failures, the 110% limit, CI behavior, required coverage, delivery/cleanup/shutdown and package compatibility remain unchanged. Deferral is not a claim of stability, a passed memory gate, or authorization to ship a failed check.\n\n- Offline client function-attribution milestone (2026-08-30, Windows/Node 22.21.0): the independent research recommendation was completed against the existing private snapshot pair, without a new measured workload or changing the original failed capture report. All 261 final deoptimization-bearing Code objects are referenced by current code fields of preexisting functions/closures; the only double-version group is unchanged. Added Code objects group as 180 Node, 22 ws and two harness; their attached streams can include preexisting nodes, so associated byte totals are not exclusively new allocation. The three unchanged Socket identities are stderr cache and two prototypes, and an unchanged eight-node worker/module path reaches `initAsClient`. Current code-field attachment does not establish executable validity or exclusive ownership. Full results, limits and hashes: `docs/RECOVERY_CODE_ATTRIBUTION.md`. The senior critic approved tooling `8a15569`, independently reproduced the report and approved its interpretation. Full regression passed 853 tests/82 suites in 434.002 seconds with pretest/types and all-four 100% instrumented-library coverage; 29 native unit/real-process/socket/snapshot tests cover all six snapshot-analysis modules at all-four 100%. Broader diagnostic-tool coverage, the original CI recovery failure and historical shutdown timeout remain open. No production runtime, dependency, frozen helper, npm link, workload, acceptance limit, publication, deployment or merge changed.\n\n- Client heap-survival milestone (2026-08-30, Windows/Node 22.21.0): one reviewed 7,400-connection run captured two private client snapshots, then failed its 1 MiB detailed-report limit. The failed original is preserved; reviewed offline reanalysis of the same snapshots succeeds without rerunning the workload. Net snapshot growth was 937,387 shallow bytes, including 801,792 in code-related categories, with concrete deoptimization/relocation/feedback data survival. No WebSocket/Sender/Receiver/Timeout instances remained; Socket and HTTPParser cohorts were unchanged. These snapshot-instrumented results do not resolve the original recovery gate or prove exclusive ownership; function/root attribution can continue offline. The critic independently reproduced the summary and approved `docs/RECOVERY_CLIENT_HEAP.md`. Full verification passed 841 tests/81 suites with pretest/types and all-four 100% instrumented-library coverage; 17 native unit/real-process/socket/snapshot tests cover all five snapshot modules at all-four 100%. Broader diagnostic-runner coverage gaps and the historical shutdown timeout remain open. Tooling commits: `4d036b4`, `bd68503`. No production runtime, acceptance limits, dependencies, frozen helpers, npm links, publication, deployment or merge changed.\n\n- Client deoptimization milestone (2026-08-30, Windows/Node 22.21.0): one reviewed client-only `--log-deopt` run preserved the unchanged worker/workload and verified all 7,400 acknowledgements plus empty measured registries. Of 703 interval records, 698 were GC weak-object dependency invalidations, four eager wrong-map bailouts and one eager not-a-Smi bailout. All four `ws.initAsClient` optimized versions were matched to subsequent weak-object invalidations, explaining the observed replacement sequence in this run without identifying retained-memory ownership or resolving the original recovery gate. The critic independently verified hashes, code-address sequences and the report at `docs/RECOVERY_DEOPTIMIZATION.md`. Full verification passed 833 tests/79 suites and pretest/types at all-four 100% instrumented-library coverage. Seventy-nine focused unit/real-V8/process/network tests pass; both parser modules are all-four 100%, while combined diagnostic-tool coverage remains 83.01% statements/lines, 97.64% branches and 94.87% functions. The historical shutdown timeout remains unexplained despite passing this run. No acceptance limit, production/library or worker source, dependency, frozen helper, npm link, publication, deployment or merge changed.\n\n- Client compilation census (2026-08-30, Windows/Node 22.21.0): one predeclared client-only native code-log run acknowledged all 7,400 replies, emptied measured registries and preserved verified source/log hashes. Between native after-warm/after-final boundaries, 851 creation events were recorded (843 TurboFan and eight baseline-tier), principally Node networking, `ws`, and harness/worker helpers. `ws.initAsClient` produced four distinct optimized versions for one function identity. These are concrete compilation candidates, not retained-size accounting or a recovery fix; no speculative production optimization was made. All phase measurements, grouped events, observer effects, pinned source references and remaining causal questions are in `docs/RECOVERY_CODE_CENSUS.md`; the senior critic independently approved the evidence and report. Full verification passed 813 tests/77 suites and pretest/types with all-four 100% instrumented-library coverage. Fifty-nine focused unit/real-child/network tests pass; the new census parser is all-four 100% in Jest and native coverage, while combined diagnostic tooling remains 80% statements/lines, 97.02% branches and 93.54% functions. The earlier shutdown timeout remains unexplained even though that test passed in this run. No acceptance limit, runtime/library source, dependency, frozen helper, npm link, publication, deployment or merge changed.\n\n- Research-led runtime controls (2026-08-30, Windows/Node 22.21.0): one fresh baseline, one complete GC/bytecode trace and one client-only JIT-disabled control ran sequentially against identical source fingerprints. Each acknowledged all 7,400 exact replies and emptied measured registries. Baseline client heap grew 848,928 bytes after warm-up, versus 5,144 bytes with client JIT disabled, strongly supporting a JIT-dependent contribution without establishing exact retaining objects or resolving the original shared-process CI failure. The traced server finished at 110.08095117632178% of warm heap and reported zero bytecode-flushing events; this negative result is preserved. Full trace logs/hashes, native-output framing limits, all phase measurements and critic review are recorded in `docs/RECOVERY_RUNTIME_CONTROLS.md`. Twenty-four focused unit/real-process/network tests pass. Native diagnostic-tool coverage remains below its unchanged 100% gate (76.03% statements/lines, 95.57% branches, 91.66% functions). Full verification passed pretest/types and 777 tests but failed one live WebSocket shutdown timeout (75/76 suites passed); all-four 100% instrumented-library coverage does not waive that failure. No acceptance limit, runtime/library source, dependency, frozen helper, npm link, publication or deployment changed.\n\n- Split-process recovery milestone (2026-08-30, Windows/Node 22.21.0): a diagnostic-only server/client separation acknowledged all 7,400 exact replies using the fixed connection counts, batches, server policies and settling sequence, with empty measured registries throughout. The server peaked at 108.35068839111547% of warm heap and finished at 95.39871085688438%; the native load generator reached 113.29229497229116%. This identifies the sustained above-110% ratio in the split run as client-process behavior, not the cause of the original shared-process CI failure. Code/metadata growth and a late server bytecode reduction guide the next retaining-path investigation; no code bytes were subtracted or acceptance limits changed. Full verification passed 770 tests/76 suites plus pretest/types at all-four 100% instrumented-library coverage. Sixteen focused unit/real-process/network tests pass; separate native coverage of the diagnostic tooling is only 74.83% statements/lines, 95.12% branches and 80% functions, so its 100% gate remains unmet. Full measurements, methodological differences, source fingerprints, raw-report hash and remaining boundaries are recorded in `docs/RECOVERY_INVESTIGATION.md`. No production implementation, dependency, frozen helper, publication or deployment changed.\n\n- Resumed recovery investigation (2026-08-30, Windows/Node 22.21.0): a capacity-only synchronous upgrade experiment removed unnecessary admission allocations but failed the unchanged recovery gate at 110.33689250984354%. Additional regressions demonstrated an unprotected throwing authorization getter and bypass of a prototype-level authorization replacement. The experiment was fully discarded and its initial reviewer approval withdrawn; production runtime remains identical to `2410e60`. Two retained compatibility regressions pass on the restored runtime. Final focused verification passes 143 tests/ten suites at all-four 100% coverage of `BaseSocketServer.js`; the earlier discarded candidate's 759-test/full-coverage pass is not a final-tree certificate. One native-`ws`-only diagnostic acknowledged all 7,400 messages and emptied its client registry but reached 113.16102565999951% of warm heap. It omits Redweb's features and supports a runtime contribution, not complete attribution or a waiver. Full measurements, trace limitations and local control source hash are recorded in `docs/RECOVERY_INVESTIGATION.md`. The critic approved only these retained regressions and the report. No threshold, workload, production implementation, dependency, frozen helper, publication or deployment changed; recovery remains unresolved.\n\n- Final linked-quickstart check: after the critic's two test-harness findings were fixed (explicit TAP selection and preserving primary failures alongside integrity-check errors), the reviewer approved this scoped increment. The corrected real-npm check passed again in 24.6 seconds with all 14 generated acceptance cases, no mocks or candidate overrides, unchanged developer inputs/link and completed owned-workspace cleanup. Six documentation unit/generator-process tests passed with all-four 100% coverage of `Documentation.js`; two unrelated documented-application tests were not selected in that focused run. Generated-artifact and all three TypeScript pretest configurations also passed. These are scoped results, not a new full-release certificate.\n\n- Linked quickstart correction (2026-08-30): the unreleased README/recipes previously installed only the Redweb tarball, which resolves the incompatible published client. They now explicitly prepare and link the matching client checkout after installation, explain rebuilding/relinking, and distinguish local development from deployable releases. An isolated real-npm check copies client inputs, uses its own global prefix, executes the printed commands without candidate overrides, compares all four rebuilt bundles and verifies resolution from installed Redweb. The realtime starter passed all 14 HTTP/WebSocket/process cases; the interactive watcher is deliberately left to its existing separate gate. Documentation generator units passed all-four 100% scoped coverage. Initial verifier drafts failed because Node's inherited test-worker context suppressed child results; removing that environment key restored independently observable acceptance output. No runtime code, dependency lockfiles or developer links changed.\n- Read-only release audit (2026-08-30): npm still exposes only the root export for `redweb-client@0.1.0`, and `redweb@0.12.0` still depends on `^0.1.0`. PR #16 remains draft. At pushed head `f86f47ede89520662cdf12a3404235baf9a29fb4`, push workflow `33322376349`, Node 22 job `99286494853` failed the unchanged steady-v2 recovery gate on Ubuntu 24.04.4/Node 22.23.2: cycle four retained 11,489,960 bytes against the 10,424,688-byte warm baseline (110.21874227794635%, above 110%). Final heap declined to 98.28000607787975% and registries were empty, but neither that decline nor the passing companion PR workflow waives the failure. Existing logs were read; no recovery rerun, threshold/protocol change or new memory diagnostic was performed. Publication, coverage gaps, website alignment and memory acceptance remain open.\n\n- Packed browser follow-through (2026-08-30, Windows/Node 22.21.0/Chrome 152.0.7977.64): the explicit candidate package gate now stages only 23 unchanged required browser drivers/fixtures beside extracted package code. It preflights destinations, never overwrites runtime files, links four allowlisted development tools individually, and requires client/WS/Express/Zod resolution inside the isolated consumer. Original package bytes and copied input ownership/hashes are rechecked even on failure, including same-bytes replacement through an outside directory link. Coverage copying and report writing are independent; either failure preserves the primary error and cannot print a passed result. Seventeen real-filesystem provenance/report units pass at all-four 100% coverage over the two new helpers; the senior critic independently reran them and approved after ownership/finalization findings were corrected. The combined package-tools gate passed 75 tests/nine suites in 206.996 seconds, with all-four 100% coverage over its eight helpers.\n- The final candidate run passed the complete package gate: independently installed examples/additions, unchanged full native-browser acceptance, frontend/refresh coverage, all six starters and executable documentation with source-free execution, consumer compilation and rendering/static export. Retained browser evidence: `coverage/packed-browser/2d98e957-05c8-4218-bae2-e127432f805b`, with Redweb archive SHA-256 `76d80bf28ab12524bb2968ee7e3e3a0a6b6ca84954ab66f2ec481fd8d3fb0211` and client archive SHA-256 `44eef644c484d12d07b8aea4ee9be8ecf44c6706a41e3e1455b129590799623f`. All 182 original package files and 23 harness inputs passed verification. Runtime run `103944c1-2d8f-4b03-b150-94b4df66ab9f` covers 426/426 statements, 262/262 branches, 64/64 functions and 351/351 lines; its measured bundle hash matches the client candidate. Plain/instrumented feedback/runtime/ownership/morph cases (18/13/7/46 assertions) and native keyboard/pointer selection passed. Refresh run `f1381721-fe3f-45da-9ac8-c38ef420dcbe` covers 82/82 statements, 44/44 branches, 12/12 functions and 71/71 lines, including actual back-forward-cache restoration. Retained source hashes/statuses were independently checked after writing. This completes the packed-browser behavior portion, not full original-client-source coverage or registry-release compatibility. External development tools are disclosed; the frozen driver's success is not proof of individual shutdown-error propagation. The 520/521 source-branch gap, memory acceptance and historical intermittent chat timeout remain open. No frozen file, production runtime, client source, lockfile, npm publication, deployment or merge changed; the development npm link is preserved.\n\n- Generated socket-test cleanup correction: an isolated unchanged chat run passed all 17 tests both before and after removing its source directory; this did not explain the preceding timeout. A new regression scaffolds and compiles the actual realtime starter, connects its generated helper to a real WebSocket server, and pauses the peer's reads. Before correction, cleanup completed only after forcibly releasing the peer and the regression failed; replacing the teardown's graceful close with termination passed. Explicit graceful chat/presence tests are unchanged. The fallback starts at cleanup entry, and the regression's new supervisory budget covers its own bounded commands and Windows cleanup. No existing chat command deadline, production runtime, coverage threshold, frozen verifier, dependency lockfile or publication state changed. The senior critic independently passed the real-network regression and approved this narrow fix; the historical timeout's cause remains unconfirmed.\n\n- Cleanup-increment verification (Windows, Node 22.21.0): `verify:package:tools` passed 58 tests/seven suites in 203.983 seconds, with all-four 100% coverage over its six verification helpers. The subsequent complete core run passed 735 tests/72 suites in 422.43 seconds, including the final strengthened assertion that cleanup actually leaves the client CLOSED; pretest/types/generated-doc checks and all-four instrumented-library coverage passed. These are scoped source-tree/linked-client results, not full client-source coverage, a fresh packed-release certificate, or evidence that the historical timeout's cause was fixed. The critic independently passed the final strengthened regression and approved the increment. The npm link remains intact; nothing was published, deployed, pushed or merged.\n\n- Earlier broader package-tools regression **failed**: 56/57 tests passed across seven suites, but the documented chat's source-free child timed out after printing its successful occupied-default-port test. The managed child command was terminated/cleaned by its owner; the subsequent generated-starter suite passed, and the separate candidate package gate passed. The cause is not established and passing neighboring runs do not waive it. Consequently that combined run reached 97.5% statements / 100% branches / 95.45% functions / 97.88% lines rather than passing its coverage threshold; `ClientCandidate` itself retained all-four 100%. All pretest/type/documentation-generation checks passed. No timeout, coverage or workload threshold was relaxed; diagnosing this chat verification hang remains required.\n\n- Explicit packed-client verification (2026-08-30): `REDWEB_CLIENT_CANDIDATE` selects a local tarball only for the isolated consumer, preserving the development npm link and both repository lockfiles. The verifier checks archive bytes/npm integrity, actual root/Live HTML export resolution from installed Redweb, canonical containment of every bundle, and unchanged fingerprints before/after dependency, browser and package phases. Extracted Redweb now uses isolated consumer runtime dependencies rather than the repository's linked dependency directory. Ten filesystem unit cases cover changed artifacts, missing/mismatched integrity, wrong identity, escaped paths and alternate unmeasured exports, with all-four 100% scoped `ClientCandidate` coverage. The critic independently reran these tests and approved the candidate-verification increment, not release approval.\n- The independently installed candidate package gate passed on Windows/Node 22.21.0, including all six generated starters and their source-free production checks, executable docs/action/room examples, consumer compilation, CLI additions, server rendering and static export. Its new native Chrome 152 phase loaded the **installed tarball** server/examples/client: server-timer counter updates, two-person chat, escaped text, draft preservation, public-client close/reconnect with presence recovery, and actual document departure all passed. Client archive `coverage/client-pair-6c5fae8/redweb-client-0.1.0.tgz` SHA-256 `44eef644c484d12d07b8aea4ee9be8ecf44c6706a41e3e1455b129590799623f`; Redweb tarball SHA-256 `fd67a94a853fdcb972aece5636b1806488060a5dce45ce22e57cc3983934b6ce`. All four installed ESM/CommonJS client bundles matched the locally tested build. This run preceded the last additional export-path assertions/documentation update; it is concrete candidate behavior evidence, not a final exact-release certificate.\n- Negative control with the same Redweb tarball and **no** candidate environment failed normally: published `redweb-client@0.1.0` does not export `./live-html` (`ERR_PACKAGE_PATH_NOT_EXPORTED` during installed counter startup). There was no fallback to the local link. Publication/version/lockfile integration therefore remains required; matching candidate behavior does not waive the unresolved 520/521 source-branch result, other coverage boundaries, memory acceptance, final cross-platform/PR checks or deployment alignment. No npm publication, deployment, merge or threshold change occurred.\n\n- Original client source coverage (2026-08-30): new `verify:client:source-coverage` instruments all executable TS/JS modules once with the existing `ApplicationCoverage` collector, feeds the same output to Vitest and esbuild/Chromium, retains separate Node/browser/worker reports, compares actual test inventories/results, and checks unchanged client inputs and loaded core tooling. Original AST forms are audited before classifying the two erased/static-linkage modules; unsupported runtime export-assignment/import-equals syntax fails closed. Vitest discovery is preserved rather than narrowed. Available command JSON reports survive failed child execution and cleanup; primary/report failures are combined and uncertain workspace ownership is recorded. Sixteen focused fixture/VM/real-subprocess tests pass with all-four 100% coverage over the new collector/report-preservation helpers. The senior critic independently reran them and approved this tooling increment, not release completion.\n- Final run `a6304aba-29ef-4cab-b126-a286c183cf72` (Windows, Node 22.21.0, TypeScript 5.9.3, esbuild 0.28.2, Chrome 152.0.7977.64) executed the same 75 client tests plain/instrumented, collected all five expected test realms, and passed native browser feedback/runtime/lifecycle/morph (18/13/7/46 assertions), shared protocol/wire (58/43 assertions) and actual keyboard/pointer selection checks. Plain browser bytes matched linked production bundles: Live HTML SHA-256 `517d4de56014efcbce0199407d8d60f9be3c9fba956ee96fc8c90cb7d28043c8`, transport `0e2da28559018a8bf44f37a41e20e464a905c7e8f678cd59dd758b9a7135c866`. Original-source totals are 790/790 statements, 125/125 functions, 658/658 lines and **520/521 branches (99.8%)**, so the gate correctly exits 1. Reports and instrumented/plain candidates are under `coverage/client-source/<run-id>`.\n- The remaining original-source branch is the defensive undefined-entry check after `queue.length > 0` and synchronous `shift()`. The reviewer found no supported callback path that can empty the dense queue between those operations; no private-state corruption test or coverage exclusion was introduced. Three legitimate boundary units now cover factory cancellation followed by failure, transports omitting optional `binaryType`, and late duplicate open notification after cancellation. All 75 tests and client type checks pass; the separate whole-client V8 gate still fails (56.57% statements/lines, 98.3% branches, 93.22% functions), while transport/protocol V8 source metrics are all 100%. These metrics have different instrumentation semantics and are not substituted for each other. The emitted frontend and refresh regression gate remains all-four 100% (runs `b4d967e8-ac73-4613-ba0b-c096f903bc06` and `25e6a5e5-45bc-4bbf-863b-c123f0a397fb`). No production source, dependency lockfile, frozen verifier, historical evaluation, memory threshold, npm publication or deployment was changed by this increment.\n\n- Senior critic approved the linked local-development increment after independently rerunning both native-browser lifecycle reproductions: one owner/connection on reentrant mount, and draft preservation on reply-time disposal. The final focused core run passed 38 tests in four suites; both lockfiles and frozen verification/evaluation sources are unchanged. Approval does not cover the still-open packed-pair, publication, remaining coverage or memory acceptance gates.\n\n- Final frontend cleanup removed unused helper bindings without changing behavior. Native run `b6fbe850-c95c-4148-a085-edfd0b1599d4` again passed all four frontend metrics (426 statements / 262 branches / 64 functions / 351 lines); covered source SHA-256 `154dcab6e3765237d39eec031f12df073b80b01b6414fe3a5d0df467c8db1287`, optional bundle `517d4de56014efcbce0199407d8d60f9be3c9fba956ee96fc8c90cb7d28043c8`. The eight collector tests retained all-four 100% coverage. Separate refresh run `90f5870b-d2dd-4a53-a9e8-a3e1a837f84b` passed 82 statements / 44 branches / 12 functions / 71 lines, with real history restoration and outage/draft controls. Generated documentation and all pretest/type gates were refreshed for the linked development guide; source and release coverage limits below remain unchanged.\n\n- Client-owned frontend (2026-08-30, Windows, Node 22.21.0, Chrome 152): migrated morph/state/action/form/feedback implementation to optional `redweb-client/live-html`; removed the three old core helper implementations and moved their request-state units to the client. The root client stays socket-only. Redweb emits a two-line import/mount bootstrap. Reviewer-reproduced reentrant mounting and reply-time disposal defects are fixed with native-browser regressions. Per user instruction, development now uses `npm link` to sibling `redweb-client`; both lockfiles remain unchanged. See `CLIENT_DEVELOPMENT.md` for reproducible setup and the explicit incompatible-published-0.1.0 boundary. Earlier draft tarballs were tested locally; the final linked revision is not claimed independently installed release-pair evidence.\n- Linked verification: 36 focused core tests, client build/types and 72 client tests passed; the frozen real-browser gate passed counter, chat, dashboard, CSS, JSX, collections, components, validated actions and docs composition. Native frontend run `453e21f8-5537-4e4a-950f-acbfc3ea57ae` covered all 426 statements, 262 branches, 64 functions and 351 lines, with identical plain/instrumented cases (18 feedback / 13 runtime / 7 lifecycle / 46 morph assertions plus native selection). Covered source SHA-256 `8c3170ea4524a0db8f326a5a9097c55f0f0b4cdda77366a01b6891aff4078cc5`; full optional bundle `1ba437b5c4b00d10c74920e24039b542832c3f86dae6e57a7b4a036e7fa6cd74`. Every bundled module is assigned to frontend or transport scope; only static export linkage lies outside executable frontend instrumentation.\n- Coverage boundaries remain open: source-level client transport/protocol now reach all four metrics at 100%, including an explicitly simulated stale-scheduler **unit** test. All client IT still use real connections/timers. Expanded whole-client Node coverage correctly fails (56.57% statements/lines, 98.27% branches, 93.22% functions) because the browser modules are not exercised in Node and an ActionFeedback V8 branch remains uncovered. Separate native-browser transport run `c1580684-9da0-4a64-beb3-c061c06757bc`, source SHA-256 `0e2da28559018a8bf44f37a41e20e464a905c7e8f678cd59dd758b9a7135c866`, passes the shared 58 protocol / 43 wire assertions but fails coverage at 346/368 statements, 232/259 branches, 61/61 functions, 315/328 lines. No thresholds or coverage scopes were narrowed to conceal these gaps. Client publication/versioning, ordinary clean-install/locked-package verification, whole-source coverage reconciliation and the unresolved memory gate remain prerequisites; nothing was published, deployed or merged.\n\n- Authorized client follow-through: user explicitly authorized changes to `C:/Users/arkam/Documents/redweb-client` and consolidation of generated frontend behavior into that client. Client branch `codex/client-lifecycle` now has critic-approved commit `c1663ae`: generation-aware cancellation, attempt retirement before terminal callbacks, reentrant connect identity, and jitter-callback cancellation. All 58 client tests pass, including 18 real-network integration tests; 100% statements/functions/lines and 99.63% branches leave the defensive stale-timer guard uncovered, so its coverage gate still fails. Existing client unit tests use transport fixtures; its new integration tests use real connections/timers. A local npm tarball from this commit was extracted and both ESM/CommonJS entrypoints passed the same 58 protocol assertions and 43 actual-network assertions. Artifact `coverage/client-candidate-c1663ae-20260830T1624/redweb-client-0.1.0.tgz`, SHA-256 `c8f17a9ea3bd9ca37e7a10527478ad7343bd36663c71b43998d4c0dd085abb3c`. Its metadata is still 0.1.0 and must not be confused with the published artifact; nothing was installed over Redweb's existing dependency or published. Core's six focused tests and all pretest/type gates passed. Native browser verification of the candidate, frontend consolidation, full coverage, and release integration remain open.\n\n- Imported-client behavioral tests (2026-08-30): added shared public protocol units (58 assertions) and real-wire client cases (43 assertions) covering correlation, error envelopes, queued cancellation/expiry, binary messages, malformed frames, callback isolation, graceful/policy/abrupt close, native constructor/upgrade failures, retry exhaustion and manual retry cancellation. Node integration uses actual `ws` connections; Chrome uses its native WebSocket/AbortController/timers. The same cases execute plain and instrumented without replacing dependency methods or browser APIs. Final installed-0.1.0 run `38aad9a8-72ba-4f9f-8669-9213ed2367e6` retains source hash `e27ca1e3c5187e51e996b33bb92065959ac4e32eb3e6700751404e6443d2e8de` and reaches 317/330 statements, 213/232 branches, 56/56 functions, 289/297 lines; it still correctly fails the 100% gate. Generated-runtime regression `cd57befe-dcce-4184-8d42-84bc7e58bfe1` remains 100% in all four metrics. Test ownership uses internally bounded waits, client disposal, combined primary/cleanup failures, and outer ownership of uncertain peer shutdown. A separately reproduced 0.1.0 defect allows a state observer's `close()` during reconnecting to be followed by a second real admission and an open client; the critic independently reproduced it. The user authorized changes to sibling `redweb-client`, now version-controlled from baseline `3433cd7` on `codex/client-lifecycle`. A generation-aware fix and eleven real-network regressions are under review there. No installed dependency, lockfile, npm release or site deployment was changed; Redweb still needs a verified matching client artifact before that fix can be claimed integrated.\n\n- Final-head CI observation at `f0bbb92`: the PR Node 22 [job 99282965186](https://github.com/lakam99/redweb/actions/runs/33321049166/job/99282965186) passed 713 tests in 68 suites (five platform-specific skips), then load, but failed unchanged recovery at storm 3: warm 10,426,736 bytes, third 11,478,784 bytes (110.0899073305%), final 10,347,768 bytes (99.2426393073%), all registries zero. The PR Node 18/20/24 and lifecycle jobs completed successfully. This recurring failure remains a release blocker; neither later recovery nor passing jobs waive it. No failed job was retried to obtain a green result.\n\n- Site synchronization and imported-client coverage audit (2026-08-30): the critic approved site commit `57e7530` on `codex/agent-ready-docs`. Its only changed file exactly matches the parsed core catalogue at `f0bbb92`; development, operations, release-status and dashboard-recipe content were updated. The existing site passed its production build (98 pages / 154 assets), actual HTTP routes/downloads/links checks and all six documentation tests at 100% scoped line/branch/function coverage. No deployment or publication occurred. Separately, `measure:browser:client` now measures the exact installed `redweb-client` browser ESM, rather than implying it is included in the generated Live HTML runtime denominator. Initial real-Chromium run `7eb53831-057f-443d-8671-25b595cf7dc7` on Windows/Node 22.21.0 / Chrome 152 retained source SHA-256 `e27ca1e3c5187e51e996b33bb92065959ac4e32eb3e6700751404e6443d2e8de`: 208/330 statements, 129/232 branches, 36/56 functions and 196/297 lines. It correctly exited 1 for incomplete coverage; identical plain/instrumented cases passed 77 assertions per execution through native DOM and actual HTTP/WebSocket traffic. No dependency methods, sockets or browser APIs were replaced. Report/source are retained at `coverage/browser-client`. This establishes a concrete remaining test gap, not a new application defect or a waived requirement. The collector's eight unit/real-filesystem tests retained 100% of all four metrics; the generated-runtime regression also retained 100% (378 statements / 240 branches / 55 functions / 309 lines, run `36209e9d-31e7-4fde-a911-39a1c12b80f9`). Memory acceptance remains unchanged and unresolved.\n\n- Exact-source diagnostic coverage (2026-08-30): `npm run verify:recovery:diagnostics` now runs the same graph unit assertions through Node's native test runner, plus real valid/malformed/oversized-file CLI subprocess cases. It enforces 100% statements/branches/functions/lines over exactly `scripts/diagnostics/recovery-heap-graph.cjs` and `recovery-heap-summary.cjs`, including their command-line paths and unloaded source. All nine tests passed natively (801.2648 ms), and the same nine passed under Jest (0.949 seconds). The native collector uses its own freshly cleared report directory; earlier C8-under-Jest measurements mixed transformed/wrapped and original source ranges and must not certify these modules. Raw reports are retained at `coverage/recovery-native-exact/tmp/`. Independently inspected all matching raw ranges: graph source 13,076 UTF-16 characters across six process reports, SHA-256 `2b9e229e64f528a8c431955382533cc4e6f5526092a315f01eda811991fe1d72`; summary source 3,854 characters across ten process reports, SHA-256 `8c0787f8a9ffa97fb8936a51a06a9c3d66e616da7ad242870e6a98f54747b495`. Every reported range matches that module's original Windows source length. Subprocess deadlines and runner-specific test timeouts are explicit. The already-present `expect` 29.7.0 assertion package is declared directly as development-only to reuse tests without duplication; no runtime dependency is added. The offline lockfile validation reported zero vulnerabilities. CI includes this scoped gate, but it does not cover `verify-recovery.js`, other verification tools, the dashboard callback, or imported browser-client code, and it does not waive the failed memory acceptance gate.\n\n- Single private graph investigation (2026-08-30), [job 99281753851](https://github.com/lakam99/redweb/actions/runs/33320593528/job/99281753851), source `d18fc0265c7672833b253bb28f309dd107baeecd`, PR merge `afc1f2fab127259e312e266b5a6cdc35c1aa372b`, Node 22.23.2 / V8 12.4.254.21-node.56: diagnostic failed, original status 1 preserved and both graph reports completed. Scalar warm 10,456,952 bytes; peak storm 3 11,799,872 bytes (112.8423655383%); final 11,104,704 bytes (106.1944627842%); all registries zero. Warm-to-third gross ordinary Object growth was 52 / 3,104 bytes: diagnostic-data-reachable growth 50 / 3,008 bytes, outside growth 2 / 96 bytes (one Promise candidate, one unresolved). All four net additional Arrays were diagnostic-data-reachable; outside Arrays had zero net growth. Third-to-final gross Objects grew 47 / 2,872 bytes: diagnostic reachable net 46 / 2,808 bytes, outside 1 / 64 bytes unresolved. Code grew 854,536 bytes then declined 685,408; hidden nodes grew 138,432 bytes then declined 1,768. Named transport/registry and closure net counts did not grow. These are data-reachability partitions, not exclusive ownership or a complete retained-size explanation. The explicit 400,000-edge traversal bound was reached in both comparisons: 594 / 289 candidates were truncated, with 608 / 297 total unresolved candidates. Ordinary application objects were prioritized; hidden-node searches account for most unresolved cases. No concrete application-owned retention fix is established, no code/diagnostic bytes are subtracted from acceptance, and no threshold or preconditioning change is justified by this report. Full normalized log: `coverage/recovery-ownership-22-99281753851.log`, SHA-256 `ec3381b5b43105cd1c166b7d5856bd376e903c1933f8575a592413a4a22a51e0`. Raw snapshots were not uploaded; the job removed exactly its three files, and the collected temporary workflow job is removed. The critic approved the actual PR increment before collection; release approval remains withheld.\n\n- Reviewed graph investigation (2026-08-30): diagnostic-only, nonenumerable PID/run and capture-phase markers identify existing tool-created records without adding a global root. The bounded native graph reader follows plain own data/backing arrays separately from prototypes/code, preserves gross deltas, and reconciles before/current/net counts and bytes, additions/removals and shared-node partition changes. Strong incoming paths yield fixed-label **candidate** retainers, never exclusive ownership. Work limits and unresolved/truncated counts are explicit; application objects are examined before hidden runtime nodes. Comparisons require matching run markers and adjacent warm / storm-3 / recovered phases. Input/output sizes are bounded; malformed/private input produces a constant error without echoing values. Markers and snapshot GC perturb the diagnostic and cannot change acceptance. All 24 focused unit/native-process tests passed in 31.106 seconds, including a real Redweb server holding a known unmarked object and then sharing it with a diagnostic record. The senior critic independently passed all seven graph units and approved the corrected analysis increment; coverage for the entire repository/tool CLI is not claimed complete. One private PR-16-only Node 22 graph investigation is prepared, retaining the original failure status and deleting exactly its three private snapshots after both reports.\n- Latest ordinary CI at `004a342`: the [PR Node 22 job](https://github.com/lakam99/redweb/actions/runs/33319859336/job/99279816026) failed recovery at storm 4: warm 10,419,896 bytes, fourth 11,490,936 bytes (110.2787974083%), final 10,199,904 bytes (97.8887313271%); all registries zero. The other ordinary jobs passed, including the same-head push Node 22 job and both lifecycle checks. Passing peers and the later decline do not replace this failed gate. Production-dependency audit on Windows with system certificate verification found zero vulnerabilities.\n\n- Targeted Node 22 peak diagnostic (2026-08-30), [job 99279398846](https://github.com/lakam99/redweb/actions/runs/33319701483/job/99279398846), source `ed7d0d1`, PR merge `5bed8291a1dd71aa7c591291bb73f3befbeb310f`, Node 22.23.2 / V8 12.4.254.21-node.56: failed, explicitly `diagnosticOnly: true`. Warm heap 10,461,008 bytes; five cycle ratios 107.4704082054%, 111.4016546015%, 111.7148940140%, 107.4922034282%, 105.2011049031%; all registries zero. Warm-to-third snapshot growth includes code +744,104 bytes and hidden nodes +134,856 bytes; named transport/registry/Promise/closure groups did not grow. Generic Object (+51 / +3,072 bytes) and Array (+4 / +128 bytes) counts did grow; retained diagnostic samples are a plausible contributor, not verified complete attribution. Third-to-final code shrank 674,296 bytes while generic Objects increased another 47 / 2,872 bytes. Snapshot GC and native observation perturb this run; it neither replaces the original failed gate nor proves absence of application retention. Raw snapshots were never uploaded and the exact three files were removed by the job's cleanup trap. Complete normalized log: `coverage/recovery-peak-22-99279398846.log`, SHA-256 `8db80d9731d9d86e088c7cadfabd1e5b71c6649142c0058cfb8ec326b3a8cfbb`. The collected temporary job is removed; no automatic repeat or more Node 20 traces are scheduled.\n- Local `ed7d0d1` resource verification (Windows, Node 22.21.0): framework metadata overhead 1,880.704 bytes/connection (2,048 limit); 3,200 messages / 32 clients at 6,521.846 messages/second, p99 6.9183 ms, slow consumer contained. Normal recovery **failed**, with warm 10,603,808 bytes, storm 4 11,699,144 bytes (110.3296476134%), final 10,506,872 bytes (99.0858378424%), every registry zero. Later recovery is not a waiver for the intermediate breach. Separately, live-HTML load passed 200 expired renders / 110 live clients / 8,059,712-byte heap delta; JSX passed 10,000 component rows in 52.6 ms with 1.3 MiB retained. These measurements are not an exclusive-host benchmark. Final focused recovery tests passed 15/15 in 28.049 seconds; documentation units passed 4/4. The senior critic verified the actual PR diff `3a9047a..ed7d0d1` and approved only this diagnostic/test increment, not release acceptance.\n\n- Original-TypeScript recipe coverage rerun `13b63aca-9a8a-4dd8-b46c-0d0f7b90d19e` completed all six applications with identical plain/instrumented inputs. The new native-response test closes the dashboard branch gap: 166/166 branches, 290/290 statements, 223/223 lines. Dashboard functions remain 73/74 (98.64%), so the combined source-coverage gate correctly exits 1. The other five applications cover all four authored-source metrics at 100%. The remaining dashboard shutdown-rejection callback is not claimed covered or removed solely to improve a percentage.\n\n- Exact-head verification and new failure (2026-08-30): `3a9047abdfe439d46a8def8013d7a1ae1f4d706d` passed all pretest/type checks and 705 tests in 67 suites (416.797 seconds), with 100% statements/branches/functions/lines over the library coverage scope. Its independent packed-package consumer gate also passed. These do not certify every tool/application. The [PR check run](https://github.com/lakam99/redweb/actions/runs/33318841001) passed all four runtime jobs and lifecycle checks, but the same-head [push Node 22 job](https://github.com/lakam99/redweb/actions/runs/33318839150/job/99277093406) failed the unchanged recovery gate: warm 10,416,232 bytes; five storms 10,861,928 / 11,210,440 / 11,466,512 / 11,281,856 / 10,311,656 bytes. Storm 3 reached 110.08310874796183%, exceeding the 110% bound by 8,656.8 bytes; every phase registry was zero. The final 98.99602850627751% result and passing peer run do not erase that failure. No threshold, workload, or warm-up increase is authorized by this evidence. The senior critic requested one Node 22 intermediate-peak diagnostic: inspect warm, storm 3, and storm 5, rather than only warm/final heaps. Opt-in native statistics observe each storm; private snapshots capture only warm, storm 3, and final/recovered to avoid extra snapshot GC before the peak or duplicate final captures. They use the existing exclusive-create mechanism; snapshot-induced GC means these are never acceptance runs. Publish only fixed-label aggregates, not raw snapshots.\n- Added no-mock negative-path checks: recovery requires explicit GC; the heap-summary CLI rejects malformed/private input without echoing contents or paths; metadata validation covers invalid types, offsets, and aggregate overflow. The initial 15-test run passed in 27.058 seconds. Tool coverage remains explicitly incomplete (94.06% statements/lines, 90.59% branches, 100% functions in that scoped measurement); library coverage is not a substitute. Dashboard middleware coverage uses the original registered handler and genuine HTTP-created Express request/response objects after native response destruction; it is a defensive-state unit test, not evidence that an aborted upload naturally takes that branch. The unreachable-in-current-fixtures defensive shutdown-rejection callback remains unclaimed.\n\n- Recovery protocol review outcome (2026-08-30): the single predefined Node 22 five-storm extension [job 99276100209](https://github.com/lakam99/redweb/actions/runs/33318462509/job/99276100209), head `75b7872`, passed with the same 10,431,536-byte warm baseline: 104.1409050403%, 107.3908962208%, 109.7890857109%, 108.4740732333%, 99.0396045223%. All phase registries were zero. The fourth/fifth-cycle decline resolves the specific concern that the earlier three-cycle cutoff hid continued boundary-crossing growth; it is not proof of an indefinite plateau. Complete normalized log: `coverage/recovery-extension-22-99276100209.log`, SHA-256 `895389df0f69c450d69b9473fe458c4c4225514fa906f75548a1390dc737e64f`. The temporary job was removed after collection in `484892d`. The senior critic approved promoting fixed preconditioning with **five** measured storms as the stronger default/minimum, keeping the same baseline/110% bound and preserving explicit `cold-v1` comparisons and historical failures. Final ordinary five-cycle cross-version checks remain required; the earlier four-runtime candidate evidence covered three storms, with this separate extension covering five only on Node 22.\n- One-hour raw-socket soak completed normally from `2026-08-30T14:05:51.033Z` to approximately `15:05:53Z`, Windows/Node 22.21.0, using code loaded at `bfcad29`: 64 clients, 3,600 seconds, 721 samples, 2,155,695 messages sent and 2,155,609 received (99.9960105674%). Final heap was 11,347,696 bytes versus 11,402,568 warmed bytes (99.5187750689%); all eight resource-trend gates passed, final clients/rooms/sessions/in-flight counts were zero, and native handles were 1 before/2 after. Report: `coverage/final-soak-bfcad29-20260830T1406Z.json`, SHA-256 `5c760822eb1664d00b4823f970e7b19c501e697d28ad8dc0413bd683ddcc3055`. The managed process and child both exited; no scheduled task was created. This is raw `/soak` transport evidence, not browser/authenticated-application certification. It overlapped other verification workloads, including the temporary heavy diagnostic-test assertion described below, so it is not an exclusive-host benchmark. The later inspector-only source correction is not exercised by this inspection-disabled soak.\n- Inspector correctness follow-through: PR run `33318223609` exposed a real closing/reconnect race in Node 22's inspection integration test. The inspector classified an attached non-open socket as `retained`, while admission correctly rejected reconnect until that socket and disconnect hook were cleared. It now reports such a transport as `detaching`; only detached sessions can be retained. A native `WebSocket.close()` regression checks synchronous CLOSING classification, actual cleanup and subsequent reconnect, without replacing browser/transport APIs. Fourteen unit/integration tests pass with 100% statements/branches/functions/lines over `Inspection.js` (2.165 seconds); the critic independently passed all nine inspection integration cases. This changes optional observation, not socket admission or disabled runtime paths. Final full-suite/package verification of the corrected observer remains required.\n- Verification status before that inspector correction: the local regression run completed 702 tests/67 suites in 427.011 seconds with 100% instrumented-library metrics, but started at `e8af515` and overlapped recovery-tool/test edits, so it is not an exact-head final certificate. A fresh extracted/independently installed package gate at `75b7872` passed and production audit reported zero vulnerabilities with certificate verification retained. Those results precede the inspector correction. Current acceptance remains open rather than treating the scoped diagnostic/test results as a completed release.\n\n- Private object-retention diagnostic (2026-08-30): [job 99274635931](https://github.com/lakam99/redweb/actions/runs/33317917054/job/99274635931) at the PR merge of `e8af515` captured warm/recovered native heaps on Node 20.20.2 with the same library/lockfile Git objects as `bfcad29`. The workload ran with only PATH and explicit diagnostic settings, and private snapshots were removed without uploading them. Logs contain fixed-label counts/self-sizes and numeric runtime statistics only; stdout/stderr were kept separate. Snapshot self-size grew 787,696 bytes, including 708,168 bytes classified as code (about 89.9%), 48,640 hidden bytes and 28,784 internal-array bytes. All reported transport/registry/Promise/Abort/closure/native counts and self-sizes stayed unchanged; WebSocket objects were absent in both samples. Fourteen plain objects and one array were added, but their ownership is not proven by aggregate counts. The scalar ratio was 109.2529123779%; snapshot-induced GC/work makes this diagnostic-only, not replacement acceptance. Complete normalized log SHA-256: `dde0e58f5587066a404f281cbd7c362a2aaebac432b48dc38223edfddb267d79`, local `coverage/node20-recovery-objects-99274635931.log`. The temporary job was removed after collection in `65b24ee`.\n- Fixed recovery candidate `steady-v2` (`7f078fc`) is an experiment, while `cold-v1` remains the default acceptance selection. It performs one predetermined storm-sized preconditioning phase, the original warm phase, then three storms, with the existing 400 ms expiry wait/two collections and empty registries after every phase. Every storm compares against the same warm baseline at 110%; no adaptive warm-up, rolling baseline, code subtraction or best-run selection is used. Default traffic is 1,200 preconditioning, 200 warm and three 1,200-connection storms, batch size 50. The first Windows/Node 22.21.0 candidate run passed all three bounds at 104.3210981256%, 108.3906658119% and 109.4339394681% of the 10,594,344-byte baseline; all phase registries were zero. That increasing series is retained, not characterized as an established plateau. The predeclared Ubuntu candidate matrix in [run 33318223609](https://github.com/lakam99/redweb/actions/runs/33318223609) passed once on each runtime: Node 18.20.8 (104.2104610568%, 102.4019590749%, 103.2427803674%); Node 20.20.2 (108.2880813492%, 108.5443856822%, 107.3774428581%); Node 22.23.2 (104.2508887370%, 108.0581383742%, 109.6167873404%); Node 24.19.0 (100.0454742472%, 100.2683641760%, 100.3819395979%). All phase registries were zero. Complete normalized logs are retained as `coverage/recovery-steady-<node>-<job>.log`; jobs are `99275451365`, `99275451465`, `99275451409`, `99275451463` respectively. The one-shot matrix was removed in `37908c6`. Acceptance promotion remains subject to review; historical cold-protocol failures stay explicit.\n- Diagnostic test follow-through: eleven focused real-process/native-snapshot and aggregate-unit cases passed (14.947 seconds before the explicit cold-protocol fixture setting). Invalid snapshot metadata/indexes fail closed, private data is not printed, existing snapshot files are not overwritten, and protocol phases use one baseline. A draft test's deep object comparison of two roughly 10 MB snapshot buffers caused excessive CPU/memory use; native byte comparison fixes the same assertion. Two runs containing the draft were stopped after verifying their exact Jest processes, and only their two known private snapshot directories were removed. They are not acceptance evidence. The hour-long soak was left running; its environment includes this temporary co-load and must not be described as an exclusive-host benchmark.\n\n- Recovery-verifier correctness and investigation (2026-08-30): commit `a7644a8` rejects zero, empty, fractional, nonfinite and unsafe connection counts, plus overflow of derived capacity/count values, before opening a listener. The previous verifier could exit successfully with one warm connection and zero storm connections; that concrete false-pass is now rejected. Seven real-process tests pass on Windows/Node 22.21.0 (8.175 seconds), including actual socket traffic and opt-in native V8 diagnostics. This is not a claim of 100% coverage of the recovery script. The senior critic approved the input validation, tests and unchanged default workload, waits, collection calls and 110% retained-heap threshold.\n- Node 20 remains an open release gate, not a retry-to-green result. PR run [33316834970](https://github.com/lakam99/redweb/actions/runs/33316834970) at `9c2a35b` passed Node 18/22/24 and the Linux browser/package/lifecycle job, but normal Node 20 recovery retained 9,383,592 bytes against an 8,519,480-byte baseline (110.1427786672%; all three registries zero). Its trace-only follow-up also failed (110.1523531234%). The same-head push run [33316833095](https://github.com/lakam99/redweb/actions/runs/33316833095) passed; that does not invalidate the failure. Earlier fixed-revision diagnostic job `99271719623` at `bfcad29` passed at 109.9059980659% with trace flags, which alter measurement conditions; it is not replacement acceptance evidence.\n- A single native-statistics diagnostic [job 99273406662](https://github.com/lakam99/redweb/actions/runs/33317461291/job/99273406662), PR merge `f58810d` of `a7644a8`, ran the default 200 warm/1,200 storm/50 batch workload on Ubuntu 24.04, Node 20.20.2, V8 11.3.244.8-node.38. It independently asserted unchanged `src`, root entrypoint and lockfile Git objects versus `bfcad29`. The diagnostic failed at 110.1683068116% (8,561,032 to 9,431,544 bytes; registries zero). Native samples show code-space growth of 329,088 bytes and old-space growth of 552,072 bytes; external/ArrayBuffer readings stayed unchanged. Code-and-metadata grew 540,568 bytes, but this is an overlapping view and must not be added to the space totals or subtracted from the acceptance measurement. Samples allocate and can perturb the diagnostic. These results establish a compiled-code contribution, not a complete attribution or proof that application retention is absent. The complete ANSI-normalized log is retained locally at `coverage/node20-recovery-spaces-99273406662.log`, SHA-256 `990bb192a5d8ad4e5b33d4a8a0d6b0c086e5bd293da7bd22496668f0ffd3a74d`; the temporary job was removed after collection in `e84aa1b`. No release-completion box is changed by these diagnostics.\n\n- Site synchronization follow-through (2026-08-30): refreshed the existing `codex/agent-ready-docs` checkout from the canonical catalogue, updating Live HTML guidance, release-status evidence and all six complete recipe downloads while retaining 66 catalogue pages and all five task guides. The existing Redweb renderer, dependency/lockfile, layout, social metadata, Firebase/manual workflow and running preview are unchanged. Production export builds 98 HTML pages/154 assets; actual HTTP checks verify 11 examples, 29 API articles, every Markdown/source download, internal links and metadata. Six documentation tests retain 100% line/branch/function coverage over seven scoped documentation/import modules, including real multi-version import/build/HTTP checks and immutable archives. The senior critic confirmed exact normalized catalogue parity and honest unreleased/published-version boundaries. This is local site readiness only: no public deployment or npm publication occurred, so public release-alignment checkboxes remain open.\n\n- Authored-application coverage increment: `verify:starters:source-coverage` instruments the exact original TypeScript before compilation and seeds every generated module at zero. It validates source maps and counter shapes/values before merging actual process-exit reports, preserves compiler options/source/output/test hashes, and verifies unchanged application/test inputs across plain and instrumented runs. Plain execution uses the shipped `test:coverage` command; instrumented execution runs the same tests without rebuilding over instrumentation. V8 reports are retained separately with matching module membership. Received reports are not a census of spawned processes (hard termination can prevent reporting), and Istanbul does not independently count optional-chaining short circuits. No authored exclusions, lowered thresholds or production-code changes were used.\n- Final source run `5404b0a2-5bbc-452e-88f5-ac7defe4a40b` (`2026-08-30T13:27:32.826Z`–`2026-08-30T13:30:43.605Z`, Windows/Node 22.21.0, TypeScript 5.9.3, Istanbul instrumenter 6.0.2) is correctly **failed**, not a completed all-application gate. Realtime/chat/site/socket/HTTP-WS reach 100% tracked authored statements/branches/functions/lines. Dashboard reaches 290/290 statements, 223/223 lines, 165/166 branches (99.39%) and 73/74 functions (98.64%). Remaining entries are the destroyed-response guard at `recipes/dashboard/app.tsx:31` and rejected-shutdown callback at line 75; no genuine public recipe failure path to the latter was identified, and no behavior was replaced to manufacture coverage. All six retained V8 reports have 100% statements/lines/branches after closing the real chat standalone-startup and HTTP/WS default-port gaps; V8 functions remain 58.33% realtime, 59.09% chat and 88.7% dashboard, with site/socket/HTTP-WS at 100%. Report and plain/instrumented-output hashes were independently checked. These are different measurement definitions, not interchangeable exhaustive-branch claims.\n- Added real occupied-port/HTTP/WebSocket default checks, dashboard database-capacity 503 handling, abandoned plain/gzip uploads and graceful standalone startup with both default and production origins. Windows uses an actual IPC message to deliver Node's signal event instead of force-terminating the child before cleanup/reporting; Unix uses its OS signal. An intermediate package run caught expected `ECONNRESET` incorrectly rejecting a close wait; both waits now observe actual closure, and 12 planned real repetitions pass. The first ad-hoc repeat setup omitted copied CSS; using the recipe's actual build command corrected that setup. The final stable suite passes 684 tests/65 suites in 381.299 seconds with every pretest/type gate and 100% instrumented-library coverage. Five collector tests pass at 100% scoped collector coverage, including real process exits and denominator-negative fixtures; seven real MCP/package tests retain 100% adapter coverage. Final extracted/independently installed package consumers and all generated/Markdown applications with source removed pass; audit reports zero vulnerabilities with certificate verification retained. The senior critic approved the measurement boundaries and final cleanup correction. Frozen helpers/evidence are unchanged. Nothing was published/deployed, no new performance/platform/soak claim is made, and the full checklist remains open.\n\n- Final package-ownership verification (2026-08-30, Windows, Node 22.21.0, TypeScript 5.9.3): the stable full suite passed 679 tests/64 suites in 382.58 seconds, including pretest/type gates, with 100% instrumented-library statements/branches/functions/lines and a normal exit. The separate package-tool gate passed 34 tests/four suites in 207.218 seconds with all four metrics at 100% over exactly `VerificationWorkspace.js`, `verificationError.js`, `verify-starter.js` and `verify-documentation.js`. The final extracted/independently installed package gate passed after migrating top-level commands, including every generated/Markdown application with source removed. Real Chromium dashboard sign-in/private cards/draft preservation/HttpOnly cookies/logout/re-login/deletion and the complete counter/chat/action/rendering browser gate passed. The combined browser coverage gate passed collector tests and 100% morph/runtime/refresh thresholds in Chrome 152.0.7977.64: morph run `5e2851d0-cb6b-4d12-bbb4-54606597510d`, runtime run `b570e11f-fb53-4e36-9376-93a0b1cc53f6`, and refresh run `cf33da88-634f-48f4-b6d3-89a32c85c190`; plain and instrumented acceptance both ran, including actual HTTP/WebSocket actions and observed back-forward-cache restoration. The senior critic approved after the non-stringifiable-error cleanup bypass was fixed and independently checked. Audit reported zero vulnerabilities with certificate verification retained. These are scoped verification improvements, not whole-tool/application coverage, additional platform certification, or a fresh load/soak claim. All remaining checklist gaps stay open; nothing was published or deployed, and frozen helpers/evidence were unchanged.\n- Package-verification ownership increment: starter and Markdown verifiers now require the caller's shared workspace owner and await the actual initializer, npm test and source-free process phases. Individual starter command deadlines remain 30 seconds; outer test supervision now accommodates the sequential phases and cleanup instead of abandoning asynchronous work early. Top-level npm packing, native archive extraction, CLI/type/consumer checks also use the managed owner with direct argument arrays and no shell. This supersedes the earlier note about the starter/documentation preparation path; unrelated compiler/browser/evaluation tooling still has its own audit requirements. Frozen evaluation/process/browser/network helpers were not edited.\n- Dashboard verification retains its existing public entrypoint while adopting the workspace owner. Listener readiness and browser operations are bounded; rejected versus still-pending page openings remain distinct, late pages are closed, page/app cleanup failures are retained, and uncertain cleanup prevents workspace deletion. Real npm/descendant tests verify timeout termination, nonzero diagnostics, phase ordering, malformed initializer output and unsupported engines. A real malformed DevTools HTTP peer proves setup failure and natural verifier exit; an actual Windows lock proves primary error plus retained-workspace reporting. A discovered falsy/non-stringifiable thrown-value bug is fixed by one coercion-free error normalizer shared by the owner, dashboard, action-feedback verifier and browser collector. Native cross-realm errors keep their identity; non-Error causes remain attached without invoking object hooks.\n\n- Final starter-increment verification (Windows, Node 22.21.0, TypeScript 5.9.3, c8 10.1.3): the stable full suite passed 663 tests/62 suites in 342.474 seconds, all pretest/type gates and 100% instrumented-library statements/branches/functions/lines. The extracted/independently installed package gate passed, including generated and documented applications with their source removed; audit reported zero vulnerabilities with certificate verification retained. The senior critic approved after the recorded fixture/process/evidence findings were fixed. Final measurement run `34010655-cb64-47a6-ab07-78e1858911e8` completed from `2026-08-30T12:35:19.418Z` to `2026-08-30T12:37:06.260Z`; saved report/output hashes were independently checked. Every application has 100% V8 source-mapped statements and lines. Site/socket also have 100% branches/functions; realtime has 100% branches/58.33% functions, chat 98.57% branches/59.09% functions, dashboard 100% branches/88.7% functions, and HTTP/WS 96.42% branches/100% functions. Uncalled TypeScript decorator accessors affect function counts; standalone canonical chat startup and an HTTP/WS factory fallback remain branch gaps. These percentages are application measurements, not whole-repository coverage or a new browser/platform/resource certification. Nothing was published or deployed, and frozen evidence/helpers were unchanged.\n\n- Starter application verification increment: all six initializers now include source maps, a development-only c8 dependency, `npm run test:coverage`, and ignored report output. The shared declaration avoids copying dashboard-specific setup into other recipes. A separate measurement runner uses managed initializer/npm subprocesses, one canonical dependency-link helper, exact expected TypeScript-module membership checks, unique run directories, source/report/input/output hashes and explicit running/failed/measured status. It records compiler/runtime versions and preserves cleanup uncertainty; a failed rerun cannot silently reuse an earlier successful summary. This is measurement, not a new 100% acceptance claim. The existing source-free package tests remain separate; their older synchronous preparation path is unchanged and remains a broader tool-ownership audit item.\n- Expanded canonical recipe tests use actual chat components for bounded history/presence and room isolation, and HTTP/WebSockets for normalized name conflicts, reconnect reservation and explicit leave. Socket tests exercise duplicate join/resume, unknown sessions, all 100 retained session slots, capacity rejection and successful resume afterwards. Shared process tests launch the actual application entrypoint against an occupied port; Windows needs both wildcard and loopback reservations. The first fixture incorrectly reserved only loopback and timed out; corrected fixtures pass. Review also caught a test-helper shadowing error and primary-error replacement during cleanup; both were corrected. An overlapping full-suite attempt observed the shadowing error and generated-doc drift during those corrections, so it is not stable-tree acceptance evidence.\n\n- Final refresh-increment verification: the stable-tree full suite passed all 663 tests/62 suites in 323.927 seconds, including pretest/type gates, with 100% instrumented-library statements/branches/functions/lines and a normal process exit. The combined native-browser coverage gate passed eight collector tests and all morph/runtime/refresh coverage thresholds; after the final pending-close ownership adjustment, refresh run `77966f51-23a1-4699-b3a0-fe108a1c8428` independently repeated all plain/instrumented acceptance cases at 100%. The complete development-browser gate passed generated watcher rebuilds, failed builds, draft/focus retention and actual BFCache restoration. Seventeen focused collector/documentation/real-network tests passed with open-handle detection, including the final fuzz cleanup implementation. The senior critic independently approved refresh ownership and fuzz harness hardening. Package dry-run/prepack checks and generated documentation checks passed; test bridges, reports and fixtures are not packaged. This closes this verification increment, not the broader release checklist, and does not establish the cause of the preceding timeout. No publication or deployment occurred.\n\n- Fuzz regression observation during refresh verification: one full run reported 662 passing tests and a 5-second timeout in the existing malformed-text/binary-frame test, followed by a 5-second teardown timeout. The completed test runner retained its fuzz listener and had to be stopped after its process lineage and socket ownership were checked. An isolated original test then passed with open-handle detection; the original timeout cause remains unconfirmed. The test now gives each frame exchange a named two-second deadline, registers listeners before sending, rejects unexpected closure, and always removes its listeners. Its multi-exchange test budget is 30 seconds; teardown terminates its own adversarial clients and independently attempts server shutdown within bounded deadlines. The senior critic approved this as harness hardening, not a production socket fix. Ten planned consecutive focused runs passed after hardening. The failed run is not treated as a passing release gate.\n\n- Development-refresh coverage increment (Windows, Node 22.21.0, Chrome 152.0.7977.64, 2026-08-30): the combined browser gate now instruments the exact `refreshBrowser()` output under its existing self-only script policy. The collector uses the actual global directly instead of dynamic code evaluation; a VM regression with string code generation disabled proves this independently. Plain/instrumented browser runs use real HTTP outages, redirects, malformed/partial/non-JSON responses, history navigation with observed BFCache restoration, delayed script loading, real file input, and native typing/clicks. They verify retained drafts, clean automatic reload, explicit discard, invalid host/revision configuration and a native-function unit call to a stopped poll. The stopped-poll call is unit robustness, not a claim of an organically scheduled callback. All 82 statements, 44 branches, 12 functions and 71 lines are covered without exclusions; source SHA-256 is `d8a14c1b44dab03f1fd6a62ef4b6721bb5e4baa9348b5f2e93a7fa2316bf8cd0`. The only production simplification removes a redundant inner stopped check: cleanup always changes generation before any current-generation finally can schedule another poll. The senior critic independently confirmed that invariant.\n- Refresh verification ownership: source-map/counter snapshots before navigation and real pagehide beacon uploads retain coverage across actual reloads; snapshots contain coverage metadata, not draft values. Explicit delivery waits precede peer shutdown. Snapshot failure cannot skip page closure, multiple failures remain aggregated, and uncertain peer cleanup is propagated to the workspace. Native close promises remain awaited even after the listener stops accepting connections. Three real HTTP/TCP regressions cover malformed/wrong-map/oversized/aborted uploads, port conflict and an upgraded connection that keeps a close pending through two timeouts before actual release/reopen. Test bridges/report endpoints remain confined to verification fixtures, not shipped runtime code. Imported-client coverage, full tool/application coverage, broader platform/resource evidence and publication alignment remain open.\n\n- Complete-runtime verification: the main suite passed 659 tests/61 suites in 350.641 seconds with all pretest/type checks and 100% instrumented-library statements/branches/functions/lines. The final combined browser-coverage gate passed the seven collector tests, standalone morph/selection checks and complete-runtime checks; run `30bcacb1-711a-40f6-b984-e7697f9d1da1` records the latter's plain/instrumented results. The full existing live-browser gate passed counter/chat/cards/components/JSX/dashboard/actions, and the separate development-browser gate passed real generated watcher reloads, failed-build handling, draft/focus guards, adverse HTTP peers and actual BFCache restoration. The senior critic approved the implementation, including the exact malformed-patch diagnostic and ordered post-reconnect action barrier. Documentation/unit checks and package dry-run/prepack checks passed; verification scripts, fixtures and coverage files remain outside the package. No npm publication, site deployment, remote CI, full imported-client coverage, or broader release completion is claimed.\n\n- Complete emitted-runtime coverage increment (Windows, Node 22.21.0, Chrome 152.0.7977.64, 2026-08-30): `npm run verify:browser:coverage` now measures the entire canonical `browserRuntime('/__redweb/client.js')` output, including embedded morph/feedback code and surrounding state/form/event wiring. All 378 statements, 240 branches, 55 functions and 309 lines are covered with no exclusions. The unchanged emitted-source SHA-256 is `5d156d2ac079ced2bb44de2c20aa92b39f4949516749f6dcd80d0fc9ae8bb399`; run-specific reports/source are under ignored `coverage/browser-runtime/`. Identical plain/instrumented runs reuse 46 morph and 18 feedback unit-style assertions, the real action acceptance driver, and 13 additional protocol/input assertions. Those exercise duplicate/scoped text targets, HTML target reindexing, boolean checkbox states, repeated/prototype-named form fields, ordinary unbound events, and an intentionally malformed component patch with its exact diagnostic. Native keyboard events update actual writable server state; closing the real client surfaces a failed state send, and a real ordered action/result round trip after reconnect verifies that the offline value was not replayed. The public runtime is not modified and no test bridge is shipped. The standalone feedback measurement remains available as `node scripts/verify-browser-coverage.js feedback`; the combined gate avoids repeating that acceptance run separately because the complete runtime includes it. Imported `redweb-client` code, development refresh, full application/tool coverage, broader platform/resource evidence and publication alignment remain distinct unfinished gates.\n\n- Action-feedback verification: `npm test -- --runInBand --silent` passed 657 tests/60 suites in 324.636 seconds, including all pretest/type checks and 100% instrumented-library statements/branches/functions/lines. The two new verifier cleanup regressions were added after that run's test discovery and passed separately against actual servers: a rejecting decorated-page disposal preserves both the original setup error and cleanup error, while a thrown non-Error value still fails after listener closure. The existing complete live-browser gate passed; the final combined generated-browser gate passed its seven collector tests, 46 plain/instrumented morph assertions, live selection actions, and plain/instrumented feedback acceptance with 18 ownership assertions. The senior critic approved after primary-error preservation, timeout supervision and unnecessary fixture-listener findings were addressed. Package dry-run/prepack checks exclude test scripts, fixtures and coverage artifacts; generated documentation checks pass. This verification increment does not certify a new npm release, deployment, all browser modules, or the full repository/application coverage requirement.\n\n- Action-feedback browser-coverage increment (Windows, Node 22.21.0, Chrome 152.0.7977.64, 2026-08-30): the shared browser coverage command now also instruments the exact emitted `browserFeedback()` source, including its embedded canonical `ActionFeedback` state machine. A test-only Express route delivers the canonical complete browser runtime with only this source segment instrumented; the actual client, HTTP/WebSocket server, DOM, timers and event APIs are not replaced. The existing action-feedback acceptance driver runs once plain and once instrumented, covering real validation/authorization/application errors, pending/completion, duplicate suppression, capacity drain, reconnect and draft preservation. Eighteen additional native-DOM unit assertions cover shared slot ordering, rebound/detached sources, retargeted/component-moved slots, authored attributes, fallback reuse and input/change revision tracking. Direct state-machine failure calls are explicitly unit-style checks, not simulated network integration evidence. All 123 statements, 56 branches, 15 functions and 109 lines are covered, without exclusions; emitted-source SHA-256 is `92a7468278dcd58b80448c9f90273a42b6a16de6386135afa0498c98e408c075`. Run-specific maps/source/status are retained under ignored `coverage/browser-feedback/`. No production behavior changes or runtime test bridge are shipped. Surrounding browser transport/form wiring, development refresh, broader application/platform coverage and final release gates remain open.\n\n- Native-browser coverage increment (Windows, Node 22.21.0, Chrome 152.0.7977.64, 2026-08-30): added `npm run verify:browser:coverage` and its bounded Node 22 CI step. The test instruments the exact emitted `browserMorph()` source, excluding the appended test bridge; identical 46 native-DOM assertions run in separate plain and instrumented documents. Keyed moves/removal, invalid boundaries, text/attributes, client-owned nodes, table/select parsing, SVG, form defaults/drafts and focus are exercised without replacing browser APIs. Direct helper robustness cases are unit-style browser tests, not claims that malformed helper arguments occur through production entrypoints. A separate uninstrumented Redweb page uses native keyboard/pointer events and two actual server actions over HTTP/WebSockets to verify selection preservation and server-default updates. All 170 generated statements, 140 branches, 18 functions and 128 lines are covered, with no exclusions. Emitted-source SHA-256: `7103a57f7ff0e9c330cbfdd2527651b0c765525ae362214ef4fb312cd5a3d066`; run-specific source, maps, browser version and status are written under `coverage/browser-morph/`.\n- The tests exposed and fixed duplicate-valued option selection: retained option identities now take priority, replacement fallback consumes each missing value once, and server-default comparison handles keyed duplicate moves without discarding unrelated drafts. Restoration and default matching use linear scans/maps. The redundant cursor/end fallback was removed only after verifying the bounded-range invariant. Seven collector tests enforce 100% scoped coverage and exercise absent/altered maps, empty source, incomplete execution, non-Error failures, and a real Windows file lock during final cleanup. Final reports are created only after workspace cleanup settles; failure/retained-workspace information cannot become a passing cleanup result. The senior critic approved the fix and harness after requiring keyed-default and cleanup-report regressions. Frozen browser/process helpers and historical trial evidence were not modified. Instrumentation dependencies are development-only and were already present transitively; production browser assets contain no coverage code or test exports.\n- Final increment verification: `npm test -- --runInBand --silent` passed 657 tests/60 suites in 333.9 seconds with all pretest/type checks and 100% instrumented-library coverage. The existing uninstrumented counter/chat/cards/components/JSX/dashboard/action browser gate, generated development refresh and actual back-forward-cache tests, packed/source-free consumer gate, and audit all passed. Live HTML load passed 200 expired renders/110 clients with 8,142,072 bytes heap growth; JSX rendered 10,000 component rows in 52.9 ms with 1.3 MiB retained. No npm publication, site deployment, new long soak or remote CI execution occurred. This closes the generated morph-module coverage gap only: browser feedback/transport/refresh modules, broader tools/application coverage, platform certification and final published-release alignment remain open.\n\n- CLI entrypoint/discovery increment (Windows, Node 22.21.0, 2026-08-30): added `npm run verify:cli`, a c8 gate over the actual shipped `bin/redweb.js` subprocess entrypoint, and a bounded Node 22 CI step. It reuses the existing real initializer/doctor/add tests instead of duplicating applications or narrowing `npm test`. The preliminary broad three-suite run passed 14 tests at 100% scoped coverage; the final command selects four actual-entrypoint tests in two suites and passed in 18.9 seconds at 100% reported statement/branch/function/line coverage (11 tracked statements, two branches, no separately tracked functions). This remains distinct from the retained 100% Babel-instrumented library report and does not complete repository/browser/application coverage. Initializer subprocesses now have explicit timeouts; generated-app closure is observed before startup, managed process cleanup is bounded, original errors survive cleanup failure, and uncertain cleanup retains/reports its directory while releasing local handles. The senior critic approved the cleanup and final gate/filter. No frozen process helper was modified; CI configuration is not a claim of an executed remote job.\n- Preregistered a fresh category-first discovery prompt before dispatching an agent with no conversation history. The frozen selection was Socket.IO 4.8.3 with a plain-DOM client; Redweb was not in the reported shortlist. Exact prompt, report and an independent primary-source fit assessment are retained under `evaluations/2026-08-30-02`. There was no implementation, install, repair, publication or deployment. Source/registry checks support plausible fit, not passing behavior; draft/presence/runtime acceptance was not run for that stack. The approximately 130-second research duration and search chronology are explicitly self-reported, and exposed host-project metadata prevents claiming full blinding. This completes the separate assigned-use/discovery evaluation mechanism, not automatic selection, a comparative success rate or proof that unpublished guide improvements are discoverable.\n\n- Task-guide/site increment (Windows, Node 22.21.0, 2026-08-30): added five task-oriented guides for private persistent dashboards, JSX without React, chat presence, typed match messages, and a shared HTTP/WebSocket listener. Each guide includes ELI5 explanations, acceptance expectations, deployment limits, and exact source from the owning canonical starter. Setup is shared with recipe/README generation; dashboard instructions provision an account before use. The catalogue contains 66 unreleased pages. The site preserves its existing Redweb architecture, design, installed published runtime, hosting configuration and historical release snapshots; landing/docs indexes surface the guides, and native expandable download lists expose every complete recipe file under its owning version. No new browser runtime or dependency was added. Invalid recipe references are rejected before documentation import writes.\n- Verification for this increment: the full core suite passed 648 tests/58 suites with all pretest/type checks and 100% instrumented-library statements/branches/functions/lines (5,473 statements, 4,048 branches, 984 functions; zero uncovered entries in the retained coverage report). Packed-package production consumers and all six generated/Markdown-extracted source-free recipes passed; the seven MCP tests retained 100% scoped coverage. A fresh site build and real-HTTP gate verified 98 pages, 154 assets, all Markdown/source downloads and internal links; six documentation tests passed with 100% line/branch/function coverage across seven included modules. Fresh focused core documentation tests and generator checks also passed. The senior critic approved the corrected guide/site integration after requiring accurate chat class names. These are local development results: no npm publication, site deployment, new browser/platform certification or current-turn resource/soak rerun is claimed. Published guide alignment, independent discovery, whole-repository/application coverage and final release gates remain open.\n\n- Onboarding/shared-listener increment (Windows, Node 22.21.0, 2026-08-30): shortened the README from 670 to 202 lines, placing channel-correct setup and the canonical counter first. One `Documentation.setup()` implementation supplies recipe and README commands; the initializer and install step use the same artifact/release. Counter and HTTP/WebSocket code blocks come from their generated recipes. Recipe-note links are labelled honestly, and historical 0.8/0.9/HTML migration guidance remains in a registered canonical guide. The catalogue now contains 61 unreleased pages. No publication or site deployment occurred; website alignment and task-oriented landing guides remain open.\n- Added the sixth `http-ws` starter: one supplied HTTP listener explicitly assigned to socket-service cleanup, raw `/chat` plus a separate `hello` handler, and the existing shared bounded entrypoint helper. Its real tests cover HTTP/WS on one port, simultaneous clients, strict socket paths, incomplete HTTP peers, repeated shutdown, and listener closure despite an application route-cleanup failure. The old separately maintained CJS example/verifier was replaced by this complete recipe in normal and packed documentation gates. HTTP service callbacks now infer Express request/response/next types; negative compile tests reject invalid request/response operations. No transport/rendering runtime behavior changed in this increment.\n- Verification: `npm test -- --runInBand --silent` passed 648 tests/58 suites and all pretest/type checks at 100% instrumented-library statements/branches/functions/lines (5,468 statements, 4,044 branches, 983 functions; zero uncovered entries). All six generated and Markdown-extracted starters passed their shipped real HTTP/WebSocket/process tests with source removed, including the extracted-package gate; the independent installed production consumer and generated additions also passed. The separate shared-helper gate passed 12 actual-process cases at 100% scoped coverage; seven MCP adapter tests retained 100% scoped coverage. The senior critic found and required a pre-write rejection for overlapping/nested README regions and accurate recipe-link descriptions; both were corrected and approved. Actual-command regressions cover missing, duplicated, reversed, stale, nested and crossing regions, normal/check/release modes, and preservation of README/catalogue/release snapshots on invalid input. Existing release snapshots were unchanged. Initial fixture/type failures were corrected without weakening checks; a separate final release-snapshot regression passed after the reviewer fixes. Whole-repository/generated-browser/new-recipe branch coverage, platform/browser certification, independent discovery, publication alignment and final release/soak gates remain open; this verification does not complete the full goal.\n\n- Shared-starter lifecycle increment (Windows, Node 22.21.0, 2026-08-30): all five initializers now copy one canonical `run-app.ts` entrypoint helper and its real-process test suite. Importing applications or the helper installs no process handlers and starts no listener. Standalone entrypoints call shutdown once for signals/listener errors/native close, keep repeated signals from bypassing active cleanup, preserve an existing failure status, and enforce a five-second whole-application deadline rather than stopping the timer when HTTP closes. Rejected cleanup permits natural exit if no live handles remain but retains an unreferenced force-exit deadline for leaked resources. Factory cleanup and cooperative cancellation remain application responsibilities. The dashboard retains auth/database cleanup and removes its competing HTTP timer.\n- Shared-starter verification: final stable-tree `npm test -- --runInBand --silent` passed 648 tests/58 suites, every pretest/type gate and 100% instrumented-library statements/branches/functions/lines. All generated and documentation-extracted applications pass their shipped HTTP/WebSocket/process tests before and after source removal; packed/source-free and independently installed consumer checks pass. The separate `verify:starters:lifecycle` gate exercises 12 actual-process cases and reports 100% coverage of the exact compiled helper (57 statement entries, 20 branch entries, four function entries). Cases include both termination handlers, repeated signals/errors, invalid deadlines, factory/cleanup failures, leaked peers, hung cleanup after HTTP close, occupied ports and preserved failure status. Windows emits signal events explicitly inside child processes; Linux CI is configured to exercise actual OS signals but has not been executed locally. The gate is included in CI; dashboard application coverage now also runs the shared suite.\n- The senior critic approved the shared helper after requiring hard-kill/reaping in the test supervisor and inclusion of the lifecycle suite in dashboard coverage. An initial auxiliary WebSocket fixture error listener and three stale generated-file expectations were corrected, with targeted tests and then the entire suite rerun successfully; no runtime checks or coverage thresholds were weakened. Real Chromium counter/chat/dashboard/action/CSS/JSX and generated-watcher refresh gates pass, including actual back-forward-cache restoration. Seven MCP tests retain 100% adapter coverage with the updated 59-page unreleased catalogue. Core transport/rendering code is unchanged from the preceding resource/audit-verified increment, so those measurements remain scoped to the same runtime code. Docker was rechecked and remains unavailable; Node 24/container execution, whole-repository/browser coverage, public documentation alignment, neutral discovery and final release/soak gates remain open. No publication, deployment or merge occurred.\n\n- Owned-listener and release-trust increment (Windows, Node 22.21.0, 2026-08-30): static/live HTML incomplete HTTP-body peers and incomplete TLS handshakes reproduced shutdown hangs before the fix. One shared lifecycle owner now tracks TCP peers before listen, terminates them at the final cleanup deadline, retains its forced-peer guard until native close, handles already-closing listeners, and leaves borrowed listeners/peers alive. Constructor failures remove framework registrations without closing supplied listeners. Cleanup preserves primary and secondary failures. Live HTML documentation and declarations explicitly describe successive phase-local deadlines rather than one total application deadline; forced transport closure does not promise persistence or completed application work.\n- Verification: 648 tests/58 suites and all pretest/type gates pass with 100% instrumented-library statements/branches/functions/lines (5,466 statements, 4,042 branches, 982 functions). The 13 added tests include actual HTTP/WebSocket/TCP/TLS ownership/deadline regressions plus unit failure-path coverage; the senior critic independently passed all 13 and approved the scoped implementation. Full real Chromium rendering/dashboard/action/counter/chat and development-refresh gates pass, including actual back-forward-cache restoration. Extracted/independently installed package consumers, every generated and documented starter with source removed, nine documentation tests, and seven separately instrumented MCP tests pass. The documentation catalogue contains 59 unreleased pages; browser-generated code and broader tooling still do not have full independent repository-wide coverage.\n- Resource gates after the heavy test/package/browser runs: 3,200 messages/32 clients at 6,682.16 messages/s and p99 6.66ms; slow consumers contained. A 200-connection warmup/1,200-connection storm recovered to 105.12% of warmed heap with zero clients/rooms/sessions. Three 500-client memory trials measured 1,880.608 extra bytes/connection against the 2,048-byte limit. Live HTML passed 200 expired renders/110 clients with 7,853,728 bytes heap delta; 10,000 JSX component rows took 49.7ms with 1.3MiB retained. Nine alternating 100,000-message raw-socket trials against commit 46d3ebc measured 0.1153% throughput and 1.5837% p99 regression, within unchanged 3%/5% gates. Dependency audit reported zero vulnerabilities with certificate verification retained.\n- Release guidance now distinguishes the package installation floor, maintained runtimes, configured CI versus executed evidence, registry signatures versus provenance, and support/reporting limits. Node 24 was added to CI, not claimed as locally executed. The recorded exact published 0.12.0 installation passed npm signature verification with no invalid/missing entries, but registry metadata supplied no provenance attestation. Docker was not running; the environment rejected the attempted Docker start/isolated Node 24 setup, so neither execution is claimed. Shared starter signal/resource cleanup, container verification, published-site alignment, independent discovery evaluation, broader coverage and final release/soak audit remain open. No npm publication or site deployment occurred.\n\n- Development-refresh increment: generated HTML development commands enable loopback-only refresh without new application boilerplate. Existing applications can opt in through `development.refresh`; explicit false overrides the development environment flag, production construction rejects enabled development features, and raw sockets/static exports remain separate. One specialized page manager uses shared path validation and document/response seams, preserving ordinary caching and static serialization-before-disposal ordering. Its three reserved resources expose only a boot revision and fixed external JavaScript/CSS, never inspection data. The initial document carries its original revision, including when the server restarts before the module arrives.\n- The browser polls sequentially with bounded requests, ignores outages/build failures/malformed responses/redirects, and pauses across navigation. Clean documents reload; conservatively detected edits retain the current DOM until a native keyboard-operable confirmation. No form data is saved to browser storage, sent to the refresh endpoint, replayed or restored after reload. The old document is not a valid replacement-process session, and restart resets server memory. Actual generated realtime/site watchers, TypeScript failures/recovery, CSS changes, peer-triggered root patches, focus/drafts, keyboard confirmation, real listener failure/recovery, partial-body timeout, delayed-module input/password/file/contenteditable/select edits under self-only CSP, and actual back-forward-cache restoration all pass in Chromium. Existing dashboard/counter/chat/action/CSS/JSX/browser regressions and independently installed/source-free package checks pass. The new browser gate is wired into CI with a ten-minute outer deadline; CI itself has not been run by this local verification.\n- The senior critic approved after CI wiring, asynchronous watcher launch failure, and inherited-pipe closure findings were fixed. Cleanup attempts all owned page/process/peer resources, preserves primary errors, and retains the workspace when process closure cannot be established. Two additional regression tests first reproduced changed static serialization ordering and weakened invalid-path rejection; both were fixed through shared manager behavior rather than duplicated validators. Sealed evaluation/browser/network helpers and historical evidence were not edited.\n- Final runtime verification: 635 tests/56 suites and all pretest/type gates pass at 100% instrumented-library statements/branches/functions/lines (5,414 statements, 4,032 branches, 973 functions). Nine new unit/real HTTP-WebSocket tests cover option/environment/production boundaries, actual resource access/caching, reactive root updates, static export, request-independent document factories, lifecycle order and reserved-path validation. Generated browser code is behaviorally exercised by the real-browser gates above, not independently covered by the library's string-generator coverage; broader browser and script-tool instrumentation remains an open requirement. No mocks replace the integration transports, compiler, watcher or browser.\n- Local resource checks (Windows, Node 22.21.0): 3,200 messages/32 clients at 6,653.55 messages/second and 6.54 ms p99 with slow-consumer containment; 200 warm plus 1,200 reconnect-storm connections leave empty client/room/session registries and heap at 105.03% of warm baseline. The existing three-trial/500-connection metadata gate reports 1,880.752 extra bytes/connection against its 2,048-byte limit. Live HTML passes 200 expired renders/110 clients with 7,929,288 bytes heap growth; JSX renders 10,000 rows in 50.2 ms with 1.3 MiB retained. A nine-trial/100,000-message disabled raw-socket comparison against pre-inspection `46d3ebc` passes unchanged limits: throughput regression 1.9489%, p99 regression 2.3365%. This baseline includes both inspection and refresh changes; these are local regression measurements, not proof of zero overhead or production capacity. Audit reports zero vulnerabilities with TLS verification retained. The optional documentation adapter passes seven actual-process/package tests at 100% scoped coverage. No new long soak, publication, deployment or site edit occurred, and old policy-rejected cleanup was not retried. Broader repository/browser instrumentation, deployment/published-version alignment, neutral discovery and final release audit remain open.\n\n- Verification-tool follow-up: a small workspace owner now runs the clean installed consumer's npm, compiler, CLI and test commands directly through Node without a Windows shell, using the existing managed-process primitives unchanged. It bounds output and command duration, waits for tree termination/closure before normal cleanup, and aggregates the primary failure with cleanup failures. If a parent has already exited while a descendant retains its pipes, cleanup cannot be claimed: the runner releases its own handles, reports/retains the workspace, rejects later commands, and remains failed even if its caller swallowed the original command error. This is containment and honest reporting, not a claim that detached descendants are always terminable after their parent exits.\n- Eleven new tests use actual processes and Windows file locks: success, literal arguments, environment overrides, verbose output, nonzero/launch errors, running descendant termination, primary-error preservation, locked-directory failure, swallowed/rethrown/replaced cleanup errors, and an outer supervisor proving the failing verifier exits while the test supervisor separately handles its known surviving descendant. Async directory removal lets Windows release handles without blocking the event loop. The senior critic approved after inherited-pipe liveness and swallowed-error findings were fixed. Final pretest/type gates and 626 tests/54 suites pass at 100% instrumented-library coverage; the new verification workspace separately passes 100% statements/branches/functions/lines. The clean extracted/installed package gate also passes. Sealed evaluation sources and evidence were not modified, and policy-rejected old cleanup was not retried. Broader repository coverage and the remaining adoption/release requirements stay open.\n\n- Development-inspection increment: Live HTML and raw socket servers accept explicit `development: { inspect: true }` and expose immutable, JSON-compatible `inspect()` snapshots. Disabled inspection returns null; production construction rejects enabling it before route/listener setup. A specialized renderer records reactive invalidations and flush attempts without changing ordinary invalidation/flush/message paths. No endpoint, browser resource, background timer or automatic logging is added. The normal page-session construction path selects the renderer class once.\n- Snapshot descriptions contain declarations and local numeric IDs, never state values, action arguments, rendered HTML, credentials, request contents or exception text. Descriptor-only metadata reads skip application accessors; standard action metadata no longer invokes accessor replacements. Shared instances are deduplicated and pending/connected/detaching/retained sessions remain distinct. Description lists have a shared 1,000-item current-metadata budget; history separately retains 256 events with at most 100 owner names each. Labels are bounded, IDs use weak keys, and shutdown does not leave retained page references in the journal. Standard metadata before first construction is explicitly unobserved, not asserted empty. History is neither action attribution nor delivery tracing and excludes initial/static/nonreactive/offline rendering paths.\n- Verification (Windows, Node 22.21.0, TypeScript 5.9.3): final clean run passes all pretest/type gates and 615 tests/53 suites at 100% instrumented-library statements, branches, functions and lines (5,343 statements, 3,980 branches, 958 functions). Thirteen new unit/real HTTP-WebSocket tests cover opt-in/production boundaries, standard and legacy metadata, accessors, immutable/bounded snapshots, shared sessions, disconnect/reconnect/revocation, dynamic routes, room/session counts, lazy context preservation, failed/superseded rendering and secret omission. An earlier run passed all tests but reported 99.94% statement coverage after a source edit during execution; the stable full rerun above is the coverage result. The senior critic approved after type-placement, accessor, documentation-budget and probe-environment findings were corrected.\n- Extracted-package/source-free consumers and all generated starters pass. An independently installed production-dependency consumer checks disabled inspection under production mode and real chat actions plus inspector metadata/privacy under explicit development mode. Standalone actual-browser dashboard, actions/feedback, CSS, JSX, collections, components, counter and chat checks pass. The optional read-only documentation adapter passes seven actual-process/package tests at 100% scoped coverage. The generated unreleased catalogue now contains 58 pages including the inspection guide. Nothing was published, deployed or changed in the site repository.\n- Disabled raw-socket overhead was compared against pre-inspection commit `46d3ebc`. The initial 5-trial/20,000-message measurement failed the unchanged 5% p99 limit at +5.2331% (throughput -0.0518% regression). A baseline-against-itself control showed +0.3343% throughput regression and -2.7595% p99 regression. The longer comparison, selected before running at 9 trials/100,000 messages, passed unchanged limits: +0.7200% throughput regression and +3.4823% p99 regression. These local measurements do not prove universal zero cost. The existing 500-connection/three-trial multiplayer metadata memory gate reports 1,880.704 extra bytes/connection against a 2,048-byte limit; it is not an inspector-enabled memory benchmark.\n- Resource regressions pass: 3,200 messages/32 clients at 6,748.62 messages/second and 6.70 ms p99 with slow-consumer containment; 200 warm plus 1,200 reconnect-storm connections leave zero client/room/session registries and heap at 105.00% of warm baseline. Live HTML passes 200 expired renders/110 clients with 9,246,784 bytes heap growth; JSX renders 10,000 rows in 49.5 ms with 1.3 MiB retained. Audit reports zero vulnerabilities. These are local increment gates, not a new long soak or cross-platform/production capacity certification.\n- The first clean-package install hit the host's certificate-trust mismatch, timed out, and revealed a verification-tool weakness: a Windows shell timeout left npm running, and cleanup's EBUSY obscured the original error. The exact orphan was stopped; the final package/audit runs passed with Node's Windows system trust store and TLS verification retained. The separately reviewed follow-up above addresses timeout containment and primary-error preservation. Environment policy rejected removal of the known `redweb-inspection-baseline-93760727c6a249dabad7aba04f778266` and `redweb-live-package-gcqfTd` temporary directories; neither cleanup was retried through an alternate mechanism. Broader coverage, browser refresh, deployment/published-version alignment, neutral discovery evaluation and final release audit remain open.\n\n- Additive CLI increment: `redweb add page`, `redweb add component`, and `redweb add socket-route` now generate one canonical source module plus an artifact-only real-network test. The source/test writer is shared with init, while effective configuration/dependency reading is shared with doctor. Add requires explicitly installed application/test dependencies, accepts configuration/source/test path options and dry-run/JSON, rejects conflicts, and reports registration as pending with a named import and build/test arguments. It never rewrites application entry points, registration lists, manifests, scripts or configuration. Socket additions demonstrate a bounded validated ping/pong handler; the existing full socket starter remains the join/move/resume recipe.\n- Layout verification uses a virtual prospective file with TypeScript's own include/exclude matching and in-memory compiler emission. It accounts for imported sources when determining the actual output, rejects existing-output relocation when roots are inferred, compiled CJS test locations, unsupported emit pipelines, and source/output package-module mismatches. The compiler is used for parsing/checking/emitting into memory only, never importing application modules or writing build output. Human commands are quoted for the host shell. The critic approved after identifying inferred-root prediction, compiled-test inclusion and PowerShell quoting problems, all corrected with regression cases. The first network matrix also exposed an incorrect component fixture; both fixture and registration guidance now use owned component fields.\n- Verification (Windows, Node 22.21.0, TypeScript 5.9.3): all pretest/type gates and 602 tests/51 suites pass with 100% instrumented-library statements, branches, functions and lines (5,214 statements, 3,894 branches, 924 functions exercised). All three generated artifacts compile and pass actual HTTP/WebSocket tests across CommonJS/ESM and standard/legacy decorators; real-filesystem negative tests cover configuration/layout/dependency/safety failures. The final package gate passes, including a clean npm tarball consumer with explicit dependencies that generates, compiles and tests all three additions, plus the existing source-free starters/documentation/example checks. README, CLI documentation and changelog describe the new unreleased commands. This does not close broader all-repository coverage, compiler/platform compatibility or final release requirements. No publication, deployment, new capacity claim or change to the frozen agent evaluation occurred.\n\n- Chat/scaffold foundation increment: the canonical chat component and generated starter now demonstrate `@action({ input })` plus inferred `ActionInput` parameters. One shared text-schema builder normalizes Unicode/whitespace and enforces visible-character/length rules; room membership/name-collision rules stay in the room/component. Invalid transport input gets built-in safe form feedback and retains its draft, rather than repeating parsing code in each method. The starter explicitly declares Zod as an application dependency; core Redweb remains validator-independent. Direct server calls require parsed input, and documentation distinguishes ordinary unexpected fields from reserved keys Zod may discard. CommonJS example compilation enables interoperability without disabling library checks.\n- The existing initializer now uses one small `FilePlan` writer intended for the forthcoming additive CLI. It preserves skip-existing semantics, preflights planned file/directory and case conflicts, rejects lexical escapes, ancestor junctions and nonportable device/stream/path aliases, uses exclusive creation, and reports completed writes separately from the attempted path after a failure. This is not a transactional installer or filesystem-tree lock. The `redweb add` command itself remains pending; extracting its shared writer does not complete that requirement.\n- Verification: 586 tests/49 suites and all type/pretest gates pass with 100% instrumented-library statements/branches/functions/lines; the final writer alone also passes all four 100% coverage thresholds using actual filesystem tests. The full real-browser gate passes, including invalid chat schema feedback, draft retention, counter/chat/presence, dashboard, CSS/JSX and component behavior. The package gate now additionally installs a real clean production-only tarball consumer, proves core/counter work without Zod or TypeScript, then explicitly installs application Zod and exercises the packed chat via HTTP/WebSockets; extracted source-free starters and documentation recipes pass too. The critic approved after planned-prefix conflicts, portable-path aliases, partial-write reporting and dependency-hoisting gaps were addressed. Initial probe failures exposed a case-sensitive test expectation and omitted protocol-version negotiation, both corrected in the probe; no runtime protocol workaround was made. No publication, deployment, new load/capacity claim or edit to the frozen agent trial occurred.\n\n- Independent-agent increment: preregistered counter/chat protocol and exact prompts, immutable nominated archive/submission/checker hashes, and a reviewed black-box evaluator are recorded under `evaluations/2026-08-30-01`. The frozen assigned implementation passes its first independent production build and all ten real-interface/browser checks, with no repair request. The evaluator verifies the archive/lockfile and all 154 installed package files, builds a separate execution copy, uses three isolated Chromium profiles with actual input/pointer events, excludes ongoing HTTP data transports, and confirms server-owned state after acceptance. Eleven real-browser fixture controls and fourteen real-subprocess/filesystem/archive unit tests validate the checker, including Windows detached-child and locked-file cleanup. Exact browser metadata and failed/not-run checks remain in reports; original bytes survive Git line-ending normalization. This is one unreleased-candidate task, not a production or general agent-success claim.\n- The unnominated agent selected Socket.IO + Express from its own three-framework shortlist. Package-directed queries and exposed host-project metadata prevent treating that result as a blinded category-search discovery measurement. The frozen report is preserved; broad discoverability remains unproven. Agent-reported implementation time/local attempts are distinguished from independently captured build/check evidence. Follow-up observations include making validated action schemas more prominent in the canonical chat example; no frozen submission was edited to improve its result. Further release/coverage/provenance and developer-experience requirements remain open.\n- Evaluation increment regression checks: all pretest/type gates and 580 tests/48 suites pass with 100% instrumented-library statements/branches/functions/lines. Extracted-package/source-free consumers and the standalone actual-browser dashboard/action-feedback/CSS/JSX/component/counter/chat gate pass. Separate c8 measurement of evaluation tooling's unit+control runs is 69.69% statements/lines, 85.34% branches and 72% functions; it excludes the separately executed preparation/seal/independent run and does not satisfy the full repository coverage requirement. No runtime implementation change, publication, deployment, or new performance/capacity claim occurred. Exact staged artifact hashes were checked against the seal, and no evaluation browser/application processes remain.\n\n- Runtime-diagnostics increment: one maintained failure catalogue supplies fixed HTTP/upgrade and typed action/access messages. Upgrade rejection now distinguishes bad credentials (401), denied origin/permission/placement (403), protocol negotiation (426), application/unsafe-placement failures (500), and deadline/cancellation/capacity/readiness failures (503), with `Redweb-Error` and no-store caching. Raw identity semantics and boolean admission results are unchanged; safe placement redirects remain 307. This intentionally replaces the earlier catch-all 401 behavior and is documented as unreleased.\n- Admission composes the common bounded-operation primitive, including checkpoints between origin, identity and placement stages. Late results cannot start the next stage, while the actual evaluation remains charged until settlement. Page errors, including unprotected render failures, no longer expose Express development stacks; already-ended responses are left alone and partial responses are closed. Mutable typed errors are normalized at both HTTP and established-socket send boundaries, including application catch/rethrow decoration. Callback text is not reflected in upgrade responses/logging; a failing upgrade logger cannot prevent rejection or reservation cleanup. Raw `exposeErrors: true` and application-owned logs remain explicit disclosure boundaries.\n- Diagnostics verification (Windows, Node 22.21.0): 566 tests/46 suites, all pretest/type gates, and 100% instrumented-library statements/branches/functions/lines pass. The 28-case real-network diagnostics suite covers failure categories, repair without restart, actual-work capacity, synchronous deadline overrun, redirects, malformed exceptions, partial responses, failed loggers, page upgrades, and sanitized versioned/unversioned/live-action error delivery. The critic approved after independently reproducing and retesting malformed error normalization and edited socket exception disclosure. Coverage also caught a test using the wrong Express-app option; the corrected regression proves actual page rendering before partial-response closure and leaves no pending test condition timer.\n- Diagnostics resource gates: final 500-connection/three-trial metadata overhead is 1,880.62 bytes per connection (2,048-byte limit); 200 warm/1,200 storm reconnects leave zero client/room/session registries and heap at 105.13% of warm baseline. Raw load passes 3,200 messages/32 clients at 6,675.02 messages/second and 6.47 ms p99 with slow-consumer containment. Five alternating disabled-feature trials against the site's installed 0.12.0 package show throughput improvement 0.98% and p99 improvement 9.73%, within unchanged regression limits. Live HTML load passes 200 expired renders/110 clients with 9,059,672 bytes heap growth; JSX passes 10,000 rows in 61.1 ms/1.3 MiB retained. These are local increment checks, not a new long soak, cross-platform certification, or claimed production capacity.\n- Extracted npm package/source-free consumers and the standalone browser counter/chat/dashboard/feedback gate pass. The optional read-only MCP adapter passes seven real-process/package tests at 100% scoped coverage. Synchronized site documentation builds 89 HTML pages/124 assets and passes actual HTTP/link/Markdown/source-download checks plus 100% coverage for its five scoped documentation modules. The new guide documents browser handshake-header invisibility, legacy unversioned errors, deliberate application-thrown diagnostics, nested page-lifetime/outer admission cancellation limits, and uncertain application side effects before retry. Audit reports zero vulnerabilities with certificate verification enabled. No npm publication or site deployment occurred; broader generated-code/browser coverage, independent agent evaluations, provenance/compatibility/soak and final release agreement remain open.\n\n- Room-authorization increment: protected rooms use `await socket.enterRoom(id)` / `rooms.enter(id, socket)` with the same bounded authorization primitive as pages/actions. Synchronous joins cannot bypass a configured policy. Pending checks are deduplicated and globally/per-connection bounded; timed-out underlying work remains charged until it actually settles. Final membership commits recheck capacity and connection eligibility. Leave, disconnect, replacement, clear and drain cancel pending work; membership removal precedes cancellation callbacks, including nested leave/clear cases. Protected socket publication requires current sender membership; server-side registry publication stays explicitly privileged. Grants are not per-message authorization or distributed revocation.\n- Shared identity now uses common request-context types and a bounded, immutable original request snapshot, captured before raw-route admission. Socket identity references cannot be replaced; raw application-owned principal objects remain compatible. Idle connections retain the snapshot but lazily allocate the public context/UUID/cancellation objects on first access; first access after disconnect/drain returns an aborted signal. A complete page plus `/team` room example shares authentication and explicitly revokes both lifetimes. Its exact TSX compiles in standard and legacy decorator modes and passes actual HTTP/WebSocket checks with source removed, including against the extracted npm package.\n- Room verification (Windows, Node 22.21.0): 535 tests/44 suites, all pretest/type gates, and 100% instrumented-library statements/branches/functions/lines pass. Twelve real-network room tests cover grants/denials, all entry points, cancelled/hung policies, budget recovery, simultaneous capacity, disconnect/replacement/drain, nested cancellation/clear, serialization-time revocation, missing senders, binary/unversioned diagnostics, malformed middleware request data, and lazy disconnected contexts. The senior critic approved after independently reproducing and retesting the revocation/publication findings and reviewing lazy context allocation. Packed-package/source-free consumers and real browser counter/chat/dashboard/feedback gates pass.\n- Resource verification: the unchanged 500-connection/three-trial idle metadata gate initially failed at 3,116.72 extra bytes per connection, exposing eager context allocation; lazy allocation reduced repeat measurement to 1,852.88 bytes, below the unchanged 2,048-byte limit. Reconnect recovery (200 warm + 1,200 storm connections) returns all client/room/session registries to zero and heap to 105.14% of warm baseline. Raw load passes 3,200 messages/32 clients at 5,708.85 messages/second and 8.12 ms p99 with a contained slow consumer. Live HTML load passes 200 expired renders/110 clients with 7,694,248 bytes post-cleanup heap growth; JSX serializes 10,000 rows in 50.3 ms with 1.3 MiB retained. Audit reports zero vulnerabilities with certificate verification enabled. These are local increment measurements, not a new protected-room scale or long-soak claim.\n- Disabled-feature benchmark against the site's installed Redweb 0.12.0 package passes five alternating 20,000-message trials at concurrency 128: throughput regression 0.0315% (3% limit), p99 improvement 3.51% (5% regression limit). This uses the installed published-package baseline, not the historical 0.8 baseline.\n- One concurrent browser-gate attempt failed during Windows temporary-directory cleanup with `EBUSY`; cleanup obscured any preceding failure. The subsequent standalone complete browser gate passed, and no remaining process referenced that temporary workspace. This remains a harness reliability/cleanup follow-up, not evidence that the failed run passed. Full generated-recipe/browser coverage, independent fresh-agent evaluations, broader runtime diagnostics, provenance/support guidance, final compatibility/soak and release agreement remain open.\n- The synchronized unreleased documentation builds 88 HTML pages/123 assets, including 11 examples and 29 API articles, and passes real HTTP/internal-link/Markdown/source-download checks plus 100% scoped site documentation coverage. Existing site architecture, published renderer dependency, branding and manual deployment workflow are preserved; no npm publication or hosting deployment occurred.\n\n- Action-reference increment: doctor now composes a bounded action inspector with the existing source reader. Literal TSX/HTML actions are checked against decorator-exposed methods on concrete page/component owners, including inheritance, overrides, callable/aliased render fields, returned markup constants, conditional returns and HTML-compatible attribute casing. Dynamic output, JSX spreads, custom scopes/decorators, unavailable inheritance and possible instance replacement are explicit warnings; this is not a full JavaScript evaluator. HTML scanning reuses the runtime traversal rather than adding a second parser. The real CLI repair test locates a typo, verifies no application execution/output, repairs and compiles the source, then invokes the fixed action over real HTTP/WebSockets.\n- Action-reference verification (Windows, Node 22.21.0): final stable run passes 515 tests/42 suites, all pretest/type gates, and 100% instrumented-library statements/branches/functions/lines. The senior critic approved after independently reproducing inheritance, instance-shadowing, dynamic returns, custom-wrapper ownership, returned constants, callable fields, destructuring, attribute-casing and aliased-renderer findings; regression tests cover every correction. The extracted npm package/source-free starters and real browser counter/chat/dashboard/feedback gates pass. Load passes 200 expired renders/110 clients with 7,683,304 bytes post-cleanup heap growth; JSX passes 10,000 rows in 61.9 ms/1.3 MiB retained. Audit reports zero vulnerabilities with certificate verification enabled. These are increment-specific measurements, not new large-scale production capacity claims.\n- Dashboard coverage follow-up supersedes the earlier branch-gap note: ten real tests now include the actual one-minute login-window expiration and default configuration paths. Its independent c8/source-map report is 100% statements/branches/lines and 87.93% functions (51/58). The seven remaining function counters map to TypeScript-generated decorator accessors, not untested authored methods; no exclusions or lowered thresholds conceal them. Full all-code/browser coverage remains an open release requirement.\n- Synchronized documentation builds 86 HTML pages/121 assets, passes real HTTP/internal-link/Markdown/source-download checks, and retains 100% coverage for the five scoped site documentation modules. Existing site architecture, rendering dependency, branding and manual deployment are preserved. No npm publication or hosting deployment occurred. Room authorization/shared socket identity, independent agent evaluations, remaining coverage/provenance/runtime-diagnostic work and the final requirement audit remain open.\n\n- Persistent-dashboard increment: the fifth initializer template combines reusable live cards, protected pages, explicit local account provisioning, salted asynchronous scrypt, hashed expiring sessions, strict origins/HttpOnly cookies, and account-wide sign-out. SQLite ownership checks and writes share synchronous transactions; a credential epoch fences password checks that overlap sign-out. Cards survive process restart and abrupt termination. Login admission, card/session counts, notifications, expiry, shutdown and failed-construction cleanup are bounded. Native SQLite and its Node 22.13+ requirement are recipe-local; core keeps its existing Node support. Doctor checks declared minimum engine requirements; older CI runtimes explicitly skip this recipe's execution while Node 22 executes it. Source-mapped recipe coverage is exposed separately through `npm run test:coverage`.\n- The real SQLite recipe uncovered an existing reactive-state defect: native row objects have no inherited text conversion, but Redweb eagerly serialized them even without an explicit binding. Reactive state/snapshots now defer legacy serialization and cache one payload per update across recipients. Real HTTP/WebSocket regressions cover null-prototype rows, unused non-stringifiable data, reconnect, and one collection-view call across three legacy recipients. The senior critic independently verified the fanout and bounded incomplete-upload shutdown, after finding stale subscription cleanup, failed-startup cleanup and shutdown issues that were corrected.\n- Dashboard verification (Windows, Node 22.21.0): 499 tests/41 suites and all pretest/type gates pass with 100% instrumented-library statements/branches/functions/lines. Each generated dashboard runs nine shipped real database/network/process tests, including source-free production execution and the extracted npm package. Real browser sign-in/forms, private live updates, draft preservation, HttpOnly cookies, sign-out/re-login and deletion pass alongside counter/chat/feedback regressions. The load gate passes 200 expired renders/110 clients with 8,169,144 bytes post-cleanup heap growth; JSX passes 10,000 rows in 52.2 ms/1.3 MiB retained. Audit reports zero vulnerabilities. One failed intermediate gate exposed FIFO test-cleanup ordering; cleanup was fixed, the orphaned test processes stopped, and the gates rerun successfully.\n- Coverage boundary remains explicit: the new dashboard's independent c8/source-map report is 100% statements/lines, 98.42% branches and 87.93% functions. Its missing function counts map to TypeScript-generated decorator accessors; the remaining branch gaps include application defaults and login-window expiration. No coverage exclusions or reduced thresholds were used to claim 100%. This is not completion of the all-code coverage/release gate. Broader browser instrumentation, room policies, static action-reference diagnostics and independent agent evaluations remain open.\n- Synchronized unreleased website documentation builds 86 HTML pages/120 assets, including 54 Markdown pages, 10 examples and 29 API articles; real HTTP/link/source-download checks and five scoped documentation tests at 100% pass. Generalized dotfile recipe-download names so the new `.npmrc` is actually retrievable without changing hosting architecture. The site remains on its published rendering dependency and manual deployment workflow; no npm publication or hosting deployment occurred.\n\n- Protected-page increment: page policies now run before connection-scoped construction/loading and before upgrade/reconnect/actions/writable state, sharing bounded authorization with action policies. Authentication keeps the existing application hook, with a bounded lookup and primitive-identity validation. Loading/rendering/connection/action contexts share one bounded deep-frozen original HTTP request snapshot; no Express response/socket graph is retained. Protected pages reject shared scope/static export and use private/no-store HTTP responses without automatic 304s. `LiveHtmlStartOptions` replaces repeated `Omit` types in the canonical starters. `server.revoke(principal)` synchronously invalidates matching in-process sessions/renders and unknown in-flight identity lookups, then performs bounded cleanup; it is not a credential store or distributed denylist.\n- Protected-page verification (Windows, Node 22.21.0): 494 tests/41 suites and all type/pretest gates pass with 100% instrumented-library statements/branches/functions/lines. Real HTTP/WebSocket regressions cover denial before construction, static-cache/export safety, original request identity across reconnects, current page policy on actions/state, multiple sessions/principals, late identity/loading/upgrade/connection-hook/validation/reactive-render completion, abandoned HTTP, and rejected/hung cleanup. The senior critic independently reproduced two P1 races (abort callbacks publishing and a post-authorization writable-state microtask gap); both were corrected and independently retested with real transports, including 20 timing positions. A final P2 finding added existing disconnect-hook work to revocation's bounded cleanup, with rejection/timeout regressions. All affected lifetimes are invalidated before abort notifications, and publication/state writes recheck validity. Packed standard/legacy consumers validate protected pages/request types/revocation with source removed. Browser counter/chat/feedback regressions pass; the generated DOM glue still lacks independent full browser branch instrumentation. The load gate passes 200 expired renders/110 clients with 8,194,616 bytes post-cleanup heap growth; JSX serialization passes 10,000 rows in 50.2 ms/1.3 MiB retained. Audit reports zero vulnerabilities. Synchronized documentation builds 85 HTML pages/105 assets and passes real HTTP/link/download checks plus five scoped documentation tests at 100% coverage. No npm or hosting publication occurred. Room policy ergonomics, durable authenticated recipes, action-reference diagnostics, independent agent evaluations, and the final release audit remain open.\n\n- Action-authorization increment: `@action({ input, authorize })` checks the server-established identity and transformed input after validation. Only literal `true` permits invocation. Authorization-only buttons keep untrusted input and trusted context in fixed positions. A shared bounded-operation primitive now serves schema validation and authorization without adding a runtime dependency; policies receive a signal that aborts on disconnect or deadline, including synchronous work found overdue on return. Recoverable permission errors feed the existing safe browser status UI. This guards action invocation only, not page rendering, passive subscriptions, writable state, room publication, or session revocation; the broader identity checklist remains open.\n- Action-authorization verification (Windows, Node 22.21.0): 465 tests/39 suites and all type/pretest gates pass with 100% instrumented-library statements/branches/functions/lines. Unit and real HTTP/WebSocket tests cover transformed input, permissions changing during validation, recoverable denial, context forgery, policy deadlines/cancellation/late approval, disposed/replaced actions, and sanitized policy bugs. Standard and legacy compiled consumers pass with source removed, including against the extracted tarball. Real browser tests pass permission denial/timeout messages, draft retention and correction, plus existing counter/chat/feedback regressions; generated DOM glue remains behaviorally tested rather than independently branch-instrumented. The load gate passes 200 expired renders/110 clients with 8,727,264 bytes post-cleanup heap growth; JSX serialization passes 10,000 rows in 50.3 ms/1.3 MiB retained. Audit reports zero vulnerabilities with certificate verification retained. The senior critic approved after overdue-signal and type/runtime-option mismatch findings were fixed. Synchronized documentation builds 85 HTML pages/105 assets and passes real HTTP/link/download checks plus five scoped documentation tests at 100% coverage. No npm or hosting publication is implied. Page identity/revocation, persistent recipes, action-reference diagnostics, agent evaluations, and final release audit remain pending.\n\n- Action-feedback increment: one browser-safe request-state class supplies built-in pending/success/safe-error messages for existing buttons and forms, with optional component-scoped `rw-status` slots. Per-control duplicate suppression and a fixed 32-request cap are independent of the socket offline queue. Disconnected browser actions/state writes are no longer queued; ambiguous failures do not promise absence of side effects. A successful form resets only when its original node, binding, values, and input/change revision survive. Server patches reconcile slot replacement, remove orphaned generated messages, and preserve newer slot ownership against old completions. Slot indexing is shared per patch batch; completed records use weak ownership rather than an accumulating completed-request collection.\n- Action-feedback verification (Windows, Node 22.21.0): 453 tests/38 suites and all type/pretest gates pass at 100% instrumented-library statements/branches/functions/lines. The state class is tested directly and the identical browser-safe source is embedded in the served runtime. Real Chromium + HTTP/WebSocket tests cover pending/success/input failure, duplicate submission, concurrent component scopes, nested/wrapper-free slots, server patches while pending, source/slot replacement, newer ownership, authored accessibility attributes, draft/focus/selection preservation, named reset controls, programmatic edits, 33-control capacity exhaustion/drain, orphan cleanup, sanitized application failure, real disconnect/reconnect, and no offline replay. Generated DOM glue is behaviorally browser-tested, not yet fully branch-instrumented; broader browser coverage remains a release gate. Packed-package checks and the dependency audit pass (zero reported vulnerabilities). The senior critic approved after slot-lifecycle findings were fixed. No npm publication or hosting deployment is implied; identity/persistence recipes, action-reference diagnostics, independent agent evaluations, and the broader release audit remain unfinished.\n\n- Validated-action increment: `@action({ input: schema })` and `ActionInput<typeof schema>` reuse the socket contract's bounded Standard Schema v1 validation through a shared module. Input rejections remain recoverable; exceptions and malformed validator results remain sanitized server failures. The method receives transformed input plus trusted context, rejects extra submitted arguments, and is not invoked after validation loses its connection, page lifetime, or original implementation identity. Browser form serialization preserves prototype-named fields as data. The teaching example bounds the transformed number to reject overflow rather than trusting a digit regex alone.\n- Validated-action verification (Windows, Node 22.21.0): 444 tests/37 suites and all type/pretest gates pass with 100% instrumented-library statements/branches/functions/lines. New unit and real HTTP/WebSocket checks cover schema transformations, extra-argument/context forgery, recoverable errors, deadlines, disconnect cancellation, component isolation, validator bugs, and overflow. Standard and legacy TypeScript consumers compile and pass real-network checks after their source is removed, both from the checkout and the extracted npm tarball. The browser gate passes invalid-input draft preservation, correction, component scoping, prototype-named form fields, and existing counter/chat/JSX regressions; it is behavioral browser evidence, not full instrumented browser-branch coverage. Audit reports zero vulnerabilities with certificate verification retained. The senior critic approved after malformed-result and example-overflow findings were fixed. The synchronized website builds 85 HTML pages/105 assets, verifies all routes/downloads/links over HTTP, and passes five scoped documentation tests at 100% coverage. Nothing was published or deployed; automatic status UI, identity/authorization ergonomics, persistent recipes, agent evaluations, and final release gates remain open.\n\n- Optional agent-access increment: `integrations/docs-mcp` exposes three read-only tools for bounded lexical search, paged Markdown, and exact embedded recipe files. A nominated catalogue is loaded once; tool requests never select disk paths, run application code, perform network access, or write files. The SDK remains outside normal Redweb dependencies and tarballs. Setup is documented as a private/unpublished checkout integration, not an available npm service or automatic editor installation.\n- Agent-access verification (Windows, Node 22.21.0): seven tests pass with 100% adapter-source line/branch/function coverage, including real MCP subprocesses, legacy and pinned 2026-07-28 protocol connections, validation failures, oversized input shutdown, byte-for-byte recipe text, and packed production-only installation against the separately packed Redweb catalogue. The adapter audit reports zero vulnerabilities; the senior critic approved the read-only/modularity boundary by source review. The full core suite still passes 429 tests/35 suites with 100% instrumented-library coverage; the synchronized site now serves 85 HTML pages/105 assets and 53 Markdown pages, with all five site documentation tests passing at their scoped 100% coverage gate. Fresh-agent task/selection benchmarks and broader release gates remain pending.\n\n- Website documentation increment: the core catalogue now includes 52 Markdown pages, 29 API sections with full ELI5 articles, and 10 capability examples. The separate site derives its existing articles, examples, homepage code, versioned guides, downloads, and discovery links from that catalogue. Imports preserve historical snapshots; shared preflight validates field shapes, hashes, paths, release identities, and immutable-version agreement before writes. Historical pages point to their own agent index. Current content is labelled unreleased; nothing has been published or deployed by this increment.\n- Website documentation verification (Windows, Node 22.21.0): 429 core tests/35 suites passed with all type/pretest gates and 100% instrumented-library statements/branches/functions/lines. The standalone shared HTTP/WebSocket homepage program is now exercised over real listeners. The site builds 84 HTML pages and 104 assets; real HTTP checks cover all routes, Markdown/source downloads, internal links and anchors. Five unit/real-process integration tests pass with 100% line/branch/function coverage of the new Markdown renderer, exporter, catalogue helpers/validator, and import command, including multi-release builds and malformed-import preservation. The senior critic approved this increment after the archive-preflight and historical-index findings were fixed. These are scoped implementation results, not approval of the broader release, all browser branches, or public availability.\n- The same increment's packed-package gate passed, including extracted Markdown applications, every generated starter, source-free production execution, and the standalone shared listener. All generated guide titles/social descriptions and every page's referenced stylesheets are checked. Core and website dependency audits each reported zero vulnerabilities with certificate verification retained through the system trust store.\n\n- Executable-documentation increment: `docs/generated.json` now derives topic Markdown, all public declaration entrypoints, a compact agent index, and four complete applications from canonical content and the initializer's file plan. The README counter is generated from the same source. Development and exact-version release channels are explicit; release snapshots are immutable, tolerate equivalent Git line endings, and require a versioned changelog without pending entries. No public release or website deployment is implied by a local catalogue.\n- Documentation verification: 428 tests/35 suites and all type/pretest gates passed with 100% instrumented-library statement/branch/function/line coverage, including the new documentation module. Tests extract the programs printed in Markdown, compile them, run actual HTTP/WebSockets, and rerun with source unavailable. The tarball gate repeats the initializer and Markdown consumers against extracted package code. Real generator subprocess tests cover stale content, README drift, release immutability, CRLF checkouts, channel preservation, and invalid commands. The critic approved after three findings were fixed. Package testing also caught and fixed prepack progress text contaminating `npm pack --json`; progress now goes to stderr. These results do not prove all illustrative guide snippets, browser branches, website integration, MCP, or fresh-agent benchmarks.\n\n- Source-diagnostic increment (Windows/Node 22.21.0): bounded, read-only TypeScript source inspection now reports declared asset errors, duplicate route/handler registrations, and source locations. Dynamic, mutated, escaped, or unsupported expressions produce explicit unresolved warnings rather than guessed success. The reader does not execute application modules. A real nested TypeScript 4.9 installation verifies the unsupported-compiler diagnostic without replacing the project's TypeScript 5.9 compiler.\n- Source-diagnostic verification: 421 tests/33 suites, all type/pretest gates, and 100% instrumented-library statements/branches/functions/lines passed; all packed starters and production-source-free checks passed. The senior critic approved after alias/mutation, bounded expansion, stylesheet provenance, and superclass-registration findings were fixed with regressions. The preceding audit reported zero vulnerabilities with certificate verification retained. One earlier full run hit an intermittent existing duplicate-client-identity WebSocket timeout; the isolated 30-test socket suite, five repetitions of that case, and subsequent full runs passed without socket runtime changes. Its cause remains unconfirmed and repeat reliability testing remains part of the final release gate.\n\n- Baseline: `6c95093` (0.12.0 initializer), 336 tests and 100% instrumented-source coverage from the preceding release. These are historical results, not evidence for this release.\n- Current implementation branch: `codex/agent-ready`.\n- First increment: discovery metadata; existing-project/dry-run/JSON initialization; preflight filesystem safety; read-only doctor checks for installed dependencies, effective JSX configuration, Node/CLI versions, and optional TCP ports. Source-level doctor checks and all remaining release items are still pending. `tests/unit/cli-tools.unit.test.js` and `tests/integration/init-cli.integration.test.js` exercise real files, compiler configuration, CLI subprocesses, and sockets.\n- First-increment verification: 361 tests across 24 suites; 100% statements, branches, functions, and lines; full type/pretest gates. Packed CLI initialization, consumer compilation, doctor, and rendering are checked by `npm run verify:live-html:package`. This is increment evidence only: it does not satisfy the remaining reactive-rendering, recipe, contract, browser, or release-performance requirements.\n- Starter increment (2026-08-29, Windows, Node 22.21.0): four selectable recipes, default shared counter, canonical chat component reuse, common scaffold/config/test helpers, development-only Nodemon watcher, and production CSS/HTML copying. The watcher integration test runs the actual generated `npm run dev`, edits TSX and CSS, observes rebuilt HTTP responses, introduces a type error, and verifies recovery after repair. No additional watcher implementation was added to the library runtime.\n- Starter verification: `npm test -- --runInBand --silent` passed 372 tests in 26 suites with 100% instrumented-library statements/branches/functions/lines and all existing type/pretest gates. `npm run verify:live-html:package` extracted the tarball and ran every generated `npm test` (real HTTP/WebSockets), then reran the network tests with `src/` unavailable. These cover two-client counter updates, chat messages/escaping/disconnect presence, static pages/CSS/404s, and socket dispatch/invalid payloads. They are not a claim of 100% generated-example coverage or a substitute for the pending full browser/release gates.\n- Audit: zero reported vulnerabilities with Node's `--use-system-ca` option. The initial audit failed certificate validation against this machine's trust setup; verification was retained using the Windows trust store, not disabled.\n- Reactive increment: added per-session state-read capture and transactional owner snapshot commits, batched updates, reconnect snapshots, cancelled-result suppression, a five-second asynchronous render deadline, and an aggregate 1 MiB/1,024-owner snapshot bound. JSX keys are forwarded by both compiler runtimes; browser reconciliation preserves keyed elements/fragments, identified siblings, focus/selection, and unchanged local form values. Explicit bindings travel in the same frame. The plain-expression TSX counter and realtime starter use this path; the canonical chatroom's migration to the simpler TSX model is still pending.\n- Reactive verification (Windows/Node 22.21.0): 385 tests/28 suites and all type/pretest gates passed with 100% instrumented-library statement/branch/function/line coverage. New pure capture/lifecycle units use real timers; new integration tests use real HTTP/WebSockets for owner scoping, shared request isolation, connection isolation, hidden/reintroduced components, reconnect during unfinished async rendering, error containment (including a failing logger), and unused/unchanged output suppression. The real-browser gate additionally covers derived expressions, keyed fragment reordering, node identity/drafts/focus/selection, server-controlled text/textarea/checkbox/select updates, and reactive table/select boundaries under CSP. Generated browser source is exercised by that gate, not included as independently instrumented browser branch coverage; broader browser coverage/performance auditing remains a release item.\n- Packed-package verification passed with all four generated starters. The existing Live HTML load gate passed (200 expired renders, 110 live clients; about 12.4 MB heap delta); this is legacy chat-path evidence, not yet the new reactive fan-out load profile. The JSX serialization gate passed 10,000 component rows in about 49 ms and 1.3 MiB retained; it does not measure live DOM patch performance.\n- Follow-up bug found by repeat testing: Nodemon treats compiler exit code 2 as fatal. Normalized failed development commands to exit 1 and made the real-process test wait for the watcher to remain active before repairing the source. Repeated full-suite runs then passed.\n- Chat simplification increment: replaced cached HTML screen/message/presence state with ordinary data and keyed TSX in the canonical `chatroom.tsx`; retained independent room instances, reserved reconnect identities, disconnect presence, validation, and the 100-message/100-visible-member bounds. Added owned synchronous class-component child typings and negative async-component type coverage. The initializer still copies the single canonical implementation. Verification: 387 tests/29 suites with 100% instrumented-library coverage and type gates; real-browser counter/chat/draft-preservation checks; all packed starters; reactive chat load with 200 expired renders and 110 live clients (about 8.8 MB post-cleanup heap growth). This supersedes the earlier note that chat migration was pending, but does not close the remaining broader release/audit requirements.\n- Socket contract increment: added schema-inferred client/server contracts using Standard Schema v1, without a runtime validator dependency in Redweb. The socket starter now supplies a shared Zod schema and independent join/move/resume handlers on `/match`, with ordered messages and bounded in-memory bearer sessions. Client and server sends share immutable JSON snapshot validation; inbound failures are distinguished from application/output bugs. Promise-like outputs stay within the validation deadline and sanitizing error boundary. Completed overdue work is rejected, but synchronous JavaScript cannot be preempted and external validator work is not cancelled; these limits are documented.\n- Contract verification (Windows/Node 22.21.0): 400 tests/31 suites and all type gates passed with 100% instrumented-library statements/branches/functions/lines. Real-socket tests cover schema transformations, mutating validators, malformed inputs and server outputs, negotiated versions, metadata, deadline/error containment, and unchanged uncontracted behavior. All four packed starters passed their shipped real-network tests and source-free production checks; the browser regression gate passed; dependency audit reported zero vulnerabilities with certificate verification retained. The senior critic independently rechecked contract tests, generated starters, and types and approved this increment after four findings were fixed with regressions. These results do not close the pending diagnostics, unified documentation, fresh-agent benchmarks, or broader release-performance audit.\n",
|
|
187
|
+
"url": "/docs/reference/0.13.2/release-status.md",
|
|
188
|
+
"sha256": "6bd9c34471d84a244140450c2e1f4ee1f48cf6b74cb2d19cb62209fbc4f6af26"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"id": "release-trust",
|
|
192
|
+
"title": "Choose and verify a release",
|
|
193
|
+
"summary": "Maintained runtimes, compiler/browser verification boundaries, pinned packages, registry signatures, provenance and support limits.",
|
|
194
|
+
"source": "docs/RELEASE_TRUST.md",
|
|
195
|
+
"markdown": "> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Choose and verify a Redweb release\n\nRedweb is a Node.js HTTP/WebSocket library with server-rendered TSX, not a hosted service. Evaluate the exact package, runtime, application and deployment you intend to use. A passing test suite, a registry signature and a provenance statement answer different questions; none certifies an application as secure.\n\n## Runtime and compiler compatibility\n\n| Area | Contract and verification boundary |\n| --- | --- |\n| Core runtime | The package declares Node `>=18`. This is an installation/compatibility floor, not a recommendation to deploy an end-of-life runtime. |\n| Production Node | Use a currently maintained LTS release with current security patches. As checked on 2026-08-30, Node 22 and 24 are LTS; Node 18 and 20 are end-of-life. Recheck the official schedule when deploying. |\n| CI coverage | The repository matrix targets Node 18, 20, 22 and 24 on Linux. The 18/20 jobs are legacy-compatibility checks, not security-support claims. A configured job is not a passing result; inspect checks for your exact commit. |\n| TypeScript/TSX | The starter uses the package's tested TypeScript dependency and `redweb/tsconfig.json`. Standard decorators and legacy `experimentalDecorators` consumers have separate compile tests. Node's native TypeScript execution is not a replacement for compiling TSX/decorators with this configuration. |\n| Persistent dashboard | This application recipe requires Node 22.13+ and native `node:sqlite`; it is not part of the core runtime requirement. Its database and account/session design are single-process. |\n| Browser | The real-browser gates exercise Chromium. They are not a Firefox/Safari compatibility certification. Test the browsers you support, including reconnect and forms, before release. |\n| Runtime platforms | This branch has local Windows evidence and Linux CI configuration. Neither proves every OS, architecture, proxy, container platform or serverless host works. Live pages require a long-lived Node listener with WebSocket upgrades. Static export is a separate deployment mode. |\n\nUse the [official Node release schedule](https://nodejs.org/en/about/previous-releases) for maintained releases, the [TypeScript decorator documentation](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators) for the distinction between decorator modes, and [Node's TypeScript limitations](https://nodejs.org/api/typescript.html) for native execution constraints. Current per-commit test evidence and uncompleted checks are recorded in the [release checklist](/docs/reference/0.13.2/release-status.md), not inferred from this table.\n\n## Pin the package and the documentation together\n\nFor a published application, select an exact release, commit its lockfile, and use `npm ci` in CI/deployment. This guide is versioned for 0.13.2. Before registry publication, verify the packed candidate; after publication, repeat these registry checks from a clean application:\n\n```sh\nnpm view redweb@0.13.2 version engines dist.integrity dist.signatures dist.attestations gitHead --json\nnpm install --save-exact redweb@0.13.2\nnpm audit signatures\nnpm audit --omit=dev\n```\n\nThe signature command must run in the installed application directory. Keep TLS verification enabled and use a current npm CLI; a certificate/trust-store failure is not a reason to disable verification. A lockfile's integrity value detects changed package bytes; registry signatures authenticate registry metadata; provenance, when present and verified, links an artifact to a build/source identity. Vulnerability audit is a separate check against known advisories, not an application penetration test.\n\nRedweb 0.13.2 contains the server-rendered TSX, reactive state/actions, complete starters, shared socket contracts, authorization, diagnostics, lifecycle work, and bounded heartbeat grace described by these versioned guides. Keep the package and documentation version aligned; do not mix a development guide or a future checkout with 0.13.2 and assume newer APIs exist.\n\nRedweb is pre-1.0. Consult the changelog and versioned guide before upgrading, run your own real HTTP/WebSocket/browser tests, and keep a rollback artifact. Patch/minor numbers and a compatible TypeScript build alone do not prove wire compatibility, preserved sessions, database compatibility or application authorization. HTTP-created live-page sessions are process-owned; a restart or rolling replacement does not migrate them automatically. Raw socket protocol versions are negotiated only when the route opts in, and application payload compatibility remains your contract.\n\n## Historical registry verification\n\nRead-only registry inspection after publication on **2026-09-01 UTC** reported `latest: 0.13.0`, with:\n\n- `gitHead`: `7196d504ee65dfaf5ac869ea4bda66d7cf86d015`, the verified merge commit on `main`.\n- SHA-512 integrity: `sha512-n5OQl214vC6ithpfg6QyhAmaOtjY8AYEGrWZGl8LxdSCyagD1K2bvplcxhQOruZP3exwkIyRPnhIuFe9rIcQFQ==`.\n- SHA-1 registry checksum: `1ff22dbd2a5c3d8eafad219055f01fe5b85b9d10`.\n- Registry signature present; **no `dist.attestations` field was returned**. Build provenance is therefore not claimed for this release.\n\nAn independently created temporary application installed that exact package with lifecycle scripts disabled and ran `npm audit signatures --json` using Node 22.21.0/npm 11.6.2 and the Windows system trust store. It exited successfully with `invalid: []` and `missing: []`; `npm audit --omit=dev --json` reported zero vulnerabilities. All 215 published files matched content at the recorded `gitHead`: two byte-for-byte and 213 after normalizing Windows CRLF materialization to Git's LF blobs. The retained release audit receipt at `docs/releases/audit-0.13.0.json` records the archive hashes and every published/Git file hash. These checks authenticate and compare the observed artifact; they do not certify the application, prove every dependency has build provenance, or verify a future release.\n\nThe immutable 0.13.0 tarball has a known documentation-only release-process defect. Its bundled `docs/generated.json` says `channel: \"unreleased\"`; its changelog says 0.13.0 was not yet published; and its README uses development-tarball setup, calls that setup prerelease/development-only, and later says Redweb remains unreleased. Runtime files and public declarations match the verified merge commit. The repository and website sources correct those labels. The repository's `docs/releases/0.13.0.json` is therefore a corrected **post-publication** documentation snapshot for 0.13.0, not the catalogue that shipped inside the immutable 0.13.0 tarball. A future patch release is required to deliver the corrected bundled documentation to npm consumers.\n\nRegistry inspection after the later 0.13.1 publication reported `gitHead` `db64d5b655d5668a75b587122c9e1e9ef4c9bca1`, SHA-512 integrity `sha512-DCLcmiDj89kXf3+80H5/1W+nQEXIhF8RfiBlU6b/LA7418/ChkzhZ8oV4JmVLDL/nt7zFNcytyjavzrDT/XfoA==`, SHA-1 checksum `c05f3e0dc560d4bb379b8fbd6747c3470fcc31f0`, a registry signature, and no provenance attestation. Its runtime contains the heartbeat correction, but its 217-file immutable archive again labels `docs/generated.json` and the README as unreleased and contains no `docs/releases/0.13.1.json`. Its package version was materialized in the publication workspace rather than committed at the recorded `gitHead`, so 0.13.1 is not presented as a clean source-identical release.\n\nThe 0.13.2 release candidate corrects that process: version metadata and lockfile, an empty Unreleased section, registry-pinned README setup, and `docs/releases/0.13.2.json` must exist in the reviewed commit before manual publication. Publish only that clean merged commit, then verify the registry metadata and installed archive before synchronizing the website.\n\nThe commands above let you repeat the check. See npm's [signature and attestation verification](https://docs.npmjs.com/cli/v11/commands/npm-audit/) and [viewing provenance](https://docs.npmjs.com/viewing-package-provenance/) documentation for current verification behavior.\n\n## Publishing provenance is a maintainer action\n\nThe 0.13.0 and 0.13.1 registry metadata contain signatures but no provenance attestations. To add provenance to a future release, the maintainer must choose an authorized supported build/publish workflow, configure the correct repository identity and npm permissions, publish the exact tested artifact, and verify the resulting registry attestation afterward. A local `npm pack`, `gitHead`, checksum, badge or successful CI run is not a substitute for a verified attestation. Do not label older releases retroactively as provenance-verified.\n\nnpm describes the supported providers and identity requirements in [generating provenance](https://docs.npmjs.com/generating-provenance-statements/) and [trusted publishing](https://docs.npmjs.com/trusted-publishers/). Provenance provides origin/build evidence, not proof that source code is safe.\n\n## Support and reporting boundaries\n\nFor ordinary bugs, provide a minimal reproducible project, exact Redweb/Node/npm/TypeScript versions, operating system, decorator mode, sanitized logs, and the failing HTTP/WebSocket sequence in the [issue tracker](https://github.com/lakam99/redweb/issues). Never include tokens, cookies, passwords, private database contents or customer traffic.\n\nThere is no paid support contract, response-time SLA or long-term backport policy established by these files. No private vulnerability contact is invented here. The repository inspection on 2026-08-30 reported no published security policy; the maintainer still needs to establish a private reporting channel and its handling policy before the project claims one. Do not disclose a suspected vulnerability or working exploit in a public bug report merely because that is the only linked tracker.\n",
|
|
196
|
+
"url": "/docs/reference/0.13.2/release-trust.md",
|
|
197
|
+
"sha256": "18b2afcd4cb826857b6a626590d6fc20662ba1ad3338a67afe42ef2d495b888f"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"id": "recipes/realtime",
|
|
201
|
+
"title": "Realtime starter",
|
|
202
|
+
"summary": "`CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.",
|
|
203
|
+
"source": "recipes/realtime/README.md",
|
|
204
|
+
"markdown": "# Realtime: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## Realtime starter\n\n`CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.\nThe browser button invokes only the decorated `increment` action; it does not supply the new count.\nOpen two tabs to check the broadcast. State is in memory and resets when the server restarts.\n\n`<output>{this.count}</output>` updates automatically because the page reads decorated state during rendering.\nNo repeated binding name or browser-side state is needed. State changes are assignment-driven; render methods must not modify state.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-realtime --template realtime\ncd my-realtime\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { listen, live } = require('./network.cjs');\n\ntest('one server action updates both visitors', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const first = await live(t, origin);\n const second = await live(t, origin);\n await first.patch(patch => patch.html.includes('<output>0</output>'));\n await second.patch(patch => patch.html.includes('<output>0</output>'));\n first.action('increment');\n await first.patch(patch => patch.html.includes('<output>1</output>'));\n await second.patch(patch => patch.html.includes('<output>1</output>'));\n assert.match(await (await fetch(origin)).text(), /<output>1<\\/output>/);\n});\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Realtime starter\n\n`CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.\nThe browser button invokes only the decorated `increment` action; it does not supply the new count.\nOpen two tabs to check the broadcast. State is in memory and resets when the server restarts.\n\n`<output>{this.count}</output>` updates automatically because the page reads decorated state during rendering.\nNo repeated binding name or browser-side state is needed. State changes are assignment-driven; render methods must not modify state.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n",
|
|
205
|
+
"files": [
|
|
206
|
+
{
|
|
207
|
+
"path": "package.json",
|
|
208
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"path": "tsconfig.json",
|
|
212
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"path": "src/app.tsx",
|
|
216
|
+
"content": "import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"path": "src/run-app.ts",
|
|
220
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"path": "src/app.css",
|
|
224
|
+
"content": ":root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"path": "scripts/copy-assets.cjs",
|
|
228
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"path": "test/network.cjs",
|
|
232
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"path": "test/app.test.cjs",
|
|
236
|
+
"content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { listen, live } = require('./network.cjs');\n\ntest('one server action updates both visitors', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const first = await live(t, origin);\n const second = await live(t, origin);\n await first.patch(patch => patch.html.includes('<output>0</output>'));\n await second.patch(patch => patch.html.includes('<output>0</output>'));\n first.action('increment');\n await first.patch(patch => patch.html.includes('<output>1</output>'));\n await second.patch(patch => patch.html.includes('<output>1</output>'));\n assert.match(await (await fetch(origin)).text(), /<output>1<\\/output>/);\n});\n"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"path": "test/run-app.test.cjs",
|
|
240
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"path": "README.md",
|
|
244
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Realtime starter\n\n`CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.\nThe browser button invokes only the decorated `increment` action; it does not supply the new count.\nOpen two tabs to check the broadcast. State is in memory and resets when the server restarts.\n\n`<output>{this.count}</output>` updates automatically because the page reads decorated state during rendering.\nNo repeated binding name or browser-side state is needed. State changes are assignment-driven; render methods must not modify state.\n"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"path": ".gitignore",
|
|
248
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
249
|
+
}
|
|
250
|
+
],
|
|
251
|
+
"url": "/docs/reference/0.13.2/recipes/realtime.md",
|
|
252
|
+
"sha256": "b122cfa1327084d57892d499bc76546c51a3f5b6f1acbb77af5f993304451672"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"id": "recipes/chat",
|
|
256
|
+
"title": "Chat starter",
|
|
257
|
+
"summary": "`src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.",
|
|
258
|
+
"source": "recipes/chat/README.md",
|
|
259
|
+
"markdown": "# Chat: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## Chat starter\n\n`src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.\nThe 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.\nVisitors choose a name once, chat in a shared room, and see live presence. Disconnect removes online presence;\nthe page session retains its identity briefly for reconnect, then disposal releases it.\n\nDisplay names are not authenticated identities. History is bounded to 100 messages in memory, not a persistent database.\nUse an application-owned persistence service before promising history across restarts or multiple server processes.\n\n`@action({ input: chatInputs.join })` validates and normalizes the form before `join` runs;\n`ActionInput<typeof chatInputs.join>` supplies its TypeScript input type. The same pattern handles messages.\nThe starter installs Zod as an application dependency; Redweb itself remains validator-independent.\nInvalid field values (including repeated names represented as arrays) receive `ACTION_INVALID_INPUT`, keep the draft,\nand show Redweb's built-in form feedback. Name collisions remain a room rule with their own friendly message.\nCalling a component method directly from server code bypasses transport validation: pass schema-parsed input.\nThe schemas reject ordinary unexpected fields; Zod may discard reserved object keys such as `__proto__`.\nOnly the parsed `name` or `message` reaches the corresponding action.\n\nWhen using the packed `examples/live-html/chatroom.js` directly instead of the generated starter,\ninstall its application validator with `npm install zod`. A cloned-repository development install already\nincludes it. Redweb's core and the counter example do not require Zod.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-chat --template chat\ncd my-chat\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport { start, type LiveHtmlStartOptions } from 'redweb';\nimport { createChatroomPage } from './chatroom';\nimport { runApp } from './run-app';\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(createChatroomPage(), { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst test = require('node:test');\nconst { once } = require('node:events');\nconst assert = require('node:assert/strict');\nconst { listen, live, connect } = require('./network.cjs');\nconst { createChatroomPage, chatInputs } = require('../dist/chatroom.js');\n\ntest('the standalone canonical chat reports an occupied default port', { timeout: 10000 }, async t => {\n const net = require('node:net');\n const { spawnSync } = require('node:child_process');\n const occupied = net.createServer(socket => socket.destroy());\n t.after(() => new Promise(resolve => occupied.close(resolve)));\n occupied.listen(8080, '0.0.0.0');\n try { await once(occupied, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); } // An existing listener is left untouched.\n const result = spawnSync(process.execPath, ['dist/chatroom.js'], {\n encoding: 'utf8', timeout: 5000, windowsHide: true,\n });\n assert.equal(result.error, undefined);\n assert.equal(result.status, 1);\n assert.match(result.stderr, /EADDRINUSE/);\n});\n\ntest('members join once, exchange messages, and leave presence on disconnect', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const alice = await live(t, origin);\n const bob = await live(t, origin);\n alice.action('join', [{ name: 'Alice' }], 'chat');\n bob.action('join', [{ name: 'Bob' }], 'chat');\n await alice.patch(patch => patch.html.includes('Online · 2'));\n await bob.patch(patch => patch.html.includes('Online · 2'));\n alice.action('send', [{ message: 'Hello <friends>' }], 'chat');\n await bob.patch(patch => patch.html.includes('Hello <friends>'));\n const closed = once(alice.socket, 'close');\n alice.socket.close();\n await closed;\n await bob.patch(patch => patch.html.includes('Online · 1'));\n});\n\ntest('identities stay reserved across reconnects and are released by leaving', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const alice = await live(t, origin);\n const visitor = await live(t, origin);\n alice.action('join', [{ name: ' Alice ' }], 'chat');\n await alice.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Alice'));\n visitor.action('join', [{ name: 'ALICE' }], 'chat');\n await visitor.patch(patch => patch.html.includes('already in use'));\n const closed = once(alice.socket, 'close');\n alice.socket.close();\n await closed;\n visitor.send({ v: visitor.config.version, type: 'redweb:html', requestId: 'reserved-name', payload: { kind: 'action', name: 'join', args: [{ name: 'ALICE' }], component: 'chat' } });\n assert.equal((await visitor.receive(message => message.requestId === 'reserved-name')).payload, false);\n const { config } = alice;\n const resumed = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin);\n await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Online · 1')));\n resumed.send({ v: config.version, type: 'redweb:html', payload: { kind: 'action', name: 'leave', args: [], component: 'chat' } });\n await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Join the chatroom')));\n visitor.action('join', [{ name: 'Alice' }], 'chat');\n await visitor.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Online · 1'));\n});\n\ntest('room units bound history/presence, isolate rooms, and make repeated lifecycle calls harmless', () => {\n const Page = createChatroomPage();\n const alice = new Page().chat;\n const bob = new Page().chat;\n const isolated = new (createChatroomPage())().chat;\n assert.equal(alice.send({ message: 'not joined' }), false);\n alice.connected();\n assert.equal(alice.join(chatInputs.join.parse({ name: ' Alice ' })), true);\n assert.equal(alice.join({ name: 'Replacement' }), false);\n assert.equal(bob.join({ name: 'ALICE' }), false);\n assert.match(bob.render().toString(), /already in use/);\n assert.equal(bob.join({ name: 'Bob' }), true);\n assert.equal(isolated.join({ name: 'Alice' }), true);\n assert.match(alice.render().toString(), /No messages yet/);\n for (let index = 0; index < 101; index++) assert.equal(alice.send({ message: `message-${index}` }), true);\n assert.equal(bob.messages.length, 100);\n assert.deepEqual(bob.messages[0], { id: 2, sender: 'Alice', text: 'message-1' });\n assert.equal(bob.messages.at(-1).id, 101);\n assert.equal(isolated.messages.length, 0);\n assert.match(bob.render().toString(), /message-100/);\n alice.disconnected();\n alice.disconnected();\n assert.deepEqual(bob.members, ['Bob']);\n assert.equal(alice.send({ message: 'offline' }), false);\n alice.connected();\n assert.deepEqual(bob.members, ['Bob', 'Alice']);\n const visitors = Array.from({ length: 100 }, (_, index) => {\n const member = new Page().chat;\n assert.equal(member.join({ name: `visitor-${index}` }), true);\n return member;\n });\n const rendered = alice.render().toString();\n assert.match(rendered, /Online · 102/);\n assert.match(rendered, /\\+2 more/);\n assert.doesNotMatch(rendered, /<li[^>]*>visitor-99<\\/li>/);\n visitors.forEach(member => member.disposed());\n alice.leave();\n alice.disposed();\n alice.disposed();\n assert.deepEqual([alice.displayName, alice.feedback, alice.messages, alice.members], ['', '', [], []]);\n assert.deepEqual(bob.members, ['Bob']);\n assert.match(alice.render().toString(), /Join the chatroom/);\n bob.disposed();\n isolated.disposed();\n});\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Chat starter\n\n`src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.\nThe 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.\nVisitors choose a name once, chat in a shared room, and see live presence. Disconnect removes online presence;\nthe page session retains its identity briefly for reconnect, then disposal releases it.\n\nDisplay names are not authenticated identities. History is bounded to 100 messages in memory, not a persistent database.\nUse an application-owned persistence service before promising history across restarts or multiple server processes.\n\n`@action({ input: chatInputs.join })` validates and normalizes the form before `join` runs;\n`ActionInput<typeof chatInputs.join>` supplies its TypeScript input type. The same pattern handles messages.\nThe starter installs Zod as an application dependency; Redweb itself remains validator-independent.\nInvalid field values (including repeated names represented as arrays) receive `ACTION_INVALID_INPUT`, keep the draft,\nand show Redweb's built-in form feedback. Name collisions remain a room rule with their own friendly message.\nCalling a component method directly from server code bypasses transport validation: pass schema-parsed input.\nThe schemas reject ordinary unexpected fields; Zod may discard reserved object keys such as `__proto__`.\nOnly the parsed `name` or `message` reaches the corresponding action.\n\nWhen using the packed `examples/live-html/chatroom.js` directly instead of the generated starter,\ninstall its application validator with `npm install zod`. A cloned-repository development install already\nincludes it. Redweb's core and the counter example do not require Zod.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n\n### src/chatroom.tsx\n\n```tsx\nimport { action, component, page, start, state, type ActionInput } from 'redweb';\nimport { z } from 'zod';\n\nconst MAX_VISIBLE_MEMBERS = 100;\nconst visibleText = (maximum: number) => z.string()\n .transform(value => value.normalize('NFKC').trim())\n .pipe(z.string().min(1).max(maximum).regex(/^[^\\p{Cc}\\p{Cf}]+$/u));\nexport const chatInputs = {\n join: z.object({ name: visibleText(40) }).strict(),\n send: z.object({ message: visibleText(500) }).strict(),\n};\n\ninterface StoredMessage { id: number; sender: string; text: string; }\ninterface RoomParticipant {\n readonly displayName: string;\n updateMessages(messages: readonly StoredMessage[]): void;\n updatePresence(members: readonly string[]): void;\n}\n\nclass ChatRoom {\n private history: readonly StoredMessage[] = [];\n private nextMessageId = 0;\n private readonly participants = new Set<RoomParticipant>();\n private readonly online = new Set<RoomParticipant>();\n\n join(participant: RoomParticipant) {\n const name = participant.displayName.toLocaleLowerCase();\n if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) return false;\n this.participants.add(participant);\n this.online.add(participant);\n participant.updateMessages(this.history);\n this.publishPresence();\n return true;\n }\n\n disconnect(participant: RoomParticipant) {\n if (this.online.delete(participant)) this.publishPresence();\n }\n\n leave(participant: RoomParticipant) {\n this.online.delete(participant);\n if (this.participants.delete(participant)) this.publishPresence();\n }\n\n send(participant: RoomParticipant, text: string) {\n if (!this.online.has(participant)) return false;\n this.history = [...this.history, { id: ++this.nextMessageId, sender: participant.displayName, text }].slice(-100);\n for (const member of this.participants) member.updateMessages(this.history);\n return true;\n }\n\n private publishPresence() {\n const members = [...this.online].map(participant => participant.displayName);\n for (const participant of this.participants) participant.updatePresence(members);\n }\n}\n\n@component()\nexport class ChatroomComponent implements RoomParticipant {\n @state() displayName = '';\n @state() feedback = '';\n @state() messages: readonly StoredMessage[] = [];\n @state() members: readonly string[] = [];\n\n constructor(private readonly room: ChatRoom) {}\n\n connected() { if (this.displayName) this.room.join(this); }\n disconnected() { this.room.disconnect(this); }\n disposed() { this.room.leave(this); }\n\n @action({ input: chatInputs.join })\n join({ name }: ActionInput<typeof chatInputs.join>) {\n if (this.displayName) return false;\n this.displayName = name;\n if (!this.room.join(this)) {\n this.displayName = '';\n this.feedback = 'That display name is already in use.';\n return false;\n }\n this.feedback = '';\n return true;\n }\n\n @action({ input: chatInputs.send })\n send({ message }: ActionInput<typeof chatInputs.send>) {\n return this.room.send(this, message);\n }\n\n @action()\n leave() {\n this.room.leave(this);\n this.displayName = '';\n this.feedback = '';\n this.messages = [];\n this.members = [];\n }\n\n updateMessages(messages: readonly StoredMessage[]) { this.messages = messages; }\n updatePresence(members: readonly string[]) { this.members = members; }\n\n render() {\n return <section class=\"chatroom\">{this.displayName ? this.roomScreen() : this.joinScreen()}</section>;\n }\n\n private joinScreen() {\n return (\n <section class=\"join-panel\">\n <p class=\"eyebrow\">Live room</p>\n <h1>Join the chatroom</h1>\n <p>Choose a name once, then chat in realtime with everyone currently in the room.</p>\n {this.feedback && <p class=\"form-error\" role=\"alert\">{this.feedback}</p>}\n <form rw-submit=\"join\" class=\"join-form\">\n <label for=\"display-name\">Display name</label>\n <div class=\"input-row\">\n <input id=\"display-name\" name=\"name\" maxlength=\"40\" autocomplete=\"nickname\" required autofocus />\n <button type=\"submit\">Join room</button>\n </div>\n </form>\n </section>\n );\n }\n\n private roomScreen() {\n const remaining = this.members.length - MAX_VISIBLE_MEMBERS;\n return (\n <div class=\"room-layout\">\n <section class=\"conversation\">\n <header class=\"room-header\">\n <div><p class=\"eyebrow\">Connected as</p><h1>{this.displayName}</h1></div>\n <button type=\"button\" class=\"quiet-button\" rw-click=\"leave\">Leave</button>\n </header>\n <ol class=\"message-list\" aria-live=\"polite\">\n {this.messages.length ? this.messages.map(entry => (\n <li key={entry.id}><strong>{entry.sender}</strong><p>{entry.text}</p></li>\n )) : <li class=\"empty-message\">No messages yet. Say hello.</li>}\n </ol>\n <form rw-submit=\"send\" class=\"composer\">\n <label class=\"sr-only\" for=\"chat-message\">Message</label>\n <input id=\"chat-message\" name=\"message\" maxlength=\"500\" autocomplete=\"off\" placeholder=\"Message the room…\" required autofocus />\n <button type=\"submit\">Send</button>\n </form>\n </section>\n <aside class=\"presence\" aria-label=\"People in the room\">\n <p class=\"eyebrow\">Online · {this.members.length}</p>\n <ul>\n {this.members.slice(0, MAX_VISIBLE_MEMBERS).map(member => <li key={member}>{member}</li>)}\n {remaining > 0 && <li class=\"more-members\">+{remaining} more</li>}\n </ul>\n </aside>\n </div>\n );\n }\n}\n\nexport function createChatroomPage() {\n const room = new ChatRoom();\n\n @page('/', { css: 'chatroom.css' })\n class ChatroomPage {\n chat = new ChatroomComponent(room);\n render() { return <main>{this.chat}</main>; }\n }\n\n return ChatroomPage;\n}\n\nif (require.main === module) start(createChatroomPage(), { port: 8080 });\n```\n\n### src/chatroom.css\n\n```css\n:root {\n color-scheme: dark;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n background: #07111f;\n color: #e5eef9;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n min-height: 100vh;\n background: radial-gradient(circle at top, #15304b 0, #07111f 45rem);\n}\n\nmain {\n width: min(68rem, calc(100% - 2rem));\n margin: 0 auto;\n padding: 3rem 0;\n}\n\n.chatroom,\n.join-panel,\n.conversation,\n.presence {\n border: 1px solid #27435e;\n border-radius: 1rem;\n background: rgba(9, 24, 40, .92);\n box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .28);\n}\n\n.chatroom { overflow: hidden; }\n\n.join-panel {\n max-width: 38rem;\n margin: 8vh auto;\n padding: 2.5rem;\n}\n\n.join-panel h1,\n.room-header h1 { margin: .2rem 0 .75rem; }\n\n.eyebrow {\n margin: 0;\n color: #67e8f9;\n font-size: .75rem;\n font-weight: 800;\n letter-spacing: .12em;\n text-transform: uppercase;\n}\n\n.join-form { margin-top: 2rem; }\n.form-error { color: #fda4af; font-weight: 700; }\n\nlabel { display: block; margin-bottom: .5rem; font-weight: 700; }\n\n.input-row,\n.composer { display: flex; gap: .75rem; }\n\ninput,\nbutton { font: inherit; }\n\ninput {\n min-width: 0;\n flex: 1;\n padding: .8rem 1rem;\n border: 1px solid #365570;\n border-radius: .55rem;\n background: #07111f;\n color: inherit;\n}\n\nbutton {\n padding: .8rem 1.1rem;\n border: 0;\n border-radius: .55rem;\n background: #22d3ee;\n color: #083344;\n cursor: pointer;\n font-weight: 800;\n}\n\n.room-layout {\n display: grid;\n grid-template-columns: minmax(0, 1fr) 15rem;\n min-height: 38rem;\n}\n\n.conversation,\n.presence { border: 0; border-radius: 0; box-shadow: none; }\n\n.conversation { display: grid; grid-template-rows: auto 1fr auto; }\n\n.room-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1.25rem 1.5rem;\n border-bottom: 1px solid #27435e;\n}\n\n.quiet-button { background: #1b3349; color: #d8e8f6; }\n\n.message-list {\n display: flex;\n flex-direction: column;\n gap: .75rem;\n margin: 0;\n padding: 1.5rem;\n list-style: none;\n overflow-wrap: anywhere;\n}\n\n.message-list li:not(.empty-message) {\n max-width: 80%;\n padding: .8rem 1rem;\n border-radius: .75rem;\n background: #142b40;\n}\n\n.message-list strong { color: #67e8f9; }\n.message-list p { margin: .25rem 0 0; }\n.empty-message { margin: auto; color: #8ca3b8; }\n\n.composer { padding: 1rem 1.5rem 1.5rem; }\n\n.presence {\n padding: 1.5rem;\n border-left: 1px solid #27435e;\n background: rgba(5, 17, 29, .72);\n}\n\n.presence ul { padding: 0; list-style: none; }\n.presence li { padding: .45rem 0; }\n.presence li::before { content: '●'; margin-right: .5rem; color: #4ade80; }\n.presence .more-members { color: #8ca3b8; }\n.presence .more-members::before { content: ''; margin: 0; }\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n@media (max-width: 42rem) {\n main { padding: 1rem 0; }\n .room-layout { grid-template-columns: 1fr; }\n .presence { border-top: 1px solid #27435e; border-left: 0; }\n .input-row { flex-direction: column; }\n}\n```\n",
|
|
260
|
+
"files": [
|
|
261
|
+
{
|
|
262
|
+
"path": "package.json",
|
|
263
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"path": "tsconfig.json",
|
|
267
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"path": "src/app.tsx",
|
|
271
|
+
"content": "import { start, type LiveHtmlStartOptions } from 'redweb';\nimport { createChatroomPage } from './chatroom';\nimport { runApp } from './run-app';\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(createChatroomPage(), { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"path": "src/run-app.ts",
|
|
275
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"path": "src/app.css",
|
|
279
|
+
"content": ":root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"path": "scripts/copy-assets.cjs",
|
|
283
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"path": "test/network.cjs",
|
|
287
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"path": "test/app.test.cjs",
|
|
291
|
+
"content": "const test = require('node:test');\nconst { once } = require('node:events');\nconst assert = require('node:assert/strict');\nconst { listen, live, connect } = require('./network.cjs');\nconst { createChatroomPage, chatInputs } = require('../dist/chatroom.js');\n\ntest('the standalone canonical chat reports an occupied default port', { timeout: 10000 }, async t => {\n const net = require('node:net');\n const { spawnSync } = require('node:child_process');\n const occupied = net.createServer(socket => socket.destroy());\n t.after(() => new Promise(resolve => occupied.close(resolve)));\n occupied.listen(8080, '0.0.0.0');\n try { await once(occupied, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); } // An existing listener is left untouched.\n const result = spawnSync(process.execPath, ['dist/chatroom.js'], {\n encoding: 'utf8', timeout: 5000, windowsHide: true,\n });\n assert.equal(result.error, undefined);\n assert.equal(result.status, 1);\n assert.match(result.stderr, /EADDRINUSE/);\n});\n\ntest('members join once, exchange messages, and leave presence on disconnect', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const alice = await live(t, origin);\n const bob = await live(t, origin);\n alice.action('join', [{ name: 'Alice' }], 'chat');\n bob.action('join', [{ name: 'Bob' }], 'chat');\n await alice.patch(patch => patch.html.includes('Online · 2'));\n await bob.patch(patch => patch.html.includes('Online · 2'));\n alice.action('send', [{ message: 'Hello <friends>' }], 'chat');\n await bob.patch(patch => patch.html.includes('Hello <friends>'));\n const closed = once(alice.socket, 'close');\n alice.socket.close();\n await closed;\n await bob.patch(patch => patch.html.includes('Online · 1'));\n});\n\ntest('identities stay reserved across reconnects and are released by leaving', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const alice = await live(t, origin);\n const visitor = await live(t, origin);\n alice.action('join', [{ name: ' Alice ' }], 'chat');\n await alice.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Alice'));\n visitor.action('join', [{ name: 'ALICE' }], 'chat');\n await visitor.patch(patch => patch.html.includes('already in use'));\n const closed = once(alice.socket, 'close');\n alice.socket.close();\n await closed;\n visitor.send({ v: visitor.config.version, type: 'redweb:html', requestId: 'reserved-name', payload: { kind: 'action', name: 'join', args: [{ name: 'ALICE' }], component: 'chat' } });\n assert.equal((await visitor.receive(message => message.requestId === 'reserved-name')).payload, false);\n const { config } = alice;\n const resumed = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin);\n await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Online · 1')));\n resumed.send({ v: config.version, type: 'redweb:html', payload: { kind: 'action', name: 'leave', args: [], component: 'chat' } });\n await resumed.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('Join the chatroom')));\n visitor.action('join', [{ name: 'Alice' }], 'chat');\n await visitor.patch(patch => patch.html.includes('Connected as') && patch.html.includes('Online · 1'));\n});\n\ntest('room units bound history/presence, isolate rooms, and make repeated lifecycle calls harmless', () => {\n const Page = createChatroomPage();\n const alice = new Page().chat;\n const bob = new Page().chat;\n const isolated = new (createChatroomPage())().chat;\n assert.equal(alice.send({ message: 'not joined' }), false);\n alice.connected();\n assert.equal(alice.join(chatInputs.join.parse({ name: ' Alice ' })), true);\n assert.equal(alice.join({ name: 'Replacement' }), false);\n assert.equal(bob.join({ name: 'ALICE' }), false);\n assert.match(bob.render().toString(), /already in use/);\n assert.equal(bob.join({ name: 'Bob' }), true);\n assert.equal(isolated.join({ name: 'Alice' }), true);\n assert.match(alice.render().toString(), /No messages yet/);\n for (let index = 0; index < 101; index++) assert.equal(alice.send({ message: `message-${index}` }), true);\n assert.equal(bob.messages.length, 100);\n assert.deepEqual(bob.messages[0], { id: 2, sender: 'Alice', text: 'message-1' });\n assert.equal(bob.messages.at(-1).id, 101);\n assert.equal(isolated.messages.length, 0);\n assert.match(bob.render().toString(), /message-100/);\n alice.disconnected();\n alice.disconnected();\n assert.deepEqual(bob.members, ['Bob']);\n assert.equal(alice.send({ message: 'offline' }), false);\n alice.connected();\n assert.deepEqual(bob.members, ['Bob', 'Alice']);\n const visitors = Array.from({ length: 100 }, (_, index) => {\n const member = new Page().chat;\n assert.equal(member.join({ name: `visitor-${index}` }), true);\n return member;\n });\n const rendered = alice.render().toString();\n assert.match(rendered, /Online · 102/);\n assert.match(rendered, /\\+2 more/);\n assert.doesNotMatch(rendered, /<li[^>]*>visitor-99<\\/li>/);\n visitors.forEach(member => member.disposed());\n alice.leave();\n alice.disposed();\n alice.disposed();\n assert.deepEqual([alice.displayName, alice.feedback, alice.messages, alice.members], ['', '', [], []]);\n assert.deepEqual(bob.members, ['Bob']);\n assert.match(alice.render().toString(), /Join the chatroom/);\n bob.disposed();\n isolated.disposed();\n});\n"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"path": "test/run-app.test.cjs",
|
|
295
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"path": "README.md",
|
|
299
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Chat starter\n\n`src/chatroom.tsx` is the canonical Redweb chat component example, included directly rather than a second implementation.\nThe 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.\nVisitors choose a name once, chat in a shared room, and see live presence. Disconnect removes online presence;\nthe page session retains its identity briefly for reconnect, then disposal releases it.\n\nDisplay names are not authenticated identities. History is bounded to 100 messages in memory, not a persistent database.\nUse an application-owned persistence service before promising history across restarts or multiple server processes.\n\n`@action({ input: chatInputs.join })` validates and normalizes the form before `join` runs;\n`ActionInput<typeof chatInputs.join>` supplies its TypeScript input type. The same pattern handles messages.\nThe starter installs Zod as an application dependency; Redweb itself remains validator-independent.\nInvalid field values (including repeated names represented as arrays) receive `ACTION_INVALID_INPUT`, keep the draft,\nand show Redweb's built-in form feedback. Name collisions remain a room rule with their own friendly message.\nCalling a component method directly from server code bypasses transport validation: pass schema-parsed input.\nThe schemas reject ordinary unexpected fields; Zod may discard reserved object keys such as `__proto__`.\nOnly the parsed `name` or `message` reaches the corresponding action.\n\nWhen using the packed `examples/live-html/chatroom.js` directly instead of the generated starter,\ninstall its application validator with `npm install zod`. A cloned-repository development install already\nincludes it. Redweb's core and the counter example do not require Zod.\n"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
"path": ".gitignore",
|
|
303
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"path": "src/chatroom.tsx",
|
|
307
|
+
"content": "import { action, component, page, start, state, type ActionInput } from 'redweb';\nimport { z } from 'zod';\n\nconst MAX_VISIBLE_MEMBERS = 100;\nconst visibleText = (maximum: number) => z.string()\n .transform(value => value.normalize('NFKC').trim())\n .pipe(z.string().min(1).max(maximum).regex(/^[^\\p{Cc}\\p{Cf}]+$/u));\nexport const chatInputs = {\n join: z.object({ name: visibleText(40) }).strict(),\n send: z.object({ message: visibleText(500) }).strict(),\n};\n\ninterface StoredMessage { id: number; sender: string; text: string; }\ninterface RoomParticipant {\n readonly displayName: string;\n updateMessages(messages: readonly StoredMessage[]): void;\n updatePresence(members: readonly string[]): void;\n}\n\nclass ChatRoom {\n private history: readonly StoredMessage[] = [];\n private nextMessageId = 0;\n private readonly participants = new Set<RoomParticipant>();\n private readonly online = new Set<RoomParticipant>();\n\n join(participant: RoomParticipant) {\n const name = participant.displayName.toLocaleLowerCase();\n if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) return false;\n this.participants.add(participant);\n this.online.add(participant);\n participant.updateMessages(this.history);\n this.publishPresence();\n return true;\n }\n\n disconnect(participant: RoomParticipant) {\n if (this.online.delete(participant)) this.publishPresence();\n }\n\n leave(participant: RoomParticipant) {\n this.online.delete(participant);\n if (this.participants.delete(participant)) this.publishPresence();\n }\n\n send(participant: RoomParticipant, text: string) {\n if (!this.online.has(participant)) return false;\n this.history = [...this.history, { id: ++this.nextMessageId, sender: participant.displayName, text }].slice(-100);\n for (const member of this.participants) member.updateMessages(this.history);\n return true;\n }\n\n private publishPresence() {\n const members = [...this.online].map(participant => participant.displayName);\n for (const participant of this.participants) participant.updatePresence(members);\n }\n}\n\n@component()\nexport class ChatroomComponent implements RoomParticipant {\n @state() displayName = '';\n @state() feedback = '';\n @state() messages: readonly StoredMessage[] = [];\n @state() members: readonly string[] = [];\n\n constructor(private readonly room: ChatRoom) {}\n\n connected() { if (this.displayName) this.room.join(this); }\n disconnected() { this.room.disconnect(this); }\n disposed() { this.room.leave(this); }\n\n @action({ input: chatInputs.join })\n join({ name }: ActionInput<typeof chatInputs.join>) {\n if (this.displayName) return false;\n this.displayName = name;\n if (!this.room.join(this)) {\n this.displayName = '';\n this.feedback = 'That display name is already in use.';\n return false;\n }\n this.feedback = '';\n return true;\n }\n\n @action({ input: chatInputs.send })\n send({ message }: ActionInput<typeof chatInputs.send>) {\n return this.room.send(this, message);\n }\n\n @action()\n leave() {\n this.room.leave(this);\n this.displayName = '';\n this.feedback = '';\n this.messages = [];\n this.members = [];\n }\n\n updateMessages(messages: readonly StoredMessage[]) { this.messages = messages; }\n updatePresence(members: readonly string[]) { this.members = members; }\n\n render() {\n return <section class=\"chatroom\">{this.displayName ? this.roomScreen() : this.joinScreen()}</section>;\n }\n\n private joinScreen() {\n return (\n <section class=\"join-panel\">\n <p class=\"eyebrow\">Live room</p>\n <h1>Join the chatroom</h1>\n <p>Choose a name once, then chat in realtime with everyone currently in the room.</p>\n {this.feedback && <p class=\"form-error\" role=\"alert\">{this.feedback}</p>}\n <form rw-submit=\"join\" class=\"join-form\">\n <label for=\"display-name\">Display name</label>\n <div class=\"input-row\">\n <input id=\"display-name\" name=\"name\" maxlength=\"40\" autocomplete=\"nickname\" required autofocus />\n <button type=\"submit\">Join room</button>\n </div>\n </form>\n </section>\n );\n }\n\n private roomScreen() {\n const remaining = this.members.length - MAX_VISIBLE_MEMBERS;\n return (\n <div class=\"room-layout\">\n <section class=\"conversation\">\n <header class=\"room-header\">\n <div><p class=\"eyebrow\">Connected as</p><h1>{this.displayName}</h1></div>\n <button type=\"button\" class=\"quiet-button\" rw-click=\"leave\">Leave</button>\n </header>\n <ol class=\"message-list\" aria-live=\"polite\">\n {this.messages.length ? this.messages.map(entry => (\n <li key={entry.id}><strong>{entry.sender}</strong><p>{entry.text}</p></li>\n )) : <li class=\"empty-message\">No messages yet. Say hello.</li>}\n </ol>\n <form rw-submit=\"send\" class=\"composer\">\n <label class=\"sr-only\" for=\"chat-message\">Message</label>\n <input id=\"chat-message\" name=\"message\" maxlength=\"500\" autocomplete=\"off\" placeholder=\"Message the room…\" required autofocus />\n <button type=\"submit\">Send</button>\n </form>\n </section>\n <aside class=\"presence\" aria-label=\"People in the room\">\n <p class=\"eyebrow\">Online · {this.members.length}</p>\n <ul>\n {this.members.slice(0, MAX_VISIBLE_MEMBERS).map(member => <li key={member}>{member}</li>)}\n {remaining > 0 && <li class=\"more-members\">+{remaining} more</li>}\n </ul>\n </aside>\n </div>\n );\n }\n}\n\nexport function createChatroomPage() {\n const room = new ChatRoom();\n\n @page('/', { css: 'chatroom.css' })\n class ChatroomPage {\n chat = new ChatroomComponent(room);\n render() { return <main>{this.chat}</main>; }\n }\n\n return ChatroomPage;\n}\n\nif (require.main === module) start(createChatroomPage(), { port: 8080 });\n"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"path": "src/chatroom.css",
|
|
311
|
+
"content": ":root {\n color-scheme: dark;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n background: #07111f;\n color: #e5eef9;\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n min-height: 100vh;\n background: radial-gradient(circle at top, #15304b 0, #07111f 45rem);\n}\n\nmain {\n width: min(68rem, calc(100% - 2rem));\n margin: 0 auto;\n padding: 3rem 0;\n}\n\n.chatroom,\n.join-panel,\n.conversation,\n.presence {\n border: 1px solid #27435e;\n border-radius: 1rem;\n background: rgba(9, 24, 40, .92);\n box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .28);\n}\n\n.chatroom { overflow: hidden; }\n\n.join-panel {\n max-width: 38rem;\n margin: 8vh auto;\n padding: 2.5rem;\n}\n\n.join-panel h1,\n.room-header h1 { margin: .2rem 0 .75rem; }\n\n.eyebrow {\n margin: 0;\n color: #67e8f9;\n font-size: .75rem;\n font-weight: 800;\n letter-spacing: .12em;\n text-transform: uppercase;\n}\n\n.join-form { margin-top: 2rem; }\n.form-error { color: #fda4af; font-weight: 700; }\n\nlabel { display: block; margin-bottom: .5rem; font-weight: 700; }\n\n.input-row,\n.composer { display: flex; gap: .75rem; }\n\ninput,\nbutton { font: inherit; }\n\ninput {\n min-width: 0;\n flex: 1;\n padding: .8rem 1rem;\n border: 1px solid #365570;\n border-radius: .55rem;\n background: #07111f;\n color: inherit;\n}\n\nbutton {\n padding: .8rem 1.1rem;\n border: 0;\n border-radius: .55rem;\n background: #22d3ee;\n color: #083344;\n cursor: pointer;\n font-weight: 800;\n}\n\n.room-layout {\n display: grid;\n grid-template-columns: minmax(0, 1fr) 15rem;\n min-height: 38rem;\n}\n\n.conversation,\n.presence { border: 0; border-radius: 0; box-shadow: none; }\n\n.conversation { display: grid; grid-template-rows: auto 1fr auto; }\n\n.room-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1.25rem 1.5rem;\n border-bottom: 1px solid #27435e;\n}\n\n.quiet-button { background: #1b3349; color: #d8e8f6; }\n\n.message-list {\n display: flex;\n flex-direction: column;\n gap: .75rem;\n margin: 0;\n padding: 1.5rem;\n list-style: none;\n overflow-wrap: anywhere;\n}\n\n.message-list li:not(.empty-message) {\n max-width: 80%;\n padding: .8rem 1rem;\n border-radius: .75rem;\n background: #142b40;\n}\n\n.message-list strong { color: #67e8f9; }\n.message-list p { margin: .25rem 0 0; }\n.empty-message { margin: auto; color: #8ca3b8; }\n\n.composer { padding: 1rem 1.5rem 1.5rem; }\n\n.presence {\n padding: 1.5rem;\n border-left: 1px solid #27435e;\n background: rgba(5, 17, 29, .72);\n}\n\n.presence ul { padding: 0; list-style: none; }\n.presence li { padding: .45rem 0; }\n.presence li::before { content: '●'; margin-right: .5rem; color: #4ade80; }\n.presence .more-members { color: #8ca3b8; }\n.presence .more-members::before { content: ''; margin: 0; }\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n@media (max-width: 42rem) {\n main { padding: 1rem 0; }\n .room-layout { grid-template-columns: 1fr; }\n .presence { border-top: 1px solid #27435e; border-left: 0; }\n .input-row { flex-direction: column; }\n}\n"
|
|
312
|
+
}
|
|
313
|
+
],
|
|
314
|
+
"url": "/docs/reference/0.13.2/recipes/chat.md",
|
|
315
|
+
"sha256": "ef37af01e55f6cda2b6a6b7130c32fd9408435944e4dc7078bb27e0a375e7c3e"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"id": "recipes/site",
|
|
319
|
+
"title": "Site starter",
|
|
320
|
+
"summary": "`defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title.",
|
|
321
|
+
"source": "recipes/site/README.md",
|
|
322
|
+
"markdown": "# Site: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## Site starter\n\n`defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title.\nPages use server-rendered TSX with no React dependency and no injected browser runtime.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-site --template site\ncd my-site\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport { defineSite, start, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nconst site = defineSite({\n css: 'app.css',\n layout: content => <body><nav><a href=\"/\">Home</a> · <a href=\"/about\">About</a></nav>{content}</body>,\n});\n\n@site.page('/', { head: { title: 'My Redweb site', description: 'A server-rendered TypeScript site.' } })\nexport class HomePage {\n render() {\n return <main class=\"home\"><h1>Your server-rendered app is ready.</h1><p>Edit src/app.tsx to make it yours.</p></main>;\n }\n}\n\n@site.page('/about', { head: { title: 'About' } })\nexport class AboutPage {\n render() { return <main class=\"home\"><h1>About</h1><p>Shared layout, separate pages, no browser JavaScript.</p></main>; }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start([HomePage, AboutPage], { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { listen } = require('./network.cjs');\n\ntest('pages share a layout, serve CSS, and require no browser runtime', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n for (const route of ['/', '/about']) {\n const response = await fetch(`${origin}${route}`);\n assert.equal(response.status, 200);\n const document = await response.text();\n assert.match(document, /<nav>/);\n assert.doesNotMatch(document, /<script/);\n const css = document.match(/<link rel=\"stylesheet\" href=\"([^\"]+)\"/)[1];\n const stylesheet = await fetch(`${origin}${css}`);\n assert.equal(stylesheet.status, 200);\n assert.match(await stylesheet.text(), /\\.home/);\n }\n assert.equal((await fetch(`${origin}/missing`)).status, 404);\n});\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Site starter\n\n`defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title.\nPages use server-rendered TSX with no React dependency and no injected browser runtime.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n",
|
|
323
|
+
"files": [
|
|
324
|
+
{
|
|
325
|
+
"path": "package.json",
|
|
326
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"path": "tsconfig.json",
|
|
330
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"path": "src/app.tsx",
|
|
334
|
+
"content": "import { defineSite, start, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nconst site = defineSite({\n css: 'app.css',\n layout: content => <body><nav><a href=\"/\">Home</a> · <a href=\"/about\">About</a></nav>{content}</body>,\n});\n\n@site.page('/', { head: { title: 'My Redweb site', description: 'A server-rendered TypeScript site.' } })\nexport class HomePage {\n render() {\n return <main class=\"home\"><h1>Your server-rendered app is ready.</h1><p>Edit src/app.tsx to make it yours.</p></main>;\n }\n}\n\n@site.page('/about', { head: { title: 'About' } })\nexport class AboutPage {\n render() { return <main class=\"home\"><h1>About</h1><p>Shared layout, separate pages, no browser JavaScript.</p></main>; }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start([HomePage, AboutPage], { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"path": "src/run-app.ts",
|
|
338
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"path": "src/app.css",
|
|
342
|
+
"content": ":root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"path": "scripts/copy-assets.cjs",
|
|
346
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"path": "test/network.cjs",
|
|
350
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"path": "test/app.test.cjs",
|
|
354
|
+
"content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { listen } = require('./network.cjs');\n\ntest('pages share a layout, serve CSS, and require no browser runtime', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n for (const route of ['/', '/about']) {\n const response = await fetch(`${origin}${route}`);\n assert.equal(response.status, 200);\n const document = await response.text();\n assert.match(document, /<nav>/);\n assert.doesNotMatch(document, /<script/);\n const css = document.match(/<link rel=\"stylesheet\" href=\"([^\"]+)\"/)[1];\n const stylesheet = await fetch(`${origin}${css}`);\n assert.equal(stylesheet.status, 200);\n assert.match(await stylesheet.text(), /\\.home/);\n }\n assert.equal((await fetch(`${origin}/missing`)).status, 404);\n});\n"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"path": "test/run-app.test.cjs",
|
|
358
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"path": "README.md",
|
|
362
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Site starter\n\n`defineSite` supplies one layout and stylesheet for `/` and `/about`. Each page sets its own title.\nPages use server-rendered TSX with no React dependency and no injected browser runtime.\n"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"path": ".gitignore",
|
|
366
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
"url": "/docs/reference/0.13.2/recipes/site.md",
|
|
370
|
+
"sha256": "7d1ba4b8d27bed38958748307d2525f50bcea8db6821a12c31f923a2c633e2d1"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"id": "recipes/socket",
|
|
374
|
+
"title": "Socket starter",
|
|
375
|
+
"summary": "This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.",
|
|
376
|
+
"source": "recipes/socket/README.md",
|
|
377
|
+
"markdown": "# Socket: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## Socket starter\n\nThis is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.\nThe URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.\nThere are no socket decorators or secondary `message.action` dispatchers.\n\nRead `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define\nthe wire contract, route/server configuration, and join/move/resume handlers.\nThe displayed handlers depend on those other generated files; initialize the\ncomplete recipe first. Session ownership is separate from room fan-out.\n\n`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:\n\n```ts\nimport { match } from './contract';\n\nconst socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');\nconst client = match.client(socket);\nsocket.addEventListener('open', () => {\n client.send('join', { name: 'Ada' }).catch(console.error);\n});\nsocket.addEventListener('message', async event => {\n try { console.log(await client.parse(event)); }\n catch (error) { console.error(error); }\n});\n```\n\nThe 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.\n\n`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.\n\n### Boundaries\n\n- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.\n- 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.\n- 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.\n- 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.\n- 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.\n- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-socket --template socket\ncd my-socket\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport { SocketRoute, SocketServer, type SocketServerOptions } from 'redweb';\nimport { match } from './contract';\nimport { Join, Move, Resume } from './handlers';\nimport { runApp } from './run-app';\n\nexport class MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [Join, Move, Resume],\n protocol: match.protocol,\n orderedMessages: true,\n sessions: { ttlMs: 30000, maxSessions: 100 },\n heartbeat: { intervalMs: 15000, timeoutMs: 10000 },\n allowDuplicateConnections: true,\n websocketOptions: { maxPayload: 4096 },\n limits: { maxConnections: 100, maxPendingMessages: 32, maxBufferedBytes: 65536 },\n });\n }\n}\n\nexport function createApp(options: SocketServerOptions = {}) {\n return new SocketServer({\n port: Number(process.env.PORT ?? 8181),\n routes: [MatchRoute],\n ...options,\n });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst { listen, connect } = require('./network.cjs');\nconst { match } = require('../dist/contract.js');\n\nasync function rejected(client, type, payload) {\n const closed = once(client.socket, 'close');\n await match.client(client.socket).send(type, payload);\n assert.equal((await client.receive(message => message.type === 'error')).error.code, 'HANDLER_FAILED');\n assert.equal((await closed)[0], 1011);\n}\n\ntest('join, move and resume use separate validated handlers with isolated server sessions', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n const first = await connect(t, url, origin);\n const second = await connect(t, url, origin);\n const client = match.client(first.socket);\n await client.send('join', { name: ' Ada ' }, { requestId: 'join-1' });\n const joined = await first.receive(message => message.type === 'state');\n assert.equal(joined.requestId, 'join-1');\n assert.equal(joined.payload.name, 'Ada');\n assert.deepEqual([joined.payload.x, joined.payload.y], [0, 0]);\n\n const unjoined = await connect(t, url, origin);\n await rejected(unjoined, 'move', { x: 2, y: 3 });\n await match.client(second.socket).send('join', { name: 'Grace' });\n const other = await second.receive(message => message.type === 'state');\n assert.notEqual(other.payload.session, joined.payload.session);\n\n await client.send('move', { x: 7, y: -3 }, { requestId: 'move-1' });\n assert.deepEqual((await first.receive(message => message.type === 'state')).payload,\n { ...joined.payload, x: 7, y: -3 });\n await assert.rejects(client.send('move', { x: 101, y: 0 }), { code: 'INVALID_PAYLOAD' });\n const closed = once(first.socket, 'close');\n first.socket.close();\n await closed;\n const resumed = await connect(t, url, origin);\n await match.client(resumed.socket).send('resume', { session: joined.payload.session });\n assert.deepEqual((await resumed.receive(message => message.type === 'state')).payload,\n { ...joined.payload, x: 7, y: -3 });\n\n // Bypass client validation to prove the server independently rejects malformed input.\n const rejectedClosed = once(resumed.socket, 'close');\n resumed.send({ v: match.version, type: 'move', payload: { x: 'wrong', y: 0 } });\n assert.equal((await resumed.receive(message => message.type === 'error')).error.code, 'INVALID_PAYLOAD');\n assert.equal((await rejectedClosed)[0], 1008);\n});\n\ntest('joined identities cannot join/resume again and unknown sessions fail closed', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n for (const type of ['join', 'resume']) {\n const client = await connect(t, url, origin);\n await match.client(client.socket).send('join', { name: 'Ada' });\n const joined = await client.receive(message => message.type === 'state');\n await rejected(client, type, type === 'join' ? { name: 'Replacement' } : { session: joined.payload.session });\n }\n const visitor = await connect(t, url, origin);\n await rejected(visitor, 'resume', { session: require('node:crypto').randomUUID() });\n});\n\ntest('retained disconnected sessions count toward the bounded session capacity', { timeout: 20000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n const sessions = new Set();\n for (let index = 0; index < 100; index++) {\n const client = await connect(t, url, origin);\n await match.client(client.socket).send('join', { name: `player-${index}` });\n const joined = await client.receive(message => message.type === 'state');\n sessions.add(joined.payload.session);\n const closed = once(client.socket, 'close');\n client.socket.close();\n await closed;\n }\n assert.equal(sessions.size, 100);\n const overflow = await connect(t, url, origin);\n await rejected(overflow, 'join', { name: 'overflow' });\n // Capacity rejection does not invalidate a previously issued bearer session.\n const resumed = await connect(t, url, origin);\n const session = sessions.values().next().value;\n await match.client(resumed.socket).send('resume', { session });\n assert.equal((await resumed.receive(message => message.type === 'state')).payload.session, session);\n});\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Socket starter\n\nThis is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.\nThe URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.\nThere are no socket decorators or secondary `message.action` dispatchers.\n\nRead `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define\nthe wire contract, route/server configuration, and join/move/resume handlers.\nThe displayed handlers depend on those other generated files; initialize the\ncomplete recipe first. Session ownership is separate from room fan-out.\n\n`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:\n\n```ts\nimport { match } from './contract';\n\nconst socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');\nconst client = match.client(socket);\nsocket.addEventListener('open', () => {\n client.send('join', { name: 'Ada' }).catch(console.error);\n});\nsocket.addEventListener('message', async event => {\n try { console.log(await client.parse(event)); }\n catch (error) { console.error(error); }\n});\n```\n\nThe 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.\n\n`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.\n\n### Boundaries\n\n- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.\n- 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.\n- 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.\n- 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.\n- 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.\n- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n\n### src/contract.ts\n\n```ts\nimport { defineSocketContract } from 'redweb/contract';\nimport { z } from 'zod';\n\nconst position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };\n\n// Share this module with a browser or Node client. It imports no server application code.\nexport const match = defineSocketContract('1', {\n join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),\n move: z.object(position).strict(),\n resume: z.object({ session: z.string().uuid() }).strict(),\n state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),\n});\n```\n\n### src/handlers.ts\n\n```ts\nimport { randomUUID } from 'node:crypto';\nimport type { RedWebSocket } from 'redweb';\nimport { match } from './contract';\n\nclass Player {\n readonly session = randomUUID();\n x = 0;\n y = 0;\n constructor(readonly name: string) {}\n}\n\nfunction requireUnjoined(socket: RedWebSocket) {\n if (socket.context?.session) throw new Error('Already joined.');\n}\n\nfunction currentPlayer(socket: RedWebSocket) {\n const session = socket.context?.session as { data?: unknown } | null | undefined;\n if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');\n return session.data;\n}\n\nexport const Join = match.handler('join', (socket, { name }, message) => {\n requireUnjoined(socket);\n const player = new Player(name);\n if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Move = match.handler('move', (socket, { x, y }, message) => {\n const player = currentPlayer(socket);\n player.x = x;\n player.y = y;\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Resume = match.handler('resume', (socket, { session }, message) => {\n requireUnjoined(socket);\n if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');\n return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });\n});\n```\n",
|
|
378
|
+
"files": [
|
|
379
|
+
{
|
|
380
|
+
"path": "package.json",
|
|
381
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n"
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"path": "tsconfig.json",
|
|
385
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"path": "src/app.tsx",
|
|
389
|
+
"content": "import { SocketRoute, SocketServer, type SocketServerOptions } from 'redweb';\nimport { match } from './contract';\nimport { Join, Move, Resume } from './handlers';\nimport { runApp } from './run-app';\n\nexport class MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [Join, Move, Resume],\n protocol: match.protocol,\n orderedMessages: true,\n sessions: { ttlMs: 30000, maxSessions: 100 },\n heartbeat: { intervalMs: 15000, timeoutMs: 10000 },\n allowDuplicateConnections: true,\n websocketOptions: { maxPayload: 4096 },\n limits: { maxConnections: 100, maxPendingMessages: 32, maxBufferedBytes: 65536 },\n });\n }\n}\n\nexport function createApp(options: SocketServerOptions = {}) {\n return new SocketServer({\n port: Number(process.env.PORT ?? 8181),\n routes: [MatchRoute],\n ...options,\n });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"path": "src/run-app.ts",
|
|
393
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"path": "src/app.css",
|
|
397
|
+
"content": ":root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n"
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
"path": "scripts/copy-assets.cjs",
|
|
401
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"path": "test/network.cjs",
|
|
405
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"path": "test/app.test.cjs",
|
|
409
|
+
"content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst { listen, connect } = require('./network.cjs');\nconst { match } = require('../dist/contract.js');\n\nasync function rejected(client, type, payload) {\n const closed = once(client.socket, 'close');\n await match.client(client.socket).send(type, payload);\n assert.equal((await client.receive(message => message.type === 'error')).error.code, 'HANDLER_FAILED');\n assert.equal((await closed)[0], 1011);\n}\n\ntest('join, move and resume use separate validated handlers with isolated server sessions', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n const first = await connect(t, url, origin);\n const second = await connect(t, url, origin);\n const client = match.client(first.socket);\n await client.send('join', { name: ' Ada ' }, { requestId: 'join-1' });\n const joined = await first.receive(message => message.type === 'state');\n assert.equal(joined.requestId, 'join-1');\n assert.equal(joined.payload.name, 'Ada');\n assert.deepEqual([joined.payload.x, joined.payload.y], [0, 0]);\n\n const unjoined = await connect(t, url, origin);\n await rejected(unjoined, 'move', { x: 2, y: 3 });\n await match.client(second.socket).send('join', { name: 'Grace' });\n const other = await second.receive(message => message.type === 'state');\n assert.notEqual(other.payload.session, joined.payload.session);\n\n await client.send('move', { x: 7, y: -3 }, { requestId: 'move-1' });\n assert.deepEqual((await first.receive(message => message.type === 'state')).payload,\n { ...joined.payload, x: 7, y: -3 });\n await assert.rejects(client.send('move', { x: 101, y: 0 }), { code: 'INVALID_PAYLOAD' });\n const closed = once(first.socket, 'close');\n first.socket.close();\n await closed;\n const resumed = await connect(t, url, origin);\n await match.client(resumed.socket).send('resume', { session: joined.payload.session });\n assert.deepEqual((await resumed.receive(message => message.type === 'state')).payload,\n { ...joined.payload, x: 7, y: -3 });\n\n // Bypass client validation to prove the server independently rejects malformed input.\n const rejectedClosed = once(resumed.socket, 'close');\n resumed.send({ v: match.version, type: 'move', payload: { x: 'wrong', y: 0 } });\n assert.equal((await resumed.receive(message => message.type === 'error')).error.code, 'INVALID_PAYLOAD');\n assert.equal((await rejectedClosed)[0], 1008);\n});\n\ntest('joined identities cannot join/resume again and unknown sessions fail closed', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n for (const type of ['join', 'resume']) {\n const client = await connect(t, url, origin);\n await match.client(client.socket).send('join', { name: 'Ada' });\n const joined = await client.receive(message => message.type === 'state');\n await rejected(client, type, type === 'join' ? { name: 'Replacement' } : { session: joined.payload.session });\n }\n const visitor = await connect(t, url, origin);\n await rejected(visitor, 'resume', { session: require('node:crypto').randomUUID() });\n});\n\ntest('retained disconnected sessions count toward the bounded session capacity', { timeout: 20000 }, async t => {\n const origin = await listen(t);\n const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;\n const sessions = new Set();\n for (let index = 0; index < 100; index++) {\n const client = await connect(t, url, origin);\n await match.client(client.socket).send('join', { name: `player-${index}` });\n const joined = await client.receive(message => message.type === 'state');\n sessions.add(joined.payload.session);\n const closed = once(client.socket, 'close');\n client.socket.close();\n await closed;\n }\n assert.equal(sessions.size, 100);\n const overflow = await connect(t, url, origin);\n await rejected(overflow, 'join', { name: 'overflow' });\n // Capacity rejection does not invalidate a previously issued bearer session.\n const resumed = await connect(t, url, origin);\n const session = sessions.values().next().value;\n await match.client(resumed.socket).send('resume', { session });\n assert.equal((await resumed.receive(message => message.type === 'state')).payload.session, session);\n});\n"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"path": "test/run-app.test.cjs",
|
|
413
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"path": "README.md",
|
|
417
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## Socket starter\n\nThis is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.\nThe URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.\nThere are no socket decorators or secondary `message.action` dispatchers.\n\nRead `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define\nthe wire contract, route/server configuration, and join/move/resume handlers.\nThe displayed handlers depend on those other generated files; initialize the\ncomplete recipe first. Session ownership is separate from room fan-out.\n\n`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:\n\n```ts\nimport { match } from './contract';\n\nconst socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');\nconst client = match.client(socket);\nsocket.addEventListener('open', () => {\n client.send('join', { name: 'Ada' }).catch(console.error);\n});\nsocket.addEventListener('message', async event => {\n try { console.log(await client.parse(event)); }\n catch (error) { console.error(error); }\n});\n```\n\nThe 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.\n\n`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.\n\n### Boundaries\n\n- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.\n- 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.\n- 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.\n- 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.\n- 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.\n- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.\n"
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
"path": ".gitignore",
|
|
421
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
"path": "src/contract.ts",
|
|
425
|
+
"content": "import { defineSocketContract } from 'redweb/contract';\nimport { z } from 'zod';\n\nconst position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };\n\n// Share this module with a browser or Node client. It imports no server application code.\nexport const match = defineSocketContract('1', {\n join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),\n move: z.object(position).strict(),\n resume: z.object({ session: z.string().uuid() }).strict(),\n state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),\n});\n"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"path": "src/handlers.ts",
|
|
429
|
+
"content": "import { randomUUID } from 'node:crypto';\nimport type { RedWebSocket } from 'redweb';\nimport { match } from './contract';\n\nclass Player {\n readonly session = randomUUID();\n x = 0;\n y = 0;\n constructor(readonly name: string) {}\n}\n\nfunction requireUnjoined(socket: RedWebSocket) {\n if (socket.context?.session) throw new Error('Already joined.');\n}\n\nfunction currentPlayer(socket: RedWebSocket) {\n const session = socket.context?.session as { data?: unknown } | null | undefined;\n if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');\n return session.data;\n}\n\nexport const Join = match.handler('join', (socket, { name }, message) => {\n requireUnjoined(socket);\n const player = new Player(name);\n if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Move = match.handler('move', (socket, { x, y }, message) => {\n const player = currentPlayer(socket);\n player.x = x;\n player.y = y;\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Resume = match.handler('resume', (socket, { session }, message) => {\n requireUnjoined(socket);\n if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');\n return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });\n});\n"
|
|
430
|
+
}
|
|
431
|
+
],
|
|
432
|
+
"url": "/docs/reference/0.13.2/recipes/socket.md",
|
|
433
|
+
"sha256": "4fceb5a35f2a867f25603510fba5a6db6527fb03129ea375d09df6794526de61"
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
"id": "recipes/dashboard",
|
|
437
|
+
"title": "Dashboard starter",
|
|
438
|
+
"summary": "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.",
|
|
439
|
+
"source": "recipes/dashboard/README.md",
|
|
440
|
+
"markdown": "# Dashboard: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n# Persistent private dashboard\n\nThis 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.\n\n## Run it\n\nRequires **Node 22.13 or newer** (native `node:sqlite`, experimental in Node 22) and npm. Other Redweb starters retain their own Node requirements. Use a supported Node release in production.\n\nAfter installing dependencies, create your account:\n\n```sh\nnpm run add-user -- alice\nnpm test\nnpm run dev\n```\n\nThe provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.\n\n`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.\n\n`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.\n\n## Where the behavior lives\n\n- `app.tsx`: composition, login page, protected dashboard, listener and shutdown.\n- `cards.tsx`: reusable `Cards` component and account-scoped live subscriptions. Normal TSX expressions update automatically. Forms call typed actions; feedback requires no browser glue.\n- `store.ts`: prepared SQL, bounded cards/sessions, owner-filtered operations and synchronous transactions.\n- `auth.ts`: asynchronous scrypt, bounded login attempts, hashed session tokens, cookies and sign-out.\n- `admin.ts`: explicit local account provisioning.\n\n## Production boundaries\n\nSet `DASHBOARD_DATABASE` to a writable persistent file path (default `data/dashboard.sqlite`). Protect the directory with OS permissions: the database contains password hashes, private card text, and session metadata. It, its WAL/SHM files, and backups must never be served as public assets or committed. Stop the process cleanly before copying the database for a backup, or use a proper SQLite online backup facility; copying only the main file during live WAL writes is not a backup plan.\n\nSet `NODE_ENV=production` and `DASHBOARD_ORIGIN=https://your-domain.example` behind an HTTPS reverse proxy. The origin must have no path or trailing slash. This enables Secure cookies; all session cookies are HttpOnly and SameSite=Strict. Both login/logout forms and socket upgrades require the exact trusted origin. The application does not trust Host or forwarded headers to establish origin or identity. The HTTP listener must not be publicly reachable around your TLS proxy.\n\nProvision accounts on the same persistent volume before serving requests. Passwords use salted scrypt; only hashes of random session tokens are stored. Default sessions last one hour. Up to 32 unexpired sessions and 100 cards per account are supported. Login work is limited to four simultaneous checks and ten attempts per minute per direct peer IP, with at most 1,024 tracked IPs; clients behind one proxy share its bucket. Add appropriate proxy-level abuse controls for an Internet deployment. There is no registration, password reset, MFA, or account recovery; integrate a dedicated identity provider if your product needs those features.\n\nSQL checks the current session and card owner inside each write transaction. Private subscriptions recheck session validity before publishing and close at expiry. Sign-out invalidates credentials before revoking Redweb sessions. An expired or disconnected page may need a reload/sign-in; the recipe does not silently retry actions with uncertain outcomes.\n\nThis is a **single-process live-update model**. SQLite transactions are synchronous and kept small; this is not a claim of unlimited concurrency. Do not put multiple app workers behind a load balancer and expect cross-worker notifications or revocation. Add a deliberate shared notification/session-revocation design before scaling horizontally. Static export cannot include protected dashboards.\n\nBuild and deploy using the shared instructions above, including the persistent data volume and the environment settings here. Neither `npm run dev` nor a process restart should erase durable cards. Redweb itself does not depend on SQLite.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-dashboard --template dashboard\ncd my-dashboard\nnpm install --save-exact redweb@0.13.2\nnpm run add-user -- alice\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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 test/rate-window.test.cjs\",\n \"add-user\": \"npm run build && node dist/admin.js\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\",\n \"express\": \"^4.22.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\",\n \"@types/node\": \"^22.20.1\",\n \"@types/express\": \"^4.17.21\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n },\n \"engines\": {\n \"node\": \">=22.13.0\"\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport express, { type ErrorRequestHandler } from 'express';\nimport { mkdirSync } from 'node:fs';\nimport { dirname, resolve } from 'node:path';\nimport { page, start, type LivePageRequestContext } from 'redweb';\nimport { DashboardAuth, sessionToken } from './auth';\nimport { Cards, PrivateCards } from './cards';\nimport { DashboardStore } from './store';\nimport { runApp } from './run-app';\n\nexport interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; }\n\nexport function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }\n\nexport function createApp(options: DashboardOptions = {}) {\n const port = options.port ?? Number(process.env.PORT ?? 8181);\n const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;\n if (configuredOrigin && (!/^https?:$/.test(new URL(configuredOrigin).protocol) || new URL(configuredOrigin).origin !== configuredOrigin)) {\n throw new Error('DASHBOARD_ORIGIN must be an exact HTTP(S) origin without a path.');\n }\n if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');\n const filename = options.database ?? databasePath();\n mkdirSync(dirname(filename), { recursive: true });\n const store = new DashboardStore(filename);\n try {\n const cards = new PrivateCards(store);\n const auth = new DashboardAuth(store, options.sessionLifetimeMs);\n const app = express();\n app.disable('x-powered-by');\n app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));\n const invalidBody: ErrorRequestHandler = (_error, _request, response, _next) => {\n if (!response.destroyed) response.status(400).send('Invalid form submission.');\n };\n app.use(invalidBody);\n const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;\n\n @page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })\n class Login {\n render() {\n return <main class=\"home\"><h1>Your private workspace</h1>\n <p>Sign in with the credentials created by your administrator.</p>\n <form method=\"post\" action=\"/login\">\n <label for=\"account\">Account</label><input id=\"account\" name=\"account\" autocomplete=\"username\" required />\n <label for=\"password\">Password</label><input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"submit\">Sign in</button>\n </form>\n </main>;\n }\n }\n\n @page('/', { css: 'app.css', authorize: context => cards.allowed(context), head: { title: 'Your cards' } })\n class Dashboard {\n private readonly workspace = new Cards(cards);\n render(context: LivePageRequestContext) {\n return <main class=\"home\"><header><div><h1>Your cards</h1><p>Signed in as {context.principal}</p></div>\n <form method=\"post\" action=\"/logout\"><button type=\"submit\">Sign out all sessions</button></form>\n </header>{this.workspace}<p>Open another tab to see your changes instantly.</p></main>;\n }\n }\n\n auth.mount(app, origin, account => server.revoke(account));\n const server = start([Login, Dashboard], {\n server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,\n origins: value => value === origin(),\n authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'\n ? true : store.session(sessionToken(request.headers.cookie))?.account,\n });\n let closing: Promise<void> | undefined;\n const shutdown = () => {\n auth.close();\n if (!closing) {\n closing = server.shutdown().finally(() => store.close());\n }\n return closing;\n };\n server.server.once('error', () => { void shutdown().catch(() => {}); });\n return {\n server: server.server,\n shutdown,\n };\n } catch (error) { store.close(); throw error; }\n}\n\nif (require.main === module) {\n const app = runApp(createApp);\n app?.server.once('listening', () => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(app.server.address() as { port: number }).port}`}/login`));\n}\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { font-family: system-ui, sans-serif; color: #e8edf5; background: #111827; color-scheme: dark; }\n* { box-sizing: border-box; }\nbody { margin: 0; }\n.home { width: min(64rem, 100%); margin: 3rem auto; padding: 0 1.5rem; }\nheader { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }\nform { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; }\ninput, button { font: inherit; border: 1px solid #526179; border-radius: .5rem; padding: .75rem; }\ninput { background: #1f2937; max-width: 100%; }\nbutton { background: #a7f3d0; color: #102c23; cursor: pointer; }\nbutton:disabled { opacity: .5; cursor: default; }\n:focus-visible { outline: 3px solid #60a5fa; outline-offset: 3px; }\n.cards { margin-top: 2rem; }\n.card-grid { padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; }\n.card-grid li { border: 1px solid #526179; border-radius: .75rem; padding: 1.25rem; overflow-wrap: anywhere; }\nh2 { font-size: 1.25rem; }\n[role=\"alert\"] { color: #fca5a5; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { once } = require('node:events');\nconst { mkdtempSync, rmSync, writeFileSync } = require('node:fs');\nconst { tmpdir } = require('node:os');\nconst { join } = require('node:path');\nconst { DatabaseSync } = require('node:sqlite');\nconst { spawn, spawnSync } = require('node:child_process');\nconst net = require('node:net');\nconst { WebSocketServer, WebSocket } = require('ws');\nconst { createApp, databasePath } = require('../dist/app');\nconst { DashboardStore } = require('../dist/store');\nconst { DashboardAuth, credentials, sessionToken } = require('../dist/auth');\nconst { PrivateCards } = require('../dist/cards');\nconst { live, connect } = require('./network.cjs');\n\nconst password = 'test-only-correct-password';\nconst delay = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nasync function fixture(t, options = {}) {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-private-cards-'));\n const database = join(directory, 'cards.sqlite');\n const store = new DashboardStore(database);\n const secret = await credentials(password);\n store.provision('alice', secret);\n store.provision('bob', secret);\n store.close();\n let app;\n t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });\n async function restart() {\n await app?.shutdown();\n app = createApp({ port: 0, database, ...options });\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n }\n return { database, restart, origin: await restart(), get app() { return app; } };\n}\n\nfunction post(origin, path, values, cookie, suppliedOrigin = origin) {\n return fetch(`${origin}${path}`, {\n method: 'POST', redirect: 'manual',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: suppliedOrigin, ...(cookie ? { Cookie: cookie } : {}) },\n body: new URLSearchParams(values),\n });\n}\n\nasync function login(origin, account = 'alice') {\n const response = await post(origin, '/login', { account, password });\n assert.equal(response.status, 303);\n const header = response.headers.get('set-cookie');\n assert.match(header, /HttpOnly; SameSite=Strict; Path=\\//);\n return header.split(';')[0];\n}\n\nasync function cardClient(t, origin, cookie) {\n const client = await live(t, origin, { Cookie: cookie });\n const component = client.document.match(/data-rw-component=\"([^\"]+)\"/)[1];\n await client.patch(patch => patch.id === 'root');\n const parseCards = html => [...html.matchAll(/data-card-id=\"([^\"]+)\"[^>]*><h2>([\\s\\S]*?)<\\/h2>/g)].map(match => ({\n id: match[1], title: match[2].replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '\"').replaceAll(''', \"'\").replaceAll('&', '&'),\n }));\n return {\n ...client, component,\n add: title => client.action('add', [{ title }], component),\n remove: id => client.action('remove', [{ id }], component),\n items: predicate => client.patch(patch => predicate(parseCards(patch.html))).then(message => parseCards(message.payload.patches.find(patch => predicate(parseCards(patch.html))).html)),\n };\n}\n\ntest('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {\n const fixtureApp = await fixture(t);\n let { origin } = fixtureApp;\n assert.equal((await fetch(`${origin}/login`)).status, 200);\n assert.equal((await fetch(origin)).status, 401);\n assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);\n assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);\n const alice = await login(origin);\n const alice2 = await login(origin);\n const bob = await login(origin, 'bob');\n const page = await fetch(origin, { headers: { Cookie: alice } });\n assert.match(page.headers.get('cache-control'), /private.*no-store/);\n assert.equal(page.headers.get('etag'), null);\n const first = await cardClient(t, origin, alice);\n const second = await cardClient(t, origin, alice2);\n const other = await cardClient(t, origin, bob);\n first.add('Saved <script>alert(1)</script>');\n const [items] = await Promise.all([first.items(value => value.length === 1), second.items(value => value.length === 1)]);\n assert.equal(items[0].title, 'Saved <script>alert(1)</script>');\n const db = new DashboardStore(fixtureApp.database);\n assert.deepEqual(db.list(sessionToken(bob)), []);\n other.remove(items[0].id);\n await delay(50);\n assert.equal(db.list(sessionToken(alice)).length, 1);\n first.action('add', [{ title: 'forged', account: 'bob' }], first.component);\n const invalid = await first.receive(message => message.type === 'error');\n assert.equal(invalid.error.code, 'ACTION_INVALID_INPUT');\n assert.equal(db.list(sessionToken(alice)).length, 1);\n const closed = once(first.socket, 'close'); first.socket.close(); await closed;\n second.add('While disconnected');\n await second.items(value => value.length === 2);\n const config = first.config;\n const reconnect = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, { Cookie: alice });\n await reconnect.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('While disconnected')));\n const deniedLogout = await post(origin, '/logout', {}, alice, 'https://foreign.example');\n assert.equal(deniedLogout.status, 403);\n const aliceClosed = once(reconnect.socket, 'close');\n const secondClosed = once(second.socket, 'close');\n const loggedOut = await post(origin, '/logout', {}, alice);\n assert.equal(loggedOut.status, 303);\n assert.match(loggedOut.headers.get('set-cookie'), /Max-Age=0/);\n await Promise.all([aliceClosed, secondClosed]);\n assert.equal((await fetch(origin, { headers: { Cookie: alice2 } })).status, 401);\n assert.equal(other.socket.readyState, 1);\n assert.equal(db.session(sessionToken(alice)), undefined);\n db.close();\n origin = await fixtureApp.restart();\n const renewed = await login(origin);\n const restored = await fetch(origin, { headers: { Cookie: renewed } });\n const text = await restored.text();\n assert.match(text, /While disconnected/);\n assert.match(text, /<script>/);\n const client = await cardClient(t, origin, renewed);\n client.remove(items[0].id);\n await client.items(value => value.length === 1);\n});\n\ntest('session expiry closes idle sockets and rejects later HTTP access', async t => {\n const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });\n const cookie = await login(origin);\n const client = await cardClient(t, origin, cookie);\n const [code] = await once(client.socket, 'close');\n assert.equal(code, 1008);\n assert.equal((await fetch(origin, { headers: { Cookie: cookie } })).status, 401);\n});\n\ntest('store and authentication units use actual SQLite and scrypt, never substitutes', async t => {\n const previousDatabase = process.env.DASHBOARD_DATABASE;\n const previousPort = process.env.PORT;\n try {\n delete process.env.DASHBOARD_DATABASE;\n delete process.env.PORT;\n assert.equal(databasePath(), require('node:path').resolve('data/dashboard.sqlite'));\n assert.throws(() => createApp({ origin: 'ftp://invalid.example' }), /exact/);\n } finally {\n if (previousDatabase === undefined) delete process.env.DASHBOARD_DATABASE; else process.env.DASHBOARD_DATABASE = previousDatabase;\n if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort;\n }\n const directory = mkdtempSync(join(tmpdir(), 'redweb-store-'));\n const database = join(directory, 'unit.sqlite');\n const store = new DashboardStore(database);\n t.after(() => { store.close(); rmSync(directory, { recursive: true, force: true }); });\n await assert.rejects(credentials('short'), /16/);\n const secret = await credentials(password);\n assert.throws(() => store.provision('?', secret), /Invalid/);\n store.provision('alice', secret);\n assert.throws(() => store.provision('alice', secret));\n assert.equal(store.credentials('missing'), undefined);\n assert.throws(() => store.issue('alice', 0));\n const auth = new DashboardAuth(store);\n assert.equal(await auth.login('peer', 'unknown', password), undefined);\n assert.equal(await auth.login('peer', {}, password), undefined);\n const token = await auth.login('peer', 'alice', password);\n assert.equal(store.session(token).account, 'alice');\n assert.equal(sessionToken(`redweb_dashboard=${token}`), token);\n assert.equal(sessionToken(`redweb_dashboard=${token}; redweb_dashboard=${token}`), '');\n assert.equal(sessionToken('redweb_dashboard=invalid'), '');\n assert.equal(store.session('invalid'), undefined);\n assert.throws(() => store.list('invalid'), /expired/);\n assert.throws(() => store.add(token, ''), /Invalid/);\n assert.throws(() => store.add(token, '\\u0000'), /Invalid/);\n for (let i = 0; i < 100; i++) store.add(token, `Card ${i}`);\n assert.throws(() => store.add(token, 'Over capacity'), /limit/);\n assert.equal(store.list(token).length, 100);\n store.remove(token, store.list(token)[0].id);\n assert.equal(store.list(token).length, 99);\n for (let i = 1; i < 32; i++) store.issue('alice', 10000);\n assert.throws(() => store.issue('alice', 10000), /existing sessions/);\n assert.equal(store.signOut(token), 'alice');\n assert.equal(store.signOut(token), undefined);\n assert.throws(() => store.remove(token, 'anything'), /expired/);\n for (let i = 0; i < 10; i++) await auth.login('limited', '!', password);\n assert.equal(await auth.login('limited', 'alice', password), undefined);\n store.close(); store.close();\n const raw = new DatabaseSync(database);\n raw.exec('PRAGMA user_version = 2'); raw.close();\n assert.throws(() => new DashboardStore(database), /Unsupported/);\n});\n\ntest('logout fences password checks in flight; close and admission bounds stop new sessions', async t => {\n const store = new DashboardStore(':memory:');\n t.after(() => store.close());\n store.provision('alice', await credentials(password));\n const auth = new DashboardAuth(store);\n const token = store.issue('alice', 5000);\n const pending = auth.login('peer', 'alice', password);\n store.signOut(token);\n await assert.rejects(pending, /Sign-out occurred/);\n const closing = auth.login('peer', 'alice', password);\n auth.close();\n assert.equal(await closing, undefined);\n assert.equal(await auth.login('peer', 'alice', password), undefined);\n assert.throws(() => new DashboardAuth(store, 0));\n const limited = new DashboardAuth(store);\n const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));\n assert.equal(await concurrent[4], undefined);\n const issued = await Promise.all(concurrent.slice(0, 4));\n assert.ok(issued.every(Boolean));\n for (let index = 0; index < 1024; index++) await limited.login(`invalid-${index}`, null, null);\n assert.equal(await limited.login('new-peer', 'alice', password), undefined);\n const expiring = store.issue('alice', 100);\n await delay(110);\n assert.equal(store.session(expiring), undefined);\n store.issue('alice', 1000); // Prunes expired rows during issuance.\n});\n\ntest('subscription cleanup is idempotent across replacement groups and failed callbacks', async t => {\n const store = new DashboardStore(':memory:');\n store.provision('alice', await credentials(password));\n const token = store.issue('alice', 5000);\n const cards = new PrivateCards(store);\n const sockets = new WebSocketServer({ port: 0, host: '127.0.0.1' });\n await once(sockets, 'listening');\n const peers = [];\n t.after(async () => {\n for (const peer of peers) peer.terminate();\n for (const peer of sockets.clients) peer.terminate();\n await new Promise(resolve => sockets.close(resolve)); store.close();\n });\n async function context() {\n const accepted = once(sockets, 'connection');\n const client = new WebSocket(`ws://127.0.0.1:${sockets.address().port}`); peers.push(client);\n const opened = once(client, 'open');\n const [socket] = await accepted;\n await opened;\n const controller = new AbortController();\n return { controller, value: { principal: 'alice', signal: controller.signal, socket, request: { get: name => name === 'cookie' ? `redweb_dashboard=${token}` : undefined } } };\n }\n const original = await context();\n const cleanup = cards.subscribe(original.value, () => {});\n original.controller.abort();\n const replacement = await context();\n let updates = 0;\n const release = cards.subscribe(replacement.value, () => updates++);\n cleanup(); cleanup();\n cards.publish(store.add(token, 'Replacement still registered'));\n assert.equal(updates, 2);\n const broken = await context();\n let fail = false;\n cards.subscribe(broken.value, () => { if (fail) throw new Error('Intentional consumer failure'); });\n fail = true;\n cards.publish(store.add(token, 'Failure isolation'));\n assert.equal(updates, 3);\n const failedInitial = await context();\n assert.throws(() => cards.subscribe(failedInitial.value, () => { throw new Error('Initial callback failure'); }), /Initial/);\n const invalid = await context(); invalid.controller.abort();\n assert.throws(() => cards.subscribe(invalid.value, () => {}), /Sign in/);\n store.signOut(token);\n cards.publish('alice');\n assert.equal(updates, 3);\n assert.throws(() => cards.subscribe(replacement.value, () => {}), /Sign in/);\n release(); cards.publish('missing');\n});\n\ntest('incomplete HTTP uploads cannot keep shutdown or the database alive indefinitely', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-drain-'));\n const database = join(directory, 'drain.sqlite');\n let app;\n t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });\n assert.throws(() => createApp({ port: 0, database, sessionLifetimeMs: 0 }), /lifetime/);\n assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);\n assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);\n app = createApp({ port: 0, database });\n await once(app.server, 'listening');\n const socket = net.connect(app.server.address().port, '127.0.0.1');\n t.after(() => socket.destroy());\n socket.on('error', () => {});\n await once(socket, 'connect');\n socket.write('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: 1000\\r\\n\\r\\naccount=');\n await delay(30);\n const started = Date.now();\n await app.shutdown();\n assert.ok(Date.now() - started < 2000);\n const reopened = new DashboardStore(database); reopened.close();\n});\n\ntest('SQLite commits survive abrupt process termination rather than only graceful shutdown', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-crash-'));\n const database = join(directory, 'crash.sqlite');\n const store = new DashboardStore(database);\n store.provision('alice', await credentials(password));\n const token = store.issue('alice', 60000); store.close();\n const child = spawn(process.execPath, ['-e', `\n const { DashboardStore } = require('./dist/store');\n const db = new DashboardStore(process.argv[1]);\n db.add(process.argv[2], 'Committed before crash');\n process.send('committed');\n setInterval(() => {}, 1000);\n `, database, token], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });\n t.after(async () => {\n if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited; }\n rmSync(directory, { recursive: true, force: true });\n });\n assert.deepEqual(await once(child, 'message'), ['committed', undefined]);\n const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited;\n const recovered = new DashboardStore(database);\n try { assert.equal(recovered.list(token)[0].title, 'Committed before crash'); }\n finally { recovered.close(); }\n});\n\ntest('production origin/cookies and malformed forms use real HTTP', async t => {\n const { origin } = await fixture(t, { origin: 'https://dashboard.example' });\n const authenticated = await post(origin, '/login', { account: 'alice', password }, '', 'https://dashboard.example');\n assert.equal(authenticated.status, 303);\n assert.match(authenticated.headers.get('set-cookie'), /; Secure/);\n assert.equal((await post(origin, '/login', { account: 'alice', password: 'x'.repeat(5000) })).status, 400);\n assert.equal((await post(origin, '/logout', {}, '', 'https://dashboard.example')).status, 303);\n assert.equal((await post(origin, '/login', {})).status, 403);\n});\n\ntest('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));\n const database = join(directory, 'cards.sqlite');\n const app = createApp({ port: 0, database });\n t.after(async () => {\n // This test deliberately makes the returned cleanup promise reject.\n // Await settlement before removing files, including on assertion failure.\n await Promise.allSettled([app.shutdown()]);\n rmSync(directory, { recursive: true, force: true });\n });\n await once(app.server, 'listening');\n const failure = new Error('Injected database cleanup failure');\n const close = DashboardStore.prototype.close;\n // Unit-only fault injection, not a claim of a naturally occurring SQLite\n // failure. Real database/socket cleanup still runs; network ITs use no mocks.\n const injected = t.mock.method(DashboardStore.prototype, 'close', function () {\n close.call(this);\n throw failure;\n });\n app.server.emit('error', new Error('Injected listener failure'));\n const closing = app.shutdown();\n assert.equal(app.shutdown(), closing);\n await assert.rejects(closing, error => error === failure);\n assert.equal(injected.mock.callCount(), 1);\n assert.equal(app.server.listening, false);\n injected.mock.restore();\n const reopened = new DashboardStore(database);\n reopened.close();\n});\n\ntest('invalid-form middleware leaves an already destroyed native HTTP response untouched', async t => {\n const { origin, app } = await fixture(t);\n const handled = new Promise(resolve => app.server.once('request', (request, response) => resolve({ request, response })));\n const page = await fetch(`${origin}/login`);\n assert.equal(page.status, 200);\n await page.text();\n const { request, response } = await handled;\n // Unit-test the defensive state with genuine Express objects. This is not\n // a claim that an aborted upload naturally reaches this middleware branch.\n const handlers = app.server.listeners('request').flatMap(listener => listener._router?.stack ?? [])\n .filter(layer => layer.handle.name === 'invalidBody');\n assert.equal(handlers.length, 1);\n response.destroy();\n assert.equal(response.destroyed, true);\n const before = { status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded };\n handlers[0].handle(new Error('Invalid form after disconnect'), request, response, () => assert.fail('must not forward'));\n assert.deepEqual({ status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded }, before);\n});\n\ntest('capacity failures and abandoned uploads remain contained over real HTTP', { timeout: 10000 }, async t => {\n const { origin, database } = await fixture(t);\n const store = new DashboardStore(database);\n try { for (let index = 0; index < 32; index++) store.issue('alice', 60000); }\n finally { store.close(); }\n const response = await post(origin, '/login', { account: 'alice', password });\n assert.equal(response.status, 503);\n assert.equal(await response.text(), 'Unable to complete the request. Try again later.');\n const socket = net.connect(Number(new URL(origin).port), '127.0.0.1');\n socket.on('error', () => {});\n t.after(() => socket.destroy());\n await once(socket, 'connect');\n const closed = new Promise(resolve => socket.once('close', resolve));\n socket.end('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: 100\\r\\n\\r\\naccount=alice');\n socket.resume();\n await closed;\n const reset = net.connect(Number(new URL(origin).port), '127.0.0.1');\n reset.on('error', () => {});\n t.after(() => reset.destroy());\n await once(reset, 'connect');\n const resetClosed = new Promise(resolve => reset.once('close', resolve));\n reset.write('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Encoding: gzip\\r\\nContent-Length: 100\\r\\n\\r\\n');\n await delay(20);\n reset.resetAndDestroy();\n await resetClosed;\n assert.equal((await fetch(`${origin}/login`)).status, 200);\n});\n\ntest('real administrator and standalone startup commands expose errors and persist accounts', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cli-'));\n const database = join(directory, 'cli.sqlite');\n let app;\n let child;\n t.after(async () => {\n if (child && child.exitCode === null && child.signalCode === null) { const exit = once(child, 'exit'); child.kill(); await exit; }\n await app?.shutdown();\n rmSync(directory, { recursive: true, force: true });\n });\n const env = { ...process.env, DASHBOARD_DATABASE: database, PORT: '0', NODE_ENV: 'test' };\n delete env.DASHBOARD_ORIGIN;\n const run = (file, args = [], overrides = {}) => spawnSync(process.execPath, [file, ...args], {\n env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000, windowsHide: true,\n });\n assert.equal(run('dist/admin.js').status, 1);\n assert.equal(run('dist/admin.js', ['?', 'extra']).status, 1);\n const created = run('dist/admin.js', ['carol']);\n assert.equal(created.status, 0); // Never include stdout (a generated password) in diagnostic output.\n assert.ok(created.stdout.startsWith('Created carol.'));\n assert.equal(run('dist/admin.js', ['carol']).status, 1);\n assert.equal(run('dist/app.js', [], { NODE_ENV: 'production' }).status, 1);\n assert.equal(run('dist/app.js', [], { NODE_ENV: 'production', DASHBOARD_ORIGIN: 'http://example.com' }).status, 1);\n const store = new DashboardStore(database);\n try { assert.ok(store.credentials('carol')); }\n finally { store.close(); }\n app = createApp({ database, port: 0 });\n await once(app.server, 'listening');\n const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });\n assert.equal(unavailable.status, 1);\n assert.match(unavailable.stderr, /Application listener failed/);\n // Windows kill('SIGTERM') terminates immediately without invoking Node handlers.\n // An actual IPC message delivers the signal event there; Unix uses its OS signal.\n const signalControl = join(directory, 'signal.cjs');\n writeFileSync(signalControl, \"process.once('message', () => { process.disconnect(); process.emit('SIGTERM'); });\");\n for (const configured of [false, true]) {\n const args = [...(process.platform === 'win32' ? ['--require', signalControl] : []), 'dist/app.js'];\n child = spawn(process.execPath, args, { env: { ...env, ...(configured ? { DASHBOARD_ORIGIN: 'https://dashboard.example', NODE_ENV: 'production' } : {}) },\n stdio: ['ignore', 'pipe', 'pipe', ...(process.platform === 'win32' ? ['ipc'] : [])], windowsHide: true });\n let output = '', errors = '';\n child.stdout.on('data', chunk => { output += chunk; });\n child.stderr.on('data', chunk => { errors += chunk; });\n const deadline = Date.now() + 5000;\n while (!output.includes('/login') && Date.now() < deadline && child.exitCode === null) await delay(20);\n assert.match(output, configured ? /Dashboard: https:\\/\\/dashboard.example\\/login/ : /Dashboard: http:\\/\\/127\\.0\\.0\\.1:\\d+\\/login/);\n if (!configured) assert.equal((await fetch(output.match(/http:\\/\\/127\\.0\\.0\\.1:\\d+\\/login/)[0])).status, 200);\n const exit = once(child, 'exit');\n if (process.platform === 'win32') child.send('stop');\n else child.kill('SIGTERM');\n const [code, signal] = await exit;\n assert.equal(code, 0, errors);\n assert.equal(signal, null);\n }\n});\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n# Persistent private dashboard\n\nThis 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.\n\n## Run it\n\nRequires **Node 22.13 or newer** (native `node:sqlite`, experimental in Node 22) and npm. Other Redweb starters retain their own Node requirements. Use a supported Node release in production.\n\nAfter installing dependencies, create your account:\n\n```sh\nnpm run add-user -- alice\nnpm test\nnpm run dev\n```\n\nThe provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.\n\n`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.\n\n`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.\n\n## Where the behavior lives\n\n- `app.tsx`: composition, login page, protected dashboard, listener and shutdown.\n- `cards.tsx`: reusable `Cards` component and account-scoped live subscriptions. Normal TSX expressions update automatically. Forms call typed actions; feedback requires no browser glue.\n- `store.ts`: prepared SQL, bounded cards/sessions, owner-filtered operations and synchronous transactions.\n- `auth.ts`: asynchronous scrypt, bounded login attempts, hashed session tokens, cookies and sign-out.\n- `admin.ts`: explicit local account provisioning.\n\n## Production boundaries\n\nSet `DASHBOARD_DATABASE` to a writable persistent file path (default `data/dashboard.sqlite`). Protect the directory with OS permissions: the database contains password hashes, private card text, and session metadata. It, its WAL/SHM files, and backups must never be served as public assets or committed. Stop the process cleanly before copying the database for a backup, or use a proper SQLite online backup facility; copying only the main file during live WAL writes is not a backup plan.\n\nSet `NODE_ENV=production` and `DASHBOARD_ORIGIN=https://your-domain.example` behind an HTTPS reverse proxy. The origin must have no path or trailing slash. This enables Secure cookies; all session cookies are HttpOnly and SameSite=Strict. Both login/logout forms and socket upgrades require the exact trusted origin. The application does not trust Host or forwarded headers to establish origin or identity. The HTTP listener must not be publicly reachable around your TLS proxy.\n\nProvision accounts on the same persistent volume before serving requests. Passwords use salted scrypt; only hashes of random session tokens are stored. Default sessions last one hour. Up to 32 unexpired sessions and 100 cards per account are supported. Login work is limited to four simultaneous checks and ten attempts per minute per direct peer IP, with at most 1,024 tracked IPs; clients behind one proxy share its bucket. Add appropriate proxy-level abuse controls for an Internet deployment. There is no registration, password reset, MFA, or account recovery; integrate a dedicated identity provider if your product needs those features.\n\nSQL checks the current session and card owner inside each write transaction. Private subscriptions recheck session validity before publishing and close at expiry. Sign-out invalidates credentials before revoking Redweb sessions. An expired or disconnected page may need a reload/sign-in; the recipe does not silently retry actions with uncertain outcomes.\n\nThis is a **single-process live-update model**. SQLite transactions are synchronous and kept small; this is not a claim of unlimited concurrency. Do not put multiple app workers behind a load balancer and expect cross-worker notifications or revocation. Add a deliberate shared notification/session-revocation design before scaling horizontally. Static export cannot include protected dashboards.\n\nBuild and deploy using the shared instructions above, including the persistent data volume and the environment settings here. Neither `npm run dev` nor a process restart should erase durable cards. Redweb itself does not depend on SQLite.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n\n### .npmrc\n\n```text\nengine-strict=true\n```\n\n### test/rate-window.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { DashboardStore } = require('../dist/store');\nconst { DashboardAuth, credentials } = require('../dist/auth');\n\ntest('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {\n const store = new DashboardStore(':memory:');\n const auth = new DashboardAuth(store);\n t.after(() => { auth.close(); store.close(); });\n const password = 'test-only-login-window-password';\n store.provision('alice', await credentials(password));\n for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', password), undefined);\n assert.equal(await auth.login('same-peer', 'alice', password), undefined);\n await new Promise(resolve => setTimeout(resolve, 60010));\n const token = await auth.login('same-peer', 'alice', password);\n assert.equal(store.session(token).account, 'alice');\n});\n```\n\n### src/store.ts\n\n```ts\nimport { createHash, randomBytes, randomUUID } from 'node:crypto';\nimport { DatabaseSync } from 'node:sqlite';\n\nexport interface Card { id: string; title: string; }\nexport interface Session { account: string; expires: number; }\nexport interface Credentials { salt: string; hash: string; }\nexport const MAX_CARDS = 100;\nexport const USERNAME = /^[a-z][a-z0-9_-]{2,31}$/;\nconst digest = (token: string) => createHash('sha256').update(token).digest('hex');\n\n/** Recipe-local persistence. Every private query derives its owner from a live session. */\nexport class DashboardStore {\n private readonly db: DatabaseSync;\n private closed = false;\n\n constructor(filename: string) {\n this.db = new DatabaseSync(filename);\n try {\n this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 1000;');\n const version = this.db.prepare('PRAGMA user_version').get()!.user_version;\n if (version !== 0 && version !== 1) throw new Error('Unsupported dashboard database version.');\n this.db.exec(`\n BEGIN IMMEDIATE;\n CREATE TABLE IF NOT EXISTS accounts (\n id TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, epoch INTEGER NOT NULL DEFAULT 0\n ) STRICT;\n CREATE TABLE IF NOT EXISTS sessions (\n token TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n expires INTEGER NOT NULL\n ) STRICT;\n CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(account);\n CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);\n CREATE TABLE IF NOT EXISTS cards (\n id TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 80)\n ) STRICT;\n CREATE INDEX IF NOT EXISTS cards_owner ON cards(account);\n PRAGMA user_version = 1;\n COMMIT;\n `);\n } catch (error) { this.db.close(); throw error; }\n }\n\n provision(account: string, credentials: Credentials) {\n if (!USERNAME.test(account) || !/^[a-f0-9]{32}$/.test(credentials.salt) || !/^[a-f0-9]{128}$/.test(credentials.hash)) {\n throw new TypeError('Invalid account credentials.');\n }\n this.db.prepare('INSERT INTO accounts(id, salt, hash) VALUES (?, ?, ?)').run(account, credentials.salt, credentials.hash);\n }\n\n credentials(account: string): (Credentials & { epoch: number }) | undefined {\n return this.db.prepare('SELECT salt, hash, epoch FROM accounts WHERE id = ?').get(account) as unknown as (Credentials & { epoch: number }) | undefined;\n }\n\n issue(account: string, ttlMs: number, expectedEpoch?: number): string {\n if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Session lifetime must be 100ms–24h.');\n return this.transaction(() => {\n if (expectedEpoch !== undefined && this.credentials(account)?.epoch !== expectedEpoch) throw new Error('Sign-out occurred during sign-in. Try again.');\n this.db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());\n const count = this.db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE account = ?').get(account)!.count as number;\n if (count >= 32) throw new Error('Sign out existing sessions before signing in again.');\n const token = randomBytes(32).toString('base64url');\n this.db.prepare('INSERT INTO sessions(token, account, expires) VALUES (?, ?, ?)').run(digest(token), account, Date.now() + ttlMs);\n return token;\n });\n }\n\n session(token: string): Session | undefined {\n if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return undefined;\n return this.db.prepare('SELECT account, expires FROM sessions WHERE token = ? AND expires > ?').get(digest(token), Date.now()) as unknown as Session | undefined;\n }\n\n list(token: string): Card[] {\n const { account } = this.requireSession(token);\n return this.db.prepare('SELECT id, title FROM cards WHERE account = ? ORDER BY rowid').all(account) as unknown as Card[];\n }\n\n add(token: string, title: string): string {\n if (typeof title !== 'string' || !title.trim() || title.length > 80 || /[\\p{Cc}\\p{Cf}]/u.test(title)) throw new TypeError('Invalid card title.');\n return this.transaction(() => {\n const { account } = this.requireSession(token);\n const count = this.db.prepare('SELECT COUNT(*) AS count FROM cards WHERE account = ?').get(account)!.count as number;\n if (count >= MAX_CARDS) throw new Error('Card limit reached.');\n this.db.prepare('INSERT INTO cards(id, account, title) VALUES (?, ?, ?)').run(randomUUID(), account, title.trim());\n return account;\n });\n }\n\n remove(token: string, id: string): string {\n return this.transaction(() => {\n const { account } = this.requireSession(token);\n this.db.prepare('DELETE FROM cards WHERE id = ? AND account = ?').run(id, account);\n return account;\n });\n }\n\n signOut(token: string): string | undefined {\n return this.transaction(() => {\n const session = this.session(token);\n if (!session) return undefined;\n this.db.prepare('DELETE FROM sessions WHERE account = ?').run(session.account);\n this.db.prepare('UPDATE accounts SET epoch = epoch + 1 WHERE id = ?').run(session.account);\n return session.account;\n });\n }\n\n close() { if (!this.closed) { this.closed = true; this.db.close(); } }\n\n private requireSession(token: string): Session {\n const session = this.session(token);\n if (!session) throw new Error('Session expired. Sign in again.');\n return session;\n }\n\n private transaction<T>(operation: () => T): T {\n this.db.exec('BEGIN IMMEDIATE');\n try { const result = operation(); this.db.exec('COMMIT'); return result; }\n catch (error) { this.db.exec('ROLLBACK'); throw error; }\n }\n}\n```\n\n### src/auth.ts\n\n```ts\nimport { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';\nimport { promisify } from 'node:util';\nimport type { Application, Request, Response } from 'express';\nimport { DashboardStore, USERNAME, type Credentials } from './store';\n\nconst COOKIE = 'redweb_dashboard';\nconst DUMMY: Credentials = { salt: '00'.repeat(16), hash: '00'.repeat(64) };\n\nexport function sessionToken(cookie: string | undefined): string {\n const matches = (cookie ?? '').split(';').map(part => part.trim()).filter(part => part.startsWith(`${COOKIE}=`));\n const token = matches.length === 1 ? matches[0].slice(COOKIE.length + 1) : '';\n return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : '';\n}\n\nconst passwordHash = promisify(scrypt);\n\nexport async function credentials(password: string): Promise<Credentials> {\n if (typeof password !== 'string' || password.length < 16 || password.length > 128) throw new TypeError('Use a password of 16–128 characters.');\n const salt = randomBytes(16).toString('hex');\n return { salt, hash: (await passwordHash(password, salt, 64) as Buffer).toString('hex') };\n}\n\n/** Bounded asynchronous password work; neither proxy headers nor browser input establish identity. */\nexport class DashboardAuth {\n private active = 0;\n private closed = false;\n private readonly attempts = new Map<string, { count: number; expires: number }>();\n\n constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {\n if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');\n }\n\n async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {\n if (this.closed) return undefined;\n const now = Date.now();\n for (const [key, entry] of this.attempts) if (entry.expires <= now) this.attempts.delete(key);\n let attempt = this.attempts.get(ip);\n if (!attempt) {\n if (this.attempts.size >= 1024) return undefined;\n attempt = { count: 0, expires: now + 60000 };\n this.attempts.set(ip, attempt);\n }\n if (++attempt.count > 10 || this.active >= 4) return undefined;\n if (typeof account !== 'string' || !USERNAME.test(account) || typeof password !== 'string' || password.length < 16 || password.length > 128) return undefined;\n this.active++;\n try {\n const stored = this.store.credentials(account);\n const expected = stored ?? DUMMY;\n const actual = await passwordHash(password, expected.salt, 64) as Buffer;\n if (this.closed) return undefined;\n if (!timingSafeEqual(actual, Buffer.from(expected.hash, 'hex')) || !stored) return undefined;\n return this.store.issue(account, this.ttlMs, stored.epoch);\n } finally { this.active--; }\n }\n\n close() { this.closed = true; this.attempts.clear(); }\n\n mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {\n const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;\n const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {\n app.post(route, (request, response) => {\n response.set('Cache-Control', 'private, no-store');\n if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }\n void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));\n });\n };\n post('/login', async (request, response) => {\n const token = await this.login(String(request.socket.remoteAddress), request.body?.account, request.body?.password);\n if (!token) { response.status(401).send('Unable to sign in. Check your credentials or try again later.'); return; }\n response.setHeader('Set-Cookie', cookie(token));\n response.redirect(303, '/');\n });\n post('/logout', async (request, response) => {\n const account = this.store.signOut(sessionToken(request.headers.cookie));\n response.setHeader('Set-Cookie', cookie(''));\n if (account) await revoke(account);\n response.redirect(303, '/login');\n });\n }\n}\n```\n\n### src/cards.tsx\n\n```tsx\nimport { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';\nimport { z } from 'zod';\nimport { sessionToken } from './auth';\nimport { DashboardStore, MAX_CARDS, type Card } from './store';\n\nconst addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\\p{Cc}\\p{Cf}]+$/u) }).strict();\nconst removeInput = z.object({ id: z.string().uuid() }).strict();\nconst tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));\n\ninterface Subscriber { token: string; update(): void; close(): void; }\n\n/** Single-process notifications; SQLite remains the source of truth on every connection. */\nexport class PrivateCards {\n private readonly accounts = new Map<string, Set<Subscriber>>();\n constructor(readonly store: DashboardStore) {}\n\n allowed(context: LivePageRequestContext) {\n const session = this.store.session(tokenOf(context));\n return !!session && session.account === context.principal && !context.signal.aborted;\n }\n\n subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {\n const token = tokenOf(context);\n const session = this.store.session(token);\n if (!session || !this.allowed(context)) throw new Error('Sign in again.');\n let group = this.accounts.get(session.account);\n if (!group) this.accounts.set(session.account, group = new Set());\n let closed = false;\n const subscriber: Subscriber = {\n token, update: () => update(this.store.list(token)),\n close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },\n };\n const unsubscribe = () => {\n if (closed) return;\n closed = true;\n clearTimeout(expiry);\n context.signal.removeEventListener('abort', unsubscribe);\n group.delete(subscriber);\n if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);\n };\n const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));\n expiry.unref();\n group.add(subscriber);\n context.signal.addEventListener('abort', unsubscribe, { once: true });\n try { subscriber.update(); }\n catch (error) { unsubscribe(); throw error; }\n return unsubscribe;\n }\n\n publish(account: string) {\n for (const subscriber of this.accounts.get(account) ?? []) {\n try {\n if (this.store.session(subscriber.token)?.account === account) subscriber.update();\n else subscriber.close();\n } catch { subscriber.close(); }\n }\n }\n}\n\n@component()\nexport class Cards {\n @state() items: Card[] = [];\n private unsubscribe?: () => void;\n\n constructor(private readonly cards: PrivateCards) {}\n loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }\n connected(context: LivePageConnectionContext) {\n this.disconnected();\n this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });\n }\n disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }\n disposed() { this.disconnected(); }\n\n @action({ input: addInput })\n add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.add(tokenOf(context), title));\n }\n\n @action({ input: removeInput })\n remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.remove(tokenOf(context), id));\n }\n\n render() {\n return <section class=\"cards\" aria-label=\"Your saved cards\">\n <form rw-submit=\"add\">\n <label for=\"card-title\">New card</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"80\" required autocomplete=\"off\" />\n <button type=\"submit\" disabled={this.items.length >= MAX_CARDS}>Add card</button>\n </form>\n <p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>\n <ul class=\"card-grid\">{this.items.map(card => <li key={card.id} data-card-id={card.id}>\n <h2>{card.title}</h2>\n <form rw-submit=\"remove\">\n <input type=\"hidden\" name=\"id\" value={card.id} />\n <button type=\"submit\" aria-label={`Delete ${card.title}`}>Delete</button>\n </form>\n </li>)}</ul>\n {!this.items.length && <p>No cards yet. Add your first one above.</p>}\n </section>;\n }\n}\n```\n\n### src/admin.ts\n\n```ts\nimport { randomBytes } from 'node:crypto';\nimport { mkdirSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport { credentials } from './auth';\nimport { databasePath } from './app';\nimport { DashboardStore, USERNAME } from './store';\n\nasync function main() {\n const account = process.argv[2];\n if (!account || !USERNAME.test(account) || process.argv.length !== 3) throw new Error('Usage: npm run add-user -- alice (3–32 lowercase letters, digits, _ or -; starts with a letter).');\n const filename = databasePath();\n mkdirSync(dirname(filename), { recursive: true });\n const store = new DashboardStore(filename);\n try {\n const password = randomBytes(24).toString('base64url');\n store.provision(account, await credentials(password));\n console.log(`Created ${account}. Store this password safely; it is displayed only once:\\n${password}`);\n } finally { store.close(); }\n}\n\nvoid main().catch(error => { console.error(error.message); process.exitCode = 1; });\n```\n",
|
|
441
|
+
"files": [
|
|
442
|
+
{
|
|
443
|
+
"path": "package.json",
|
|
444
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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 test/rate-window.test.cjs\",\n \"add-user\": \"npm run build && node dist/admin.js\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\",\n \"zod\": \"^4.4.3\",\n \"express\": \"^4.22.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\",\n \"@types/node\": \"^22.20.1\",\n \"@types/express\": \"^4.17.21\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n },\n \"engines\": {\n \"node\": \">=22.13.0\"\n }\n}\n"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"path": "tsconfig.json",
|
|
448
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
"path": "src/app.tsx",
|
|
452
|
+
"content": "import express, { type ErrorRequestHandler } from 'express';\nimport { mkdirSync } from 'node:fs';\nimport { dirname, resolve } from 'node:path';\nimport { page, start, type LivePageRequestContext } from 'redweb';\nimport { DashboardAuth, sessionToken } from './auth';\nimport { Cards, PrivateCards } from './cards';\nimport { DashboardStore } from './store';\nimport { runApp } from './run-app';\n\nexport interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; }\n\nexport function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }\n\nexport function createApp(options: DashboardOptions = {}) {\n const port = options.port ?? Number(process.env.PORT ?? 8181);\n const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;\n if (configuredOrigin && (!/^https?:$/.test(new URL(configuredOrigin).protocol) || new URL(configuredOrigin).origin !== configuredOrigin)) {\n throw new Error('DASHBOARD_ORIGIN must be an exact HTTP(S) origin without a path.');\n }\n if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');\n const filename = options.database ?? databasePath();\n mkdirSync(dirname(filename), { recursive: true });\n const store = new DashboardStore(filename);\n try {\n const cards = new PrivateCards(store);\n const auth = new DashboardAuth(store, options.sessionLifetimeMs);\n const app = express();\n app.disable('x-powered-by');\n app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));\n const invalidBody: ErrorRequestHandler = (_error, _request, response, _next) => {\n if (!response.destroyed) response.status(400).send('Invalid form submission.');\n };\n app.use(invalidBody);\n const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;\n\n @page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })\n class Login {\n render() {\n return <main class=\"home\"><h1>Your private workspace</h1>\n <p>Sign in with the credentials created by your administrator.</p>\n <form method=\"post\" action=\"/login\">\n <label for=\"account\">Account</label><input id=\"account\" name=\"account\" autocomplete=\"username\" required />\n <label for=\"password\">Password</label><input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"submit\">Sign in</button>\n </form>\n </main>;\n }\n }\n\n @page('/', { css: 'app.css', authorize: context => cards.allowed(context), head: { title: 'Your cards' } })\n class Dashboard {\n private readonly workspace = new Cards(cards);\n render(context: LivePageRequestContext) {\n return <main class=\"home\"><header><div><h1>Your cards</h1><p>Signed in as {context.principal}</p></div>\n <form method=\"post\" action=\"/logout\"><button type=\"submit\">Sign out all sessions</button></form>\n </header>{this.workspace}<p>Open another tab to see your changes instantly.</p></main>;\n }\n }\n\n auth.mount(app, origin, account => server.revoke(account));\n const server = start([Login, Dashboard], {\n server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,\n origins: value => value === origin(),\n authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'\n ? true : store.session(sessionToken(request.headers.cookie))?.account,\n });\n let closing: Promise<void> | undefined;\n const shutdown = () => {\n auth.close();\n if (!closing) {\n closing = server.shutdown().finally(() => store.close());\n }\n return closing;\n };\n server.server.once('error', () => { void shutdown().catch(() => {}); });\n return {\n server: server.server,\n shutdown,\n };\n } catch (error) { store.close(); throw error; }\n}\n\nif (require.main === module) {\n const app = runApp(createApp);\n app?.server.once('listening', () => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(app.server.address() as { port: number }).port}`}/login`));\n}\n"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"path": "src/run-app.ts",
|
|
456
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"path": "src/app.css",
|
|
460
|
+
"content": ":root { font-family: system-ui, sans-serif; color: #e8edf5; background: #111827; color-scheme: dark; }\n* { box-sizing: border-box; }\nbody { margin: 0; }\n.home { width: min(64rem, 100%); margin: 3rem auto; padding: 0 1.5rem; }\nheader { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }\nform { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; }\ninput, button { font: inherit; border: 1px solid #526179; border-radius: .5rem; padding: .75rem; }\ninput { background: #1f2937; max-width: 100%; }\nbutton { background: #a7f3d0; color: #102c23; cursor: pointer; }\nbutton:disabled { opacity: .5; cursor: default; }\n:focus-visible { outline: 3px solid #60a5fa; outline-offset: 3px; }\n.cards { margin-top: 2rem; }\n.card-grid { padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; }\n.card-grid li { border: 1px solid #526179; border-radius: .75rem; padding: 1.25rem; overflow-wrap: anywhere; }\nh2 { font-size: 1.25rem; }\n[role=\"alert\"] { color: #fca5a5; }\n"
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"path": "scripts/copy-assets.cjs",
|
|
464
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"path": "test/network.cjs",
|
|
468
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"path": "test/app.test.cjs",
|
|
472
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { once } = require('node:events');\nconst { mkdtempSync, rmSync, writeFileSync } = require('node:fs');\nconst { tmpdir } = require('node:os');\nconst { join } = require('node:path');\nconst { DatabaseSync } = require('node:sqlite');\nconst { spawn, spawnSync } = require('node:child_process');\nconst net = require('node:net');\nconst { WebSocketServer, WebSocket } = require('ws');\nconst { createApp, databasePath } = require('../dist/app');\nconst { DashboardStore } = require('../dist/store');\nconst { DashboardAuth, credentials, sessionToken } = require('../dist/auth');\nconst { PrivateCards } = require('../dist/cards');\nconst { live, connect } = require('./network.cjs');\n\nconst password = 'test-only-correct-password';\nconst delay = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nasync function fixture(t, options = {}) {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-private-cards-'));\n const database = join(directory, 'cards.sqlite');\n const store = new DashboardStore(database);\n const secret = await credentials(password);\n store.provision('alice', secret);\n store.provision('bob', secret);\n store.close();\n let app;\n t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });\n async function restart() {\n await app?.shutdown();\n app = createApp({ port: 0, database, ...options });\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n }\n return { database, restart, origin: await restart(), get app() { return app; } };\n}\n\nfunction post(origin, path, values, cookie, suppliedOrigin = origin) {\n return fetch(`${origin}${path}`, {\n method: 'POST', redirect: 'manual',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: suppliedOrigin, ...(cookie ? { Cookie: cookie } : {}) },\n body: new URLSearchParams(values),\n });\n}\n\nasync function login(origin, account = 'alice') {\n const response = await post(origin, '/login', { account, password });\n assert.equal(response.status, 303);\n const header = response.headers.get('set-cookie');\n assert.match(header, /HttpOnly; SameSite=Strict; Path=\\//);\n return header.split(';')[0];\n}\n\nasync function cardClient(t, origin, cookie) {\n const client = await live(t, origin, { Cookie: cookie });\n const component = client.document.match(/data-rw-component=\"([^\"]+)\"/)[1];\n await client.patch(patch => patch.id === 'root');\n const parseCards = html => [...html.matchAll(/data-card-id=\"([^\"]+)\"[^>]*><h2>([\\s\\S]*?)<\\/h2>/g)].map(match => ({\n id: match[1], title: match[2].replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '\"').replaceAll(''', \"'\").replaceAll('&', '&'),\n }));\n return {\n ...client, component,\n add: title => client.action('add', [{ title }], component),\n remove: id => client.action('remove', [{ id }], component),\n items: predicate => client.patch(patch => predicate(parseCards(patch.html))).then(message => parseCards(message.payload.patches.find(patch => predicate(parseCards(patch.html))).html)),\n };\n}\n\ntest('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {\n const fixtureApp = await fixture(t);\n let { origin } = fixtureApp;\n assert.equal((await fetch(`${origin}/login`)).status, 200);\n assert.equal((await fetch(origin)).status, 401);\n assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);\n assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);\n const alice = await login(origin);\n const alice2 = await login(origin);\n const bob = await login(origin, 'bob');\n const page = await fetch(origin, { headers: { Cookie: alice } });\n assert.match(page.headers.get('cache-control'), /private.*no-store/);\n assert.equal(page.headers.get('etag'), null);\n const first = await cardClient(t, origin, alice);\n const second = await cardClient(t, origin, alice2);\n const other = await cardClient(t, origin, bob);\n first.add('Saved <script>alert(1)</script>');\n const [items] = await Promise.all([first.items(value => value.length === 1), second.items(value => value.length === 1)]);\n assert.equal(items[0].title, 'Saved <script>alert(1)</script>');\n const db = new DashboardStore(fixtureApp.database);\n assert.deepEqual(db.list(sessionToken(bob)), []);\n other.remove(items[0].id);\n await delay(50);\n assert.equal(db.list(sessionToken(alice)).length, 1);\n first.action('add', [{ title: 'forged', account: 'bob' }], first.component);\n const invalid = await first.receive(message => message.type === 'error');\n assert.equal(invalid.error.code, 'ACTION_INVALID_INPUT');\n assert.equal(db.list(sessionToken(alice)).length, 1);\n const closed = once(first.socket, 'close'); first.socket.close(); await closed;\n second.add('While disconnected');\n await second.items(value => value.length === 2);\n const config = first.config;\n const reconnect = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, { Cookie: alice });\n await reconnect.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('While disconnected')));\n const deniedLogout = await post(origin, '/logout', {}, alice, 'https://foreign.example');\n assert.equal(deniedLogout.status, 403);\n const aliceClosed = once(reconnect.socket, 'close');\n const secondClosed = once(second.socket, 'close');\n const loggedOut = await post(origin, '/logout', {}, alice);\n assert.equal(loggedOut.status, 303);\n assert.match(loggedOut.headers.get('set-cookie'), /Max-Age=0/);\n await Promise.all([aliceClosed, secondClosed]);\n assert.equal((await fetch(origin, { headers: { Cookie: alice2 } })).status, 401);\n assert.equal(other.socket.readyState, 1);\n assert.equal(db.session(sessionToken(alice)), undefined);\n db.close();\n origin = await fixtureApp.restart();\n const renewed = await login(origin);\n const restored = await fetch(origin, { headers: { Cookie: renewed } });\n const text = await restored.text();\n assert.match(text, /While disconnected/);\n assert.match(text, /<script>/);\n const client = await cardClient(t, origin, renewed);\n client.remove(items[0].id);\n await client.items(value => value.length === 1);\n});\n\ntest('session expiry closes idle sockets and rejects later HTTP access', async t => {\n const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });\n const cookie = await login(origin);\n const client = await cardClient(t, origin, cookie);\n const [code] = await once(client.socket, 'close');\n assert.equal(code, 1008);\n assert.equal((await fetch(origin, { headers: { Cookie: cookie } })).status, 401);\n});\n\ntest('store and authentication units use actual SQLite and scrypt, never substitutes', async t => {\n const previousDatabase = process.env.DASHBOARD_DATABASE;\n const previousPort = process.env.PORT;\n try {\n delete process.env.DASHBOARD_DATABASE;\n delete process.env.PORT;\n assert.equal(databasePath(), require('node:path').resolve('data/dashboard.sqlite'));\n assert.throws(() => createApp({ origin: 'ftp://invalid.example' }), /exact/);\n } finally {\n if (previousDatabase === undefined) delete process.env.DASHBOARD_DATABASE; else process.env.DASHBOARD_DATABASE = previousDatabase;\n if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort;\n }\n const directory = mkdtempSync(join(tmpdir(), 'redweb-store-'));\n const database = join(directory, 'unit.sqlite');\n const store = new DashboardStore(database);\n t.after(() => { store.close(); rmSync(directory, { recursive: true, force: true }); });\n await assert.rejects(credentials('short'), /16/);\n const secret = await credentials(password);\n assert.throws(() => store.provision('?', secret), /Invalid/);\n store.provision('alice', secret);\n assert.throws(() => store.provision('alice', secret));\n assert.equal(store.credentials('missing'), undefined);\n assert.throws(() => store.issue('alice', 0));\n const auth = new DashboardAuth(store);\n assert.equal(await auth.login('peer', 'unknown', password), undefined);\n assert.equal(await auth.login('peer', {}, password), undefined);\n const token = await auth.login('peer', 'alice', password);\n assert.equal(store.session(token).account, 'alice');\n assert.equal(sessionToken(`redweb_dashboard=${token}`), token);\n assert.equal(sessionToken(`redweb_dashboard=${token}; redweb_dashboard=${token}`), '');\n assert.equal(sessionToken('redweb_dashboard=invalid'), '');\n assert.equal(store.session('invalid'), undefined);\n assert.throws(() => store.list('invalid'), /expired/);\n assert.throws(() => store.add(token, ''), /Invalid/);\n assert.throws(() => store.add(token, '\\u0000'), /Invalid/);\n for (let i = 0; i < 100; i++) store.add(token, `Card ${i}`);\n assert.throws(() => store.add(token, 'Over capacity'), /limit/);\n assert.equal(store.list(token).length, 100);\n store.remove(token, store.list(token)[0].id);\n assert.equal(store.list(token).length, 99);\n for (let i = 1; i < 32; i++) store.issue('alice', 10000);\n assert.throws(() => store.issue('alice', 10000), /existing sessions/);\n assert.equal(store.signOut(token), 'alice');\n assert.equal(store.signOut(token), undefined);\n assert.throws(() => store.remove(token, 'anything'), /expired/);\n for (let i = 0; i < 10; i++) await auth.login('limited', '!', password);\n assert.equal(await auth.login('limited', 'alice', password), undefined);\n store.close(); store.close();\n const raw = new DatabaseSync(database);\n raw.exec('PRAGMA user_version = 2'); raw.close();\n assert.throws(() => new DashboardStore(database), /Unsupported/);\n});\n\ntest('logout fences password checks in flight; close and admission bounds stop new sessions', async t => {\n const store = new DashboardStore(':memory:');\n t.after(() => store.close());\n store.provision('alice', await credentials(password));\n const auth = new DashboardAuth(store);\n const token = store.issue('alice', 5000);\n const pending = auth.login('peer', 'alice', password);\n store.signOut(token);\n await assert.rejects(pending, /Sign-out occurred/);\n const closing = auth.login('peer', 'alice', password);\n auth.close();\n assert.equal(await closing, undefined);\n assert.equal(await auth.login('peer', 'alice', password), undefined);\n assert.throws(() => new DashboardAuth(store, 0));\n const limited = new DashboardAuth(store);\n const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));\n assert.equal(await concurrent[4], undefined);\n const issued = await Promise.all(concurrent.slice(0, 4));\n assert.ok(issued.every(Boolean));\n for (let index = 0; index < 1024; index++) await limited.login(`invalid-${index}`, null, null);\n assert.equal(await limited.login('new-peer', 'alice', password), undefined);\n const expiring = store.issue('alice', 100);\n await delay(110);\n assert.equal(store.session(expiring), undefined);\n store.issue('alice', 1000); // Prunes expired rows during issuance.\n});\n\ntest('subscription cleanup is idempotent across replacement groups and failed callbacks', async t => {\n const store = new DashboardStore(':memory:');\n store.provision('alice', await credentials(password));\n const token = store.issue('alice', 5000);\n const cards = new PrivateCards(store);\n const sockets = new WebSocketServer({ port: 0, host: '127.0.0.1' });\n await once(sockets, 'listening');\n const peers = [];\n t.after(async () => {\n for (const peer of peers) peer.terminate();\n for (const peer of sockets.clients) peer.terminate();\n await new Promise(resolve => sockets.close(resolve)); store.close();\n });\n async function context() {\n const accepted = once(sockets, 'connection');\n const client = new WebSocket(`ws://127.0.0.1:${sockets.address().port}`); peers.push(client);\n const opened = once(client, 'open');\n const [socket] = await accepted;\n await opened;\n const controller = new AbortController();\n return { controller, value: { principal: 'alice', signal: controller.signal, socket, request: { get: name => name === 'cookie' ? `redweb_dashboard=${token}` : undefined } } };\n }\n const original = await context();\n const cleanup = cards.subscribe(original.value, () => {});\n original.controller.abort();\n const replacement = await context();\n let updates = 0;\n const release = cards.subscribe(replacement.value, () => updates++);\n cleanup(); cleanup();\n cards.publish(store.add(token, 'Replacement still registered'));\n assert.equal(updates, 2);\n const broken = await context();\n let fail = false;\n cards.subscribe(broken.value, () => { if (fail) throw new Error('Intentional consumer failure'); });\n fail = true;\n cards.publish(store.add(token, 'Failure isolation'));\n assert.equal(updates, 3);\n const failedInitial = await context();\n assert.throws(() => cards.subscribe(failedInitial.value, () => { throw new Error('Initial callback failure'); }), /Initial/);\n const invalid = await context(); invalid.controller.abort();\n assert.throws(() => cards.subscribe(invalid.value, () => {}), /Sign in/);\n store.signOut(token);\n cards.publish('alice');\n assert.equal(updates, 3);\n assert.throws(() => cards.subscribe(replacement.value, () => {}), /Sign in/);\n release(); cards.publish('missing');\n});\n\ntest('incomplete HTTP uploads cannot keep shutdown or the database alive indefinitely', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-drain-'));\n const database = join(directory, 'drain.sqlite');\n let app;\n t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });\n assert.throws(() => createApp({ port: 0, database, sessionLifetimeMs: 0 }), /lifetime/);\n assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);\n assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);\n app = createApp({ port: 0, database });\n await once(app.server, 'listening');\n const socket = net.connect(app.server.address().port, '127.0.0.1');\n t.after(() => socket.destroy());\n socket.on('error', () => {});\n await once(socket, 'connect');\n socket.write('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: 1000\\r\\n\\r\\naccount=');\n await delay(30);\n const started = Date.now();\n await app.shutdown();\n assert.ok(Date.now() - started < 2000);\n const reopened = new DashboardStore(database); reopened.close();\n});\n\ntest('SQLite commits survive abrupt process termination rather than only graceful shutdown', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-crash-'));\n const database = join(directory, 'crash.sqlite');\n const store = new DashboardStore(database);\n store.provision('alice', await credentials(password));\n const token = store.issue('alice', 60000); store.close();\n const child = spawn(process.execPath, ['-e', `\n const { DashboardStore } = require('./dist/store');\n const db = new DashboardStore(process.argv[1]);\n db.add(process.argv[2], 'Committed before crash');\n process.send('committed');\n setInterval(() => {}, 1000);\n `, database, token], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });\n t.after(async () => {\n if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited; }\n rmSync(directory, { recursive: true, force: true });\n });\n assert.deepEqual(await once(child, 'message'), ['committed', undefined]);\n const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited;\n const recovered = new DashboardStore(database);\n try { assert.equal(recovered.list(token)[0].title, 'Committed before crash'); }\n finally { recovered.close(); }\n});\n\ntest('production origin/cookies and malformed forms use real HTTP', async t => {\n const { origin } = await fixture(t, { origin: 'https://dashboard.example' });\n const authenticated = await post(origin, '/login', { account: 'alice', password }, '', 'https://dashboard.example');\n assert.equal(authenticated.status, 303);\n assert.match(authenticated.headers.get('set-cookie'), /; Secure/);\n assert.equal((await post(origin, '/login', { account: 'alice', password: 'x'.repeat(5000) })).status, 400);\n assert.equal((await post(origin, '/logout', {}, '', 'https://dashboard.example')).status, 303);\n assert.equal((await post(origin, '/login', {})).status, 403);\n});\n\ntest('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));\n const database = join(directory, 'cards.sqlite');\n const app = createApp({ port: 0, database });\n t.after(async () => {\n // This test deliberately makes the returned cleanup promise reject.\n // Await settlement before removing files, including on assertion failure.\n await Promise.allSettled([app.shutdown()]);\n rmSync(directory, { recursive: true, force: true });\n });\n await once(app.server, 'listening');\n const failure = new Error('Injected database cleanup failure');\n const close = DashboardStore.prototype.close;\n // Unit-only fault injection, not a claim of a naturally occurring SQLite\n // failure. Real database/socket cleanup still runs; network ITs use no mocks.\n const injected = t.mock.method(DashboardStore.prototype, 'close', function () {\n close.call(this);\n throw failure;\n });\n app.server.emit('error', new Error('Injected listener failure'));\n const closing = app.shutdown();\n assert.equal(app.shutdown(), closing);\n await assert.rejects(closing, error => error === failure);\n assert.equal(injected.mock.callCount(), 1);\n assert.equal(app.server.listening, false);\n injected.mock.restore();\n const reopened = new DashboardStore(database);\n reopened.close();\n});\n\ntest('invalid-form middleware leaves an already destroyed native HTTP response untouched', async t => {\n const { origin, app } = await fixture(t);\n const handled = new Promise(resolve => app.server.once('request', (request, response) => resolve({ request, response })));\n const page = await fetch(`${origin}/login`);\n assert.equal(page.status, 200);\n await page.text();\n const { request, response } = await handled;\n // Unit-test the defensive state with genuine Express objects. This is not\n // a claim that an aborted upload naturally reaches this middleware branch.\n const handlers = app.server.listeners('request').flatMap(listener => listener._router?.stack ?? [])\n .filter(layer => layer.handle.name === 'invalidBody');\n assert.equal(handlers.length, 1);\n response.destroy();\n assert.equal(response.destroyed, true);\n const before = { status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded };\n handlers[0].handle(new Error('Invalid form after disconnect'), request, response, () => assert.fail('must not forward'));\n assert.deepEqual({ status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded }, before);\n});\n\ntest('capacity failures and abandoned uploads remain contained over real HTTP', { timeout: 10000 }, async t => {\n const { origin, database } = await fixture(t);\n const store = new DashboardStore(database);\n try { for (let index = 0; index < 32; index++) store.issue('alice', 60000); }\n finally { store.close(); }\n const response = await post(origin, '/login', { account: 'alice', password });\n assert.equal(response.status, 503);\n assert.equal(await response.text(), 'Unable to complete the request. Try again later.');\n const socket = net.connect(Number(new URL(origin).port), '127.0.0.1');\n socket.on('error', () => {});\n t.after(() => socket.destroy());\n await once(socket, 'connect');\n const closed = new Promise(resolve => socket.once('close', resolve));\n socket.end('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: 100\\r\\n\\r\\naccount=alice');\n socket.resume();\n await closed;\n const reset = net.connect(Number(new URL(origin).port), '127.0.0.1');\n reset.on('error', () => {});\n t.after(() => reset.destroy());\n await once(reset, 'connect');\n const resetClosed = new Promise(resolve => reset.once('close', resolve));\n reset.write('POST /login HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Encoding: gzip\\r\\nContent-Length: 100\\r\\n\\r\\n');\n await delay(20);\n reset.resetAndDestroy();\n await resetClosed;\n assert.equal((await fetch(`${origin}/login`)).status, 200);\n});\n\ntest('real administrator and standalone startup commands expose errors and persist accounts', async t => {\n const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cli-'));\n const database = join(directory, 'cli.sqlite');\n let app;\n let child;\n t.after(async () => {\n if (child && child.exitCode === null && child.signalCode === null) { const exit = once(child, 'exit'); child.kill(); await exit; }\n await app?.shutdown();\n rmSync(directory, { recursive: true, force: true });\n });\n const env = { ...process.env, DASHBOARD_DATABASE: database, PORT: '0', NODE_ENV: 'test' };\n delete env.DASHBOARD_ORIGIN;\n const run = (file, args = [], overrides = {}) => spawnSync(process.execPath, [file, ...args], {\n env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000, windowsHide: true,\n });\n assert.equal(run('dist/admin.js').status, 1);\n assert.equal(run('dist/admin.js', ['?', 'extra']).status, 1);\n const created = run('dist/admin.js', ['carol']);\n assert.equal(created.status, 0); // Never include stdout (a generated password) in diagnostic output.\n assert.ok(created.stdout.startsWith('Created carol.'));\n assert.equal(run('dist/admin.js', ['carol']).status, 1);\n assert.equal(run('dist/app.js', [], { NODE_ENV: 'production' }).status, 1);\n assert.equal(run('dist/app.js', [], { NODE_ENV: 'production', DASHBOARD_ORIGIN: 'http://example.com' }).status, 1);\n const store = new DashboardStore(database);\n try { assert.ok(store.credentials('carol')); }\n finally { store.close(); }\n app = createApp({ database, port: 0 });\n await once(app.server, 'listening');\n const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });\n assert.equal(unavailable.status, 1);\n assert.match(unavailable.stderr, /Application listener failed/);\n // Windows kill('SIGTERM') terminates immediately without invoking Node handlers.\n // An actual IPC message delivers the signal event there; Unix uses its OS signal.\n const signalControl = join(directory, 'signal.cjs');\n writeFileSync(signalControl, \"process.once('message', () => { process.disconnect(); process.emit('SIGTERM'); });\");\n for (const configured of [false, true]) {\n const args = [...(process.platform === 'win32' ? ['--require', signalControl] : []), 'dist/app.js'];\n child = spawn(process.execPath, args, { env: { ...env, ...(configured ? { DASHBOARD_ORIGIN: 'https://dashboard.example', NODE_ENV: 'production' } : {}) },\n stdio: ['ignore', 'pipe', 'pipe', ...(process.platform === 'win32' ? ['ipc'] : [])], windowsHide: true });\n let output = '', errors = '';\n child.stdout.on('data', chunk => { output += chunk; });\n child.stderr.on('data', chunk => { errors += chunk; });\n const deadline = Date.now() + 5000;\n while (!output.includes('/login') && Date.now() < deadline && child.exitCode === null) await delay(20);\n assert.match(output, configured ? /Dashboard: https:\\/\\/dashboard.example\\/login/ : /Dashboard: http:\\/\\/127\\.0\\.0\\.1:\\d+\\/login/);\n if (!configured) assert.equal((await fetch(output.match(/http:\\/\\/127\\.0\\.0\\.1:\\d+\\/login/)[0])).status, 200);\n const exit = once(child, 'exit');\n if (process.platform === 'win32') child.send('stop');\n else child.kill('SIGTERM');\n const [code, signal] = await exit;\n assert.equal(code, 0, errors);\n assert.equal(signal, null);\n }\n});\n"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
"path": "test/run-app.test.cjs",
|
|
476
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
"path": "README.md",
|
|
480
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n# Persistent private dashboard\n\nThis 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.\n\n## Run it\n\nRequires **Node 22.13 or newer** (native `node:sqlite`, experimental in Node 22) and npm. Other Redweb starters retain their own Node requirements. Use a supported Node release in production.\n\nAfter installing dependencies, create your account:\n\n```sh\nnpm run add-user -- alice\nnpm test\nnpm run dev\n```\n\nThe provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.\n\n`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.\n\n`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.\n\n## Where the behavior lives\n\n- `app.tsx`: composition, login page, protected dashboard, listener and shutdown.\n- `cards.tsx`: reusable `Cards` component and account-scoped live subscriptions. Normal TSX expressions update automatically. Forms call typed actions; feedback requires no browser glue.\n- `store.ts`: prepared SQL, bounded cards/sessions, owner-filtered operations and synchronous transactions.\n- `auth.ts`: asynchronous scrypt, bounded login attempts, hashed session tokens, cookies and sign-out.\n- `admin.ts`: explicit local account provisioning.\n\n## Production boundaries\n\nSet `DASHBOARD_DATABASE` to a writable persistent file path (default `data/dashboard.sqlite`). Protect the directory with OS permissions: the database contains password hashes, private card text, and session metadata. It, its WAL/SHM files, and backups must never be served as public assets or committed. Stop the process cleanly before copying the database for a backup, or use a proper SQLite online backup facility; copying only the main file during live WAL writes is not a backup plan.\n\nSet `NODE_ENV=production` and `DASHBOARD_ORIGIN=https://your-domain.example` behind an HTTPS reverse proxy. The origin must have no path or trailing slash. This enables Secure cookies; all session cookies are HttpOnly and SameSite=Strict. Both login/logout forms and socket upgrades require the exact trusted origin. The application does not trust Host or forwarded headers to establish origin or identity. The HTTP listener must not be publicly reachable around your TLS proxy.\n\nProvision accounts on the same persistent volume before serving requests. Passwords use salted scrypt; only hashes of random session tokens are stored. Default sessions last one hour. Up to 32 unexpired sessions and 100 cards per account are supported. Login work is limited to four simultaneous checks and ten attempts per minute per direct peer IP, with at most 1,024 tracked IPs; clients behind one proxy share its bucket. Add appropriate proxy-level abuse controls for an Internet deployment. There is no registration, password reset, MFA, or account recovery; integrate a dedicated identity provider if your product needs those features.\n\nSQL checks the current session and card owner inside each write transaction. Private subscriptions recheck session validity before publishing and close at expiry. Sign-out invalidates credentials before revoking Redweb sessions. An expired or disconnected page may need a reload/sign-in; the recipe does not silently retry actions with uncertain outcomes.\n\nThis is a **single-process live-update model**. SQLite transactions are synchronous and kept small; this is not a claim of unlimited concurrency. Do not put multiple app workers behind a load balancer and expect cross-worker notifications or revocation. Add a deliberate shared notification/session-revocation design before scaling horizontally. Static export cannot include protected dashboards.\n\nBuild and deploy using the shared instructions above, including the persistent data volume and the environment settings here. Neither `npm run dev` nor a process restart should erase durable cards. Redweb itself does not depend on SQLite.\n"
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"path": ".gitignore",
|
|
484
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"path": ".npmrc",
|
|
488
|
+
"content": "engine-strict=true\n"
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
"path": "test/rate-window.test.cjs",
|
|
492
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { DashboardStore } = require('../dist/store');\nconst { DashboardAuth, credentials } = require('../dist/auth');\n\ntest('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {\n const store = new DashboardStore(':memory:');\n const auth = new DashboardAuth(store);\n t.after(() => { auth.close(); store.close(); });\n const password = 'test-only-login-window-password';\n store.provision('alice', await credentials(password));\n for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', password), undefined);\n assert.equal(await auth.login('same-peer', 'alice', password), undefined);\n await new Promise(resolve => setTimeout(resolve, 60010));\n const token = await auth.login('same-peer', 'alice', password);\n assert.equal(store.session(token).account, 'alice');\n});\n"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
"path": "src/store.ts",
|
|
496
|
+
"content": "import { createHash, randomBytes, randomUUID } from 'node:crypto';\nimport { DatabaseSync } from 'node:sqlite';\n\nexport interface Card { id: string; title: string; }\nexport interface Session { account: string; expires: number; }\nexport interface Credentials { salt: string; hash: string; }\nexport const MAX_CARDS = 100;\nexport const USERNAME = /^[a-z][a-z0-9_-]{2,31}$/;\nconst digest = (token: string) => createHash('sha256').update(token).digest('hex');\n\n/** Recipe-local persistence. Every private query derives its owner from a live session. */\nexport class DashboardStore {\n private readonly db: DatabaseSync;\n private closed = false;\n\n constructor(filename: string) {\n this.db = new DatabaseSync(filename);\n try {\n this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 1000;');\n const version = this.db.prepare('PRAGMA user_version').get()!.user_version;\n if (version !== 0 && version !== 1) throw new Error('Unsupported dashboard database version.');\n this.db.exec(`\n BEGIN IMMEDIATE;\n CREATE TABLE IF NOT EXISTS accounts (\n id TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, epoch INTEGER NOT NULL DEFAULT 0\n ) STRICT;\n CREATE TABLE IF NOT EXISTS sessions (\n token TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n expires INTEGER NOT NULL\n ) STRICT;\n CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(account);\n CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);\n CREATE TABLE IF NOT EXISTS cards (\n id TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 80)\n ) STRICT;\n CREATE INDEX IF NOT EXISTS cards_owner ON cards(account);\n PRAGMA user_version = 1;\n COMMIT;\n `);\n } catch (error) { this.db.close(); throw error; }\n }\n\n provision(account: string, credentials: Credentials) {\n if (!USERNAME.test(account) || !/^[a-f0-9]{32}$/.test(credentials.salt) || !/^[a-f0-9]{128}$/.test(credentials.hash)) {\n throw new TypeError('Invalid account credentials.');\n }\n this.db.prepare('INSERT INTO accounts(id, salt, hash) VALUES (?, ?, ?)').run(account, credentials.salt, credentials.hash);\n }\n\n credentials(account: string): (Credentials & { epoch: number }) | undefined {\n return this.db.prepare('SELECT salt, hash, epoch FROM accounts WHERE id = ?').get(account) as unknown as (Credentials & { epoch: number }) | undefined;\n }\n\n issue(account: string, ttlMs: number, expectedEpoch?: number): string {\n if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Session lifetime must be 100ms–24h.');\n return this.transaction(() => {\n if (expectedEpoch !== undefined && this.credentials(account)?.epoch !== expectedEpoch) throw new Error('Sign-out occurred during sign-in. Try again.');\n this.db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());\n const count = this.db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE account = ?').get(account)!.count as number;\n if (count >= 32) throw new Error('Sign out existing sessions before signing in again.');\n const token = randomBytes(32).toString('base64url');\n this.db.prepare('INSERT INTO sessions(token, account, expires) VALUES (?, ?, ?)').run(digest(token), account, Date.now() + ttlMs);\n return token;\n });\n }\n\n session(token: string): Session | undefined {\n if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return undefined;\n return this.db.prepare('SELECT account, expires FROM sessions WHERE token = ? AND expires > ?').get(digest(token), Date.now()) as unknown as Session | undefined;\n }\n\n list(token: string): Card[] {\n const { account } = this.requireSession(token);\n return this.db.prepare('SELECT id, title FROM cards WHERE account = ? ORDER BY rowid').all(account) as unknown as Card[];\n }\n\n add(token: string, title: string): string {\n if (typeof title !== 'string' || !title.trim() || title.length > 80 || /[\\p{Cc}\\p{Cf}]/u.test(title)) throw new TypeError('Invalid card title.');\n return this.transaction(() => {\n const { account } = this.requireSession(token);\n const count = this.db.prepare('SELECT COUNT(*) AS count FROM cards WHERE account = ?').get(account)!.count as number;\n if (count >= MAX_CARDS) throw new Error('Card limit reached.');\n this.db.prepare('INSERT INTO cards(id, account, title) VALUES (?, ?, ?)').run(randomUUID(), account, title.trim());\n return account;\n });\n }\n\n remove(token: string, id: string): string {\n return this.transaction(() => {\n const { account } = this.requireSession(token);\n this.db.prepare('DELETE FROM cards WHERE id = ? AND account = ?').run(id, account);\n return account;\n });\n }\n\n signOut(token: string): string | undefined {\n return this.transaction(() => {\n const session = this.session(token);\n if (!session) return undefined;\n this.db.prepare('DELETE FROM sessions WHERE account = ?').run(session.account);\n this.db.prepare('UPDATE accounts SET epoch = epoch + 1 WHERE id = ?').run(session.account);\n return session.account;\n });\n }\n\n close() { if (!this.closed) { this.closed = true; this.db.close(); } }\n\n private requireSession(token: string): Session {\n const session = this.session(token);\n if (!session) throw new Error('Session expired. Sign in again.');\n return session;\n }\n\n private transaction<T>(operation: () => T): T {\n this.db.exec('BEGIN IMMEDIATE');\n try { const result = operation(); this.db.exec('COMMIT'); return result; }\n catch (error) { this.db.exec('ROLLBACK'); throw error; }\n }\n}\n"
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
"path": "src/auth.ts",
|
|
500
|
+
"content": "import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';\nimport { promisify } from 'node:util';\nimport type { Application, Request, Response } from 'express';\nimport { DashboardStore, USERNAME, type Credentials } from './store';\n\nconst COOKIE = 'redweb_dashboard';\nconst DUMMY: Credentials = { salt: '00'.repeat(16), hash: '00'.repeat(64) };\n\nexport function sessionToken(cookie: string | undefined): string {\n const matches = (cookie ?? '').split(';').map(part => part.trim()).filter(part => part.startsWith(`${COOKIE}=`));\n const token = matches.length === 1 ? matches[0].slice(COOKIE.length + 1) : '';\n return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : '';\n}\n\nconst passwordHash = promisify(scrypt);\n\nexport async function credentials(password: string): Promise<Credentials> {\n if (typeof password !== 'string' || password.length < 16 || password.length > 128) throw new TypeError('Use a password of 16–128 characters.');\n const salt = randomBytes(16).toString('hex');\n return { salt, hash: (await passwordHash(password, salt, 64) as Buffer).toString('hex') };\n}\n\n/** Bounded asynchronous password work; neither proxy headers nor browser input establish identity. */\nexport class DashboardAuth {\n private active = 0;\n private closed = false;\n private readonly attempts = new Map<string, { count: number; expires: number }>();\n\n constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {\n if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');\n }\n\n async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {\n if (this.closed) return undefined;\n const now = Date.now();\n for (const [key, entry] of this.attempts) if (entry.expires <= now) this.attempts.delete(key);\n let attempt = this.attempts.get(ip);\n if (!attempt) {\n if (this.attempts.size >= 1024) return undefined;\n attempt = { count: 0, expires: now + 60000 };\n this.attempts.set(ip, attempt);\n }\n if (++attempt.count > 10 || this.active >= 4) return undefined;\n if (typeof account !== 'string' || !USERNAME.test(account) || typeof password !== 'string' || password.length < 16 || password.length > 128) return undefined;\n this.active++;\n try {\n const stored = this.store.credentials(account);\n const expected = stored ?? DUMMY;\n const actual = await passwordHash(password, expected.salt, 64) as Buffer;\n if (this.closed) return undefined;\n if (!timingSafeEqual(actual, Buffer.from(expected.hash, 'hex')) || !stored) return undefined;\n return this.store.issue(account, this.ttlMs, stored.epoch);\n } finally { this.active--; }\n }\n\n close() { this.closed = true; this.attempts.clear(); }\n\n mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {\n const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;\n const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {\n app.post(route, (request, response) => {\n response.set('Cache-Control', 'private, no-store');\n if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }\n void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));\n });\n };\n post('/login', async (request, response) => {\n const token = await this.login(String(request.socket.remoteAddress), request.body?.account, request.body?.password);\n if (!token) { response.status(401).send('Unable to sign in. Check your credentials or try again later.'); return; }\n response.setHeader('Set-Cookie', cookie(token));\n response.redirect(303, '/');\n });\n post('/logout', async (request, response) => {\n const account = this.store.signOut(sessionToken(request.headers.cookie));\n response.setHeader('Set-Cookie', cookie(''));\n if (account) await revoke(account);\n response.redirect(303, '/login');\n });\n }\n}\n"
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
"path": "src/cards.tsx",
|
|
504
|
+
"content": "import { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';\nimport { z } from 'zod';\nimport { sessionToken } from './auth';\nimport { DashboardStore, MAX_CARDS, type Card } from './store';\n\nconst addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\\p{Cc}\\p{Cf}]+$/u) }).strict();\nconst removeInput = z.object({ id: z.string().uuid() }).strict();\nconst tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));\n\ninterface Subscriber { token: string; update(): void; close(): void; }\n\n/** Single-process notifications; SQLite remains the source of truth on every connection. */\nexport class PrivateCards {\n private readonly accounts = new Map<string, Set<Subscriber>>();\n constructor(readonly store: DashboardStore) {}\n\n allowed(context: LivePageRequestContext) {\n const session = this.store.session(tokenOf(context));\n return !!session && session.account === context.principal && !context.signal.aborted;\n }\n\n subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {\n const token = tokenOf(context);\n const session = this.store.session(token);\n if (!session || !this.allowed(context)) throw new Error('Sign in again.');\n let group = this.accounts.get(session.account);\n if (!group) this.accounts.set(session.account, group = new Set());\n let closed = false;\n const subscriber: Subscriber = {\n token, update: () => update(this.store.list(token)),\n close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },\n };\n const unsubscribe = () => {\n if (closed) return;\n closed = true;\n clearTimeout(expiry);\n context.signal.removeEventListener('abort', unsubscribe);\n group.delete(subscriber);\n if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);\n };\n const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));\n expiry.unref();\n group.add(subscriber);\n context.signal.addEventListener('abort', unsubscribe, { once: true });\n try { subscriber.update(); }\n catch (error) { unsubscribe(); throw error; }\n return unsubscribe;\n }\n\n publish(account: string) {\n for (const subscriber of this.accounts.get(account) ?? []) {\n try {\n if (this.store.session(subscriber.token)?.account === account) subscriber.update();\n else subscriber.close();\n } catch { subscriber.close(); }\n }\n }\n}\n\n@component()\nexport class Cards {\n @state() items: Card[] = [];\n private unsubscribe?: () => void;\n\n constructor(private readonly cards: PrivateCards) {}\n loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }\n connected(context: LivePageConnectionContext) {\n this.disconnected();\n this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });\n }\n disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }\n disposed() { this.disconnected(); }\n\n @action({ input: addInput })\n add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.add(tokenOf(context), title));\n }\n\n @action({ input: removeInput })\n remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {\n this.cards.publish(this.cards.store.remove(tokenOf(context), id));\n }\n\n render() {\n return <section class=\"cards\" aria-label=\"Your saved cards\">\n <form rw-submit=\"add\">\n <label for=\"card-title\">New card</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"80\" required autocomplete=\"off\" />\n <button type=\"submit\" disabled={this.items.length >= MAX_CARDS}>Add card</button>\n </form>\n <p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>\n <ul class=\"card-grid\">{this.items.map(card => <li key={card.id} data-card-id={card.id}>\n <h2>{card.title}</h2>\n <form rw-submit=\"remove\">\n <input type=\"hidden\" name=\"id\" value={card.id} />\n <button type=\"submit\" aria-label={`Delete ${card.title}`}>Delete</button>\n </form>\n </li>)}</ul>\n {!this.items.length && <p>No cards yet. Add your first one above.</p>}\n </section>;\n }\n}\n"
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"path": "src/admin.ts",
|
|
508
|
+
"content": "import { randomBytes } from 'node:crypto';\nimport { mkdirSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport { credentials } from './auth';\nimport { databasePath } from './app';\nimport { DashboardStore, USERNAME } from './store';\n\nasync function main() {\n const account = process.argv[2];\n if (!account || !USERNAME.test(account) || process.argv.length !== 3) throw new Error('Usage: npm run add-user -- alice (3–32 lowercase letters, digits, _ or -; starts with a letter).');\n const filename = databasePath();\n mkdirSync(dirname(filename), { recursive: true });\n const store = new DashboardStore(filename);\n try {\n const password = randomBytes(24).toString('base64url');\n store.provision(account, await credentials(password));\n console.log(`Created ${account}. Store this password safely; it is displayed only once:\\n${password}`);\n } finally { store.close(); }\n}\n\nvoid main().catch(error => { console.error(error.message); process.exitCode = 1; });\n"
|
|
509
|
+
}
|
|
510
|
+
],
|
|
511
|
+
"url": "/docs/reference/0.13.2/recipes/dashboard.md",
|
|
512
|
+
"sha256": "5b053842147c50d4a1e41b1f02f84c993c1f55f8ba14415e29dc8013fa16ffa0"
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
"id": "recipes/http-ws",
|
|
516
|
+
"title": "Http-ws starter",
|
|
517
|
+
"summary": "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.",
|
|
518
|
+
"source": "recipes/http-ws/README.md",
|
|
519
|
+
"markdown": "# Http-ws: complete application\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## HTTP and WebSockets on one listener\n\nOne 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.\n\nAfter starting the application, request `http://127.0.0.1:8181/health` to receive `{\"ok\":true}`. Connect a WebSocket to `ws://127.0.0.1:8181/chat` and send `{\"type\":\"hello\"}` to receive `{\"type\":\"hello\",\"message\":\"Hello from the server!\"}`. This is a raw JSON socket example, not a chatroom UI or the versioned socket-contract protocol. Use the chat or socket starter for those applications.\n\nThe HTTP builder does not bind a port. The socket service explicitly takes responsibility for listening and closing the supplied server with `listen: true` and `closeServerOnShutdown: true`. Call the returned application's `shutdown()`; it processes route failures and still closes its HTTP/TCP peers. Do not separately close the HTTP builder. Importing this module creates no listener; the standalone entrypoint uses the same bounded `runApp` helper as the other starters.\n\n`/health` reports liveness, not readiness or completed application work. Loopback binding is intentional. Before exposing the service, choose your deployment bind address, configure HTTPS/WSS, trusted origins, authentication, authorization, payload/connection limits, and any persistence you need. Shared listeners do not automatically provide these policies. Shutdown may force connections closed; it does not guarantee message delivery or durable work.\n\n`npm test` runs real HTTP and WebSocket requests on ephemeral ports, checks strict socket routing and multiple clients, verifies idempotent cleanup with an incomplete HTTP peer, and proves a failing route cleanup still closes the listener. It also runs the shared process-lifecycle suite. No mocks are used. The package verification gate repeats the tests against the compiled application with `src/` unavailable.\n\n\n## Setup and acceptance\n\n```sh\nnpx --yes redweb@0.13.2 init my-http-ws --template http-ws\ncd my-http-ws\nnpm install --save-exact redweb@0.13.2\nnpm test\nnpm run dev\n```\n\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n\n## Exact generated files\n\nThese 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.\n\n### package.json\n\n```json\n{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n```\n\n### src/app.tsx\n\n```tsx\nimport { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nexport class Hello extends BaseHandler {\n constructor() { super('hello'); }\n\n onMessage(socket: RedWebSocket) {\n socket.sendJson({ type: 'hello', message: 'Hello from the server!' });\n }\n}\n\nexport class ChatRoute extends SocketRoute {\n constructor() {\n super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });\n }\n}\n\nexport function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {\n const http = new HttpServer({\n listen: false,\n publicPaths: [],\n services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],\n });\n\n return new SocketServer({\n port: options.port ?? Number(process.env.PORT ?? 8181),\n bind: options.bind ?? '127.0.0.1',\n logger: options.logger,\n server: http.server,\n routes: [ChatRoute],\n listen: true,\n closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.\n });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n### src/run-app.ts\n\n```ts\nimport type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n```\n\n### src/app.css\n\n```css\n:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n```\n\n### scripts/copy-assets.cjs\n\n```js\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n```\n\n### test/network.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n```\n\n### test/app.test.cjs\n\n```js\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { SocketRoute } = require('redweb');\nconst { createApp, Hello } = require('../dist/app.js');\nconst { listen, connect } = require('./network.cjs');\n\ntest('an absent PORT binds the documented default or reports that exact port occupied', { timeout: 10000 }, async () => {\n const { spawnSync } = require('node:child_process');\n const env = { ...process.env };\n delete env.PORT;\n const result = spawnSync(process.execPath, ['-e', `\n const assert = require('node:assert/strict');\n const { once } = require('node:events');\n const WebSocket = require('ws');\n const { createApp } = require('./dist/app.js');\n (async () => {\n const app = createApp();\n let socket;\n try {\n try { if (!app.server.listening) await once(app.server, 'listening'); }\n catch (error) {\n assert.equal(error.code, 'EADDRINUSE');\n assert.equal(error.port, 8181);\n return; // Never send test traffic to a listener this test does not own.\n }\n assert.equal(app.server.address().port, 8181);\n const response = await fetch('http://127.0.0.1:8181/health', { signal: AbortSignal.timeout(3000) });\n assert.deepEqual(await response.json(), { ok: true });\n socket = new WebSocket('ws://127.0.0.1:8181/chat', { handshakeTimeout: 3000 });\n await once(socket, 'open');\n const reply = once(socket, 'message');\n socket.send(JSON.stringify({ type: 'hello' }));\n assert.equal(JSON.parse((await reply)[0].toString()).type, 'hello');\n } finally { socket?.terminate(); await app.shutdown(); }\n })().catch(error => { console.error(error); process.exitCode = 1; });\n `], { env, encoding: 'utf8', timeout: 7000, windowsHide: true });\n assert.equal(result.error, undefined);\n assert.equal(result.status, 0, result.stdout + result.stderr);\n});\n\ntest('HTTP and separate message handlers share one port, with strict socket paths', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const response = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(3000) });\n assert.equal(response.status, 200);\n assert.deepEqual(await response.json(), { ok: true });\n assert.equal((await fetch(`${origin}/missing`, { signal: AbortSignal.timeout(3000) })).status, 404);\n for (let index = 0; index < 2; index++) {\n const client = await connect(t, `${origin.replace('http:', 'ws:')}/chat`, origin);\n client.send({ type: 'hello' });\n assert.deepEqual(await client.receive(message => message.type === 'hello'),\n { type: 'hello', message: 'Hello from the server!' });\n }\n const unknown = new WebSocket(`${origin.replace('http:', 'ws:')}/missing`, { handshakeTimeout: 3000 });\n t.after(() => unknown.terminate());\n await once(unknown, 'error');\n});\n\nfor (const failingRoute of [false, true]) {\n test(`shutdown closes incomplete HTTP peers${failingRoute ? ' despite a route failure' : ' idempotently'}`, { timeout: 10000 }, async t => {\n const app = createApp({ port: 0, logger: null });\n t.after(() => app.shutdown().catch(() => {}));\n if (!app.server.listening) await once(app.server, 'listening');\n assert.equal(app.closeServerOnShutdown, true);\n const failure = new Error('Application cleanup failed');\n if (failingRoute) {\n class FailingRoute extends SocketRoute {\n constructor() { super({ path: '/fails', handlers: [Hello] }); }\n async shutdown() { await super.shutdown(); throw failure; }\n }\n app.addRoute(FailingRoute);\n }\n const accepted = once(app.server, 'connection');\n const peer = net.connect(app.server.address().port, '127.0.0.1');\n t.after(() => peer.destroy());\n peer.on('error', () => {});\n await once(peer, 'connect');\n const [serverPeer] = await accepted;\n peer.write('POST /health HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Length: 100\\r\\n\\r\\nx');\n const closed = once(serverPeer, 'close');\n const shutdown = app.shutdown();\n assert.equal(app.shutdown(), shutdown);\n if (failingRoute) await assert.rejects(shutdown, error => error.errors.length === 1 && error.errors[0] === failure);\n else await shutdown;\n await closed;\n assert.equal(serverPeer.destroyed, true);\n assert.equal(app.server.listening, false);\n assert.equal(app.server.listenerCount('upgrade'), 0);\n });\n}\n```\n\n### test/run-app.test.cjs\n\n```js\nconst assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n```\n\n### README.md\n\n````md\n# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## HTTP and WebSockets on one listener\n\nOne 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.\n\nAfter starting the application, request `http://127.0.0.1:8181/health` to receive `{\"ok\":true}`. Connect a WebSocket to `ws://127.0.0.1:8181/chat` and send `{\"type\":\"hello\"}` to receive `{\"type\":\"hello\",\"message\":\"Hello from the server!\"}`. This is a raw JSON socket example, not a chatroom UI or the versioned socket-contract protocol. Use the chat or socket starter for those applications.\n\nThe HTTP builder does not bind a port. The socket service explicitly takes responsibility for listening and closing the supplied server with `listen: true` and `closeServerOnShutdown: true`. Call the returned application's `shutdown()`; it processes route failures and still closes its HTTP/TCP peers. Do not separately close the HTTP builder. Importing this module creates no listener; the standalone entrypoint uses the same bounded `runApp` helper as the other starters.\n\n`/health` reports liveness, not readiness or completed application work. Loopback binding is intentional. Before exposing the service, choose your deployment bind address, configure HTTPS/WSS, trusted origins, authentication, authorization, payload/connection limits, and any persistence you need. Shared listeners do not automatically provide these policies. Shutdown may force connections closed; it does not guarantee message delivery or durable work.\n\n`npm test` runs real HTTP and WebSocket requests on ephemeral ports, checks strict socket routing and multiple clients, verifies idempotent cleanup with an incomplete HTTP peer, and proves a failing route cleanup still closes the listener. It also runs the shared process-lifecycle suite. No mocks are used. The package verification gate repeats the tests against the compiled application with `src/` unavailable.\n````\n\n### .gitignore\n\n```text\nnode_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n```\n",
|
|
520
|
+
"files": [
|
|
521
|
+
{
|
|
522
|
+
"path": "package.json",
|
|
523
|
+
"content": "{\n \"name\": \"redweb-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc && node scripts/copy-assets.cjs\",\n \"start\": \"node dist/app.js\",\n \"dev\": \"nodemon\",\n \"test\": \"npm run build && node --test test/app.test.cjs test/run-app.test.cjs\",\n \"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\"\n },\n \"dependencies\": {\n \"redweb\": \"^0.13.2\"\n },\n \"devDependencies\": {\n \"typescript\": \"^5.9.3\",\n \"nodemon\": \"^3.1.11\",\n \"ws\": \"^8.21.3\",\n \"c8\": \"^10.1.3\"\n },\n \"nodemonConfig\": {\n \"env\": {\n \"REDWEB_DEV_REFRESH\": \"1\"\n },\n \"watch\": [\n \"src\",\n \"tsconfig.json\"\n ],\n \"ext\": \"ts,tsx,css,html,json\",\n \"exec\": \"npm run build && npm start || exit 1\",\n \"delay\": 200\n }\n}\n"
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
"path": "tsconfig.json",
|
|
527
|
+
"content": "{\n \"extends\": \"redweb/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"outDir\": \"dist\",\n \"sourceMap\": true\n },\n \"include\": [\n \"src/**/*.ts\",\n \"src/**/*.tsx\"\n ]\n}\n"
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
"path": "src/app.tsx",
|
|
531
|
+
"content": "import { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nexport class Hello extends BaseHandler {\n constructor() { super('hello'); }\n\n onMessage(socket: RedWebSocket) {\n socket.sendJson({ type: 'hello', message: 'Hello from the server!' });\n }\n}\n\nexport class ChatRoute extends SocketRoute {\n constructor() {\n super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });\n }\n}\n\nexport function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {\n const http = new HttpServer({\n listen: false,\n publicPaths: [],\n services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],\n });\n\n return new SocketServer({\n port: options.port ?? Number(process.env.PORT ?? 8181),\n bind: options.bind ?? '127.0.0.1',\n logger: options.logger,\n server: http.server,\n routes: [ChatRoute],\n listen: true,\n closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.\n });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"path": "src/run-app.ts",
|
|
535
|
+
"content": "import type { Server } from 'node:http';\n\ninterface Application { server: Server; shutdown(): Promise<void>; }\n\n/** Entry-point policy only: importing a recipe never installs process handlers. */\nexport function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {\n if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {\n throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');\n }\n const fail = (message: string) => {\n console.error(message);\n if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;\n };\n let app: T;\n try { app = createApp(); }\n catch { fail('Application startup failed.'); return undefined; }\n\n let closing: Promise<void> | undefined;\n const stop = () => {\n if (!closing) {\n let failed = false;\n const deadline = setTimeout(() => {\n fail('Application cleanup exceeded its deadline; terminating the process.');\n process.exit();\n }, shutdownTimeoutMs);\n closing = Promise.resolve().then(() => app.shutdown()).catch(() => {\n failed = true;\n fail('Application cleanup failed.');\n }).finally(() => {\n // Failed cleanup may leave live handles. Permit natural exit if none\n // remain, but still force a bounded exit when resources were leaked.\n if (failed) { deadline.unref(); return; }\n clearTimeout(deadline);\n process.off('SIGINT', stop);\n process.off('SIGTERM', stop);\n app.server.off('error', onError);\n app.server.off('close', stop);\n });\n }\n return closing;\n };\n const onError = () => { fail('Application listener failed.'); void stop(); };\n // Persistent handlers keep repeated signals from bypassing active cleanup.\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n app.server.on('error', onError);\n // Native close can precede database/worker cleanup: it starts, never ends, shutdown.\n app.server.once('close', stop);\n return app;\n}\n"
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
"path": "src/app.css",
|
|
539
|
+
"content": ":root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }\nbody { margin: 0; }\n.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }\nh1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }\np { color: #bfc1ca; line-height: 1.6; }\nbutton { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }\nbutton:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }\nnav { padding: 1rem; } a { color: #ff8795; }\n"
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
"path": "scripts/copy-assets.cjs",
|
|
543
|
+
"content": "const fs = require('node:fs');\nconst path = require('node:path');\n\n// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.\nfs.cpSync('src', 'dist', {\n recursive: true,\n filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),\n});\n"
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"path": "test/network.cjs",
|
|
547
|
+
"content": "const assert = require('node:assert/strict');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { createApp } = require('../dist/app.js');\n\nasync function listen(t) {\n const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });\n t.after(() => app.shutdown());\n if (!app.server.listening) await once(app.server, 'listening');\n return `http://127.0.0.1:${app.server.address().port}`;\n}\n\nasync function connect(t, url, origin, headers = {}) {\n const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });\n const messages = [];\n socket.on('message', raw => messages.push(JSON.parse(raw.toString())));\n t.after(async () => {\n if (socket.readyState === WebSocket.CLOSED) return;\n const closed = once(socket, 'close');\n // Cleanup must not depend on a peer completing the closing handshake.\n // Tests of graceful disconnect explicitly close and await their sockets.\n socket.terminate();\n await closed;\n });\n await once(socket, 'open');\n return {\n socket,\n send: message => socket.send(JSON.stringify(message)),\n async receive(predicate) {\n const deadline = Date.now() + 3000;\n while (Date.now() < deadline) {\n const index = messages.findIndex(predicate);\n if (index !== -1) return messages.splice(index, 1)[0];\n await new Promise(resolve => setTimeout(resolve, 10));\n }\n assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);\n },\n };\n}\n\nasync function live(t, origin, headers = {}) {\n const response = await fetch(origin, { headers });\n assert.equal(response.status, 200);\n const document = await response.text();\n const config = JSON.parse(document.match(/id=\"__redweb_page\">([^<]+)</)[1]);\n const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);\n return {\n ...connection,\n document, config,\n patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),\n action: (name, args = [], component) => connection.send({\n v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },\n }),\n state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&\n message.payload.name === name && message.payload.component === component && value(message.payload.value)),\n };\n}\n\nmodule.exports = { listen, connect, live };\n"
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
"path": "test/app.test.cjs",
|
|
551
|
+
"content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst { SocketRoute } = require('redweb');\nconst { createApp, Hello } = require('../dist/app.js');\nconst { listen, connect } = require('./network.cjs');\n\ntest('an absent PORT binds the documented default or reports that exact port occupied', { timeout: 10000 }, async () => {\n const { spawnSync } = require('node:child_process');\n const env = { ...process.env };\n delete env.PORT;\n const result = spawnSync(process.execPath, ['-e', `\n const assert = require('node:assert/strict');\n const { once } = require('node:events');\n const WebSocket = require('ws');\n const { createApp } = require('./dist/app.js');\n (async () => {\n const app = createApp();\n let socket;\n try {\n try { if (!app.server.listening) await once(app.server, 'listening'); }\n catch (error) {\n assert.equal(error.code, 'EADDRINUSE');\n assert.equal(error.port, 8181);\n return; // Never send test traffic to a listener this test does not own.\n }\n assert.equal(app.server.address().port, 8181);\n const response = await fetch('http://127.0.0.1:8181/health', { signal: AbortSignal.timeout(3000) });\n assert.deepEqual(await response.json(), { ok: true });\n socket = new WebSocket('ws://127.0.0.1:8181/chat', { handshakeTimeout: 3000 });\n await once(socket, 'open');\n const reply = once(socket, 'message');\n socket.send(JSON.stringify({ type: 'hello' }));\n assert.equal(JSON.parse((await reply)[0].toString()).type, 'hello');\n } finally { socket?.terminate(); await app.shutdown(); }\n })().catch(error => { console.error(error); process.exitCode = 1; });\n `], { env, encoding: 'utf8', timeout: 7000, windowsHide: true });\n assert.equal(result.error, undefined);\n assert.equal(result.status, 0, result.stdout + result.stderr);\n});\n\ntest('HTTP and separate message handlers share one port, with strict socket paths', { timeout: 10000 }, async t => {\n const origin = await listen(t);\n const response = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(3000) });\n assert.equal(response.status, 200);\n assert.deepEqual(await response.json(), { ok: true });\n assert.equal((await fetch(`${origin}/missing`, { signal: AbortSignal.timeout(3000) })).status, 404);\n for (let index = 0; index < 2; index++) {\n const client = await connect(t, `${origin.replace('http:', 'ws:')}/chat`, origin);\n client.send({ type: 'hello' });\n assert.deepEqual(await client.receive(message => message.type === 'hello'),\n { type: 'hello', message: 'Hello from the server!' });\n }\n const unknown = new WebSocket(`${origin.replace('http:', 'ws:')}/missing`, { handshakeTimeout: 3000 });\n t.after(() => unknown.terminate());\n await once(unknown, 'error');\n});\n\nfor (const failingRoute of [false, true]) {\n test(`shutdown closes incomplete HTTP peers${failingRoute ? ' despite a route failure' : ' idempotently'}`, { timeout: 10000 }, async t => {\n const app = createApp({ port: 0, logger: null });\n t.after(() => app.shutdown().catch(() => {}));\n if (!app.server.listening) await once(app.server, 'listening');\n assert.equal(app.closeServerOnShutdown, true);\n const failure = new Error('Application cleanup failed');\n if (failingRoute) {\n class FailingRoute extends SocketRoute {\n constructor() { super({ path: '/fails', handlers: [Hello] }); }\n async shutdown() { await super.shutdown(); throw failure; }\n }\n app.addRoute(FailingRoute);\n }\n const accepted = once(app.server, 'connection');\n const peer = net.connect(app.server.address().port, '127.0.0.1');\n t.after(() => peer.destroy());\n peer.on('error', () => {});\n await once(peer, 'connect');\n const [serverPeer] = await accepted;\n peer.write('POST /health HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Length: 100\\r\\n\\r\\nx');\n const closed = once(serverPeer, 'close');\n const shutdown = app.shutdown();\n assert.equal(app.shutdown(), shutdown);\n if (failingRoute) await assert.rejects(shutdown, error => error.errors.length === 1 && error.errors[0] === failure);\n else await shutdown;\n await closed;\n assert.equal(serverPeer.destroyed, true);\n assert.equal(app.server.listening, false);\n assert.equal(app.server.listenerCount('upgrade'), 0);\n });\n}\n"
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
"path": "test/run-app.test.cjs",
|
|
555
|
+
"content": "const assert = require('node:assert/strict');\nconst { test } = require('node:test');\nconst { spawn } = require('node:child_process');\n\n// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.\n// Windows cannot deliver POSIX signals through child.kill, so only that platform\n// explicitly emits the signal event inside the child. Linux uses real OS signals.\nconst fixture = String.raw`\nconst assert = require('node:assert/strict');\nconst http = require('node:http');\nconst net = require('node:net');\nconst { once } = require('node:events');\nconst WebSocket = require('ws');\nconst mode = process.argv[1];\nconst signals = ['SIGINT', 'SIGTERM'];\nconst initial = signals.map(signal => process.listenerCount(signal));\nconst { runApp } = require('./dist/run-app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nrequire('./dist/app.js');\nassert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);\nlet cleanups = 0;\nprocess.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));\nconst signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);\nif (mode === 'invalid') {\n for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);\n} else if (mode === 'factory') {\n assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);\n} else {\n if (mode === 'preserve') process.exitCode = '7';\n const server = http.createServer((_request, response) => response.end('ready'));\n const wss = new WebSocket.Server({ server });\n wss.on('error', () => {}); // The HTTP listener error is owned by runApp.\n const peers = new Set();\n server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });\n const close = async () => {\n for (const peer of peers) peer.destroy();\n for (const peer of wss.clients) peer.terminate();\n await new Promise(resolve => wss.close(resolve));\n await new Promise(resolve => server.close(resolve));\n };\n const app = runApp(() => ({ server, shutdown() {\n cleanups++;\n console.log('cleanup-started');\n if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }\n if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));\n return close().then(async () => {\n if (mode === 'hung') return new Promise(() => {});\n if (mode === 'reject') throw Error('private cleanup detail');\n if (mode === 'repeat') {\n signal('SIGINT'); signal('SIGTERM');\n server.emit('error', Error('private listener detail'));\n }\n await new Promise(resolve => setTimeout(resolve, 20));\n });\n } }), 200);\n assert.equal(app.server, server);\n (async () => {\n if (mode === 'occupied') {\n const other = http.createServer();\n await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));\n server.once('error', () => other.close());\n server.listen(other.address().port, '127.0.0.1');\n return;\n }\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n const port = server.address().port;\n const response = await fetch('http://127.0.0.1:' + port);\n assert.equal(await response.text(), 'ready');\n const peer = net.connect(port, '127.0.0.1');\n peer.on('error', () => {});\n await once(peer, 'connect');\n peer.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n');\n const socket = new WebSocket('ws://127.0.0.1:' + port);\n socket.on('error', () => {});\n await once(socket, 'open');\n if (mode === 'native-close') {\n for (const connection of peers) connection.destroy();\n server.close();\n return;\n }\n // A partial HTTP peer otherwise prevents native close; application cleanup\n // begins via the signal and the later native close must not end its timer.\n signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');\n })().catch(error => { console.error(error); process.exit(99); });\n}\n`;\n\nfunction execute(mode, t, args = ['-e', fixture, mode], env = process.env) {\n return new Promise((resolve, reject) => {\n const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });\n let stdout = '', stderr = '';\n let timedOut = false, finished = false;\n const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));\n const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);\n t.after(async () => {\n clearTimeout(deadline);\n if (!finished) { child.kill('SIGKILL'); await closed; }\n });\n child.stdout.on('data', data => { stdout += data; });\n child.stderr.on('data', data => { stderr += data; });\n child.once('error', reject);\n child.once('close', (code, signal) => {\n clearTimeout(deadline);\n if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\\n${stdout}\\n${stderr}`));\n else resolve({ code, signal, stdout, stderr });\n });\n });\n}\n\ntest('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {\n const net = require('node:net');\n const { once } = require('node:events');\n const fs = require('node:fs');\n const path = require('node:path');\n const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));\n const occupied = net.createServer(socket => socket.destroy());\n const loopback = net.createServer(socket => socket.destroy());\n let failure;\n try {\n occupied.listen(0, '0.0.0.0');\n await once(occupied, 'listening');\n // Windows permits distinct wildcard/loopback binds on the same port.\n // Hold both addresses; Unix may already reject the second bind.\n loopback.listen(occupied.address().port, '127.0.0.1');\n try { await once(loopback, 'listening'); }\n catch (error) { assert.equal(error.code, 'EADDRINUSE'); }\n const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };\n delete env.DASHBOARD_ORIGIN;\n const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);\n assert.equal(result.code, 1, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.match(result.stderr, /Application listener failed/);\n } catch (error) { failure = error; }\n const cleanup = await Promise.allSettled([\n ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>\n error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),\n fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),\n ]);\n const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];\n if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');\n});\n\nfor (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {\n test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {\n const result = await execute(mode, t);\n const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;\n assert.equal(result.code, expected, `${result.stdout}\\n${result.stderr}`);\n assert.equal(result.signal, null);\n assert.doesNotMatch(result.stderr, /private .* detail/);\n const noApp = ['invalid', 'factory'].includes(mode);\n assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);\n if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);\n if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {\n const snapshot = JSON.parse(result.stdout.trim().split(/\\r?\\n/).at(-1));\n assert.deepEqual(snapshot.signals, snapshot.initial);\n }\n });\n}\n"
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"path": "README.md",
|
|
559
|
+
"content": "# Your Redweb application\n\nRequirements: 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.\n\nFor 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.\n\n```sh\nnpm install\nnpm test\nnpm run dev\n```\n\nHTTP 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.\n`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.\n`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`.\n\n## Development and production\n\nEdit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,\nthen rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,\nHTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible\nnotice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,\nnot autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.\nThe generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.\nThe refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),\nand creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;\ncustom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.\n`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.\nRun `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,\nthen install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.\n\nThe 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.\n\nThe 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.\n\nFor public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,\nand application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.\nNever commit secrets; `.env` is ignored but is not loaded automatically.\n\n`npx --no-install redweb doctor --json` reports configuration problems without changing your files.\n\n## HTTP and WebSockets on one listener\n\nOne 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.\n\nAfter starting the application, request `http://127.0.0.1:8181/health` to receive `{\"ok\":true}`. Connect a WebSocket to `ws://127.0.0.1:8181/chat` and send `{\"type\":\"hello\"}` to receive `{\"type\":\"hello\",\"message\":\"Hello from the server!\"}`. This is a raw JSON socket example, not a chatroom UI or the versioned socket-contract protocol. Use the chat or socket starter for those applications.\n\nThe HTTP builder does not bind a port. The socket service explicitly takes responsibility for listening and closing the supplied server with `listen: true` and `closeServerOnShutdown: true`. Call the returned application's `shutdown()`; it processes route failures and still closes its HTTP/TCP peers. Do not separately close the HTTP builder. Importing this module creates no listener; the standalone entrypoint uses the same bounded `runApp` helper as the other starters.\n\n`/health` reports liveness, not readiness or completed application work. Loopback binding is intentional. Before exposing the service, choose your deployment bind address, configure HTTPS/WSS, trusted origins, authentication, authorization, payload/connection limits, and any persistence you need. Shared listeners do not automatically provide these policies. Shutdown may force connections closed; it does not guarantee message delivery or durable work.\n\n`npm test` runs real HTTP and WebSocket requests on ephemeral ports, checks strict socket routing and multiple clients, verifies idempotent cleanup with an incomplete HTTP peer, and proves a failing route cleanup still closes the listener. It also runs the shared process-lifecycle suite. No mocks are used. The package verification gate repeats the tests against the compiled application with `src/` unavailable.\n"
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
"path": ".gitignore",
|
|
563
|
+
"content": "node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n"
|
|
564
|
+
}
|
|
565
|
+
],
|
|
566
|
+
"url": "/docs/reference/0.13.2/recipes/http-ws.md",
|
|
567
|
+
"sha256": "7c9daef14245c363f5cec4cd022dbcd03a03fdcd6fc87de3ebd81ac7829815b3"
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
"id": "examples/room-access",
|
|
571
|
+
"title": "One identity for a page and a protected room",
|
|
572
|
+
"summary": "A complete local demonstration of shared authentication, decorator-first server HTML and explicitly authorized raw socket room entry.",
|
|
573
|
+
"source": "docs/reference.json",
|
|
574
|
+
"markdown": "# One identity for a page and a protected room\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nA complete local demonstration of shared authentication, decorator-first server HTML and explicitly authorized raw socket room entry.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```tsx\nimport { randomBytes } from 'node:crypto';\nimport { page, start, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';\n\n// A runnable local demonstration, not a production credential store.\nexport function createApp(port = 8181) {\n const token = randomBytes(32).toString('base64url');\n let enabled = true;\n const authenticate = (request: Pick<RedWebRequest, 'headers'>) =>\n enabled && request.headers.authorization === `Bearer ${token}` ? 'alice' : false;\n\n @page('/', { authorize: context => context.principal === 'alice' })\n class Home {\n render({ principal }: LivePageRequestContext) { return <main><h1>Private workspace</h1><p>{principal}</p></main>; }\n }\n\n class Join extends BaseHandler {\n constructor() { super('join'); }\n async onMessage(socket: RedWebSocket) {\n socket.sendJson({ joined: await socket.enterRoom!('team'), principal: socket.context!.principal });\n }\n }\n class Team extends SocketRoute {\n constructor() {\n super({ path: '/team', handlers: [Join], allowDuplicateConnections: true, logger: null,\n admission: { authenticate },\n rooms: { authorize: (context, roomId) => enabled && context.principal === 'alice' && roomId === 'team' },\n });\n }\n }\n\n const app = start(Home, { listen: false, authenticate, logger: null });\n const team = app.sockets!.addRoute(Team);\n app.server.listen(port, '127.0.0.1');\n return {\n app, team, token,\n async revoke() {\n enabled = false; // Invalidate credentials and future permissions first.\n team.clients.forEach(socket => team.rooms!.leaveAll(socket));\n await app.revoke('alice');\n },\n shutdown: () => app.shutdown(),\n };\n}\n\nif (require.main === module) {\n const demo = createApp();\n console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');\n console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.\n process.once('SIGTERM', () => void demo.shutdown().catch(console.error));\n process.once('SIGINT', () => void demo.shutdown().catch(console.error));\n}\n```\n\n## Notes and boundaries\n\n- 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.\n- 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.\n- 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.\n",
|
|
575
|
+
"url": "/docs/reference/0.13.2/examples/room-access.md",
|
|
576
|
+
"sha256": "91da82ad94aa7b5acd305db959abafa728a5ed4309005dbc5fba22c0131240b9"
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
"id": "examples/shared-server",
|
|
580
|
+
"title": "HTTP and WebSockets on one listener",
|
|
581
|
+
"summary": "Build the Express side without binding, attach route classes to the same Node server, and explicitly give the socket service responsibility for listening and cleanup.",
|
|
582
|
+
"source": "docs/reference.json",
|
|
583
|
+
"markdown": "# HTTP and WebSockets on one listener\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nBuild the Express side without binding, attach route classes to the same Node server, and explicitly give the socket service responsibility for listening and cleanup.\n\nUse the [complete http-ws recipe](/docs/reference/0.13.2/recipes/http-ws.md) for setup, files, and tests.\n\n```tsx\nimport { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nexport class Hello extends BaseHandler {\n constructor() { super('hello'); }\n\n onMessage(socket: RedWebSocket) {\n socket.sendJson({ type: 'hello', message: 'Hello from the server!' });\n }\n}\n\nexport class ChatRoute extends SocketRoute {\n constructor() {\n super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });\n }\n}\n\nexport function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {\n const http = new HttpServer({\n listen: false,\n publicPaths: [],\n services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],\n });\n\n return new SocketServer({\n port: options.port ?? Number(process.env.PORT ?? 8181),\n bind: options.bind ?? '127.0.0.1',\n logger: options.logger,\n server: http.server,\n routes: [ChatRoute],\n listen: true,\n closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.\n });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n## Notes and boundaries\n\n- GET /health returns JSON; ws://127.0.0.1:8181/chat accepts {\"type\":\"hello\"}. The HTTP endpoint reports liveness, not readiness.\n- Use the complete http-ws starter for compiler configuration, the shared entrypoint helper, and actual HTTP/WebSocket tests. It binds loopback for local development.\n- The socket service explicitly owns shared-listener cleanup with closeServerOnShutdown: true. Call its shutdown() rather than a second HTTP shutdown sequence. Configure authentication, trusted origins, limits, and HTTPS/WSS before public deployment.\n",
|
|
584
|
+
"url": "/docs/reference/0.13.2/examples/shared-server.md",
|
|
585
|
+
"sha256": "c04e77391271d99aab6f76aef463f48b5352682f6b0e3ef49a41c05cec76f935"
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
"id": "examples/live-html",
|
|
589
|
+
"title": "Server state and reusable TSX components",
|
|
590
|
+
"summary": "Ordinary TSX expressions read server-owned state and update automatically after an action; no repeated binding names or browser component runtime.",
|
|
591
|
+
"source": "docs/reference.json",
|
|
592
|
+
"markdown": "# Server state and reusable TSX components\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nOrdinary TSX expressions read server-owned state and update automatically after an action; no repeated binding names or browser component runtime.\n\nUse the [complete realtime recipe](/docs/reference/0.13.2/recipes/realtime.md) for setup, files, and tests.\n\n```tsx\nimport { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n## Notes and boundaries\n\n- The first response is complete server-rendered HTML.\n- Function components handle stateless snippets; decorated classes own state and actions.\n- There is no React dependency, virtual DOM, or hydration pass.\n",
|
|
593
|
+
"url": "/docs/reference/0.13.2/examples/live-html.md",
|
|
594
|
+
"sha256": "33a869c23728eab1e7a416a253ba2546d92ffd2536eb1a438cf564d77ddd4328"
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
"id": "examples/static-site",
|
|
598
|
+
"title": "A complete site with shared defaults",
|
|
599
|
+
"summary": "Define metadata, layout, CSS, caching, and asset export once, then keep each page focused on its content.",
|
|
600
|
+
"source": "docs/reference.json",
|
|
601
|
+
"markdown": "# A complete site with shared defaults\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDefine metadata, layout, CSS, caching, and asset export once, then keep each page focused on its content.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```tsx\nimport { defineSite } from 'redweb'\n\nconst site = defineSite({\n origin: 'https://example.com',\n css: 'site.css',\n head: { description: 'Product documentation', image: '/og.png' },\n cache: { maxAge: 300 },\n layout: content => <body><nav>Product</nav><main>{content}</main></body>,\n})\n\n@site.page('/docs', { head: { title: 'Documentation' } })\nclass DocsPage {\n render() { return <h1>Documentation</h1> }\n}\n\nawait site.export(DocsPage, {\n outDir: 'dist',\n publicDir: 'public',\n})\n```\n\n## Notes and boundaries\n\n- Canonical URLs are derived from the route and origin.\n- Public assets and rendered pages are staged before output changes.\n- The generated site contains no Redweb browser runtime or WebSocket.\n",
|
|
602
|
+
"url": "/docs/reference/0.13.2/examples/static-site.md",
|
|
603
|
+
"sha256": "36cbfca73e06505fbc48537d2a38861ef7735cb156c2c38cd0d64bb3d3d30f3a"
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
"id": "examples/handlers",
|
|
607
|
+
"title": "JSON routing, broadcast, and binary frames",
|
|
608
|
+
"summary": "Text messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.",
|
|
609
|
+
"source": "docs/reference.json",
|
|
610
|
+
"markdown": "# JSON routing, broadcast, and binary frames\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nText messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nconst { BaseHandler, SocketRoute } = require('redweb')\n\nclass ChatHandler extends BaseHandler {\n constructor() { super('chat') }\n\n onMessage(socket, message) {\n socket.broadcast({ type: 'chat', text: message.text })\n }\n}\n\nclass SnapshotHandler extends BaseHandler {\n constructor() { super('snapshot') }\n onMessage() {}\n acceptsBinary(_socket, buffer) { return buffer.length > 0 }\n onBinaryMessage(socket, buffer) {\n socket.sendJson({ type: 'snapshot:received', bytes: buffer.length })\n }\n}\n\nclass RealtimeRoute extends SocketRoute {\n constructor() {\n super({\n path: '/realtime',\n handlers: [ChatHandler, SnapshotHandler],\n websocketOptions: { maxPayload: 64 * 1024 },\n })\n }\n}\n```\n\n## Notes and boundaries\n\n- sendJson and broadcast share the same outbound policy.\n- acceptsBinary can select among multiple binary handlers.\n- Async handler failures become safe client errors.\n",
|
|
611
|
+
"url": "/docs/reference/0.13.2/examples/handlers.md",
|
|
612
|
+
"sha256": "6ba306100147613a87b3f3f646cf7a863fef42e765eeb8ae4bff42ca00a554bf"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
"id": "examples/protected-route",
|
|
616
|
+
"title": "Bound admission, work, and slow peers",
|
|
617
|
+
"summary": "Production controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.",
|
|
618
|
+
"source": "docs/reference.json",
|
|
619
|
+
"markdown": "# Bound admission, work, and slow peers\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nProduction controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nclass MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [InputHandler],\n admission: {\n origins: ['https://game.example'],\n timeoutMs: 3000,\n authenticate: (request, { signal }) =>\n verifyPlayer(request, signal),\n },\n maxPendingUpgrades: 64,\n limits: {\n maxConnections: 5000,\n maxBufferedBytes: 1024 * 1024,\n maxPendingMessages: 64,\n messageRate: { capacity: 60, refillPerSecond: 30 },\n },\n orderedMessages: true,\n heartbeat: { intervalMs: 30000, timeoutMs: 10000 },\n websocketOptions: { maxPayload: 64 * 1024 },\n })\n }\n}\n```\n\n## Notes and boundaries\n\n- Authentication completes before onInitialContact.\n- Ordered overflow closes pending work synchronously.\n- Disabled controls add no per-connection queue or timer.\n",
|
|
620
|
+
"url": "/docs/reference/0.13.2/examples/protected-route.md",
|
|
621
|
+
"sha256": "ac6d92e5de01e873079d5e57360d35f0cf4e7a338dd374c1fa4c9840e9ac5aa4"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"id": "examples/rooms-sessions",
|
|
625
|
+
"title": "Match handlers and resumable ownership",
|
|
626
|
+
"summary": "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.",
|
|
627
|
+
"source": "docs/reference.json",
|
|
628
|
+
"markdown": "# Match handlers and resumable ownership\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nGive 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.\n\nUse the [complete socket recipe](/docs/reference/0.13.2/recipes/socket.md) for setup, files, and tests.\n\n```ts\nimport { randomUUID } from 'node:crypto';\nimport type { RedWebSocket } from 'redweb';\nimport { match } from './contract';\n\nclass Player {\n readonly session = randomUUID();\n x = 0;\n y = 0;\n constructor(readonly name: string) {}\n}\n\nfunction requireUnjoined(socket: RedWebSocket) {\n if (socket.context?.session) throw new Error('Already joined.');\n}\n\nfunction currentPlayer(socket: RedWebSocket) {\n const session = socket.context?.session as { data?: unknown } | null | undefined;\n if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');\n return session.data;\n}\n\nexport const Join = match.handler('join', (socket, { name }, message) => {\n requireUnjoined(socket);\n const player = new Player(name);\n if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Move = match.handler('move', (socket, { x, y }, message) => {\n const player = currentPlayer(socket);\n player.x = x;\n player.y = y;\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Resume = match.handler('resume', (socket, { session }, message) => {\n requireUnjoined(socket);\n if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');\n return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });\n});\n```\n\n## Notes and boundaries\n\n- 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.\n- 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.\n- 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.\n- 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.\n",
|
|
629
|
+
"url": "/docs/reference/0.13.2/examples/rooms-sessions.md",
|
|
630
|
+
"sha256": "fe4c1cb0b2a6ebaa676482c827fa60c4b171db5c4671e1d5b971f520011bb769"
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
"id": "examples/fixed-step",
|
|
634
|
+
"title": "Fixed-step work without overlapping ticks",
|
|
635
|
+
"summary": "FixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.",
|
|
636
|
+
"source": "docs/reference.json",
|
|
637
|
+
"markdown": "# Fixed-step work without overlapping ticks\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nFixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nconst { FixedStepService, SocketRoute } = require('redweb')\n\nclass Simulation extends FixedStepService {\n constructor() {\n super('simulation', 50, 3, 250)\n }\n\n async onTick(stepMs, tick) {\n await authoritativeGame.update(stepMs, tick)\n }\n\n onLagDropped(milliseconds) {\n console.warn('Simulation lag discarded', { milliseconds })\n }\n}\n\nclass SimulationRoute extends SocketRoute {\n constructor() {\n super({\n path: '/simulation',\n handlers: [InputHandler],\n services: [Simulation],\n })\n }\n}\n```\n\n## Notes and boundaries\n\n- The active async tick must finish before another begins.\n- maxCatchUpTicks prevents a spiral of death.\n- maxRetainedLagMs bounds remembered delay.\n",
|
|
638
|
+
"url": "/docs/reference/0.13.2/examples/fixed-step.md",
|
|
639
|
+
"sha256": "6ea14be4518639740c01bae74af4dad142a8d77db90f1382600ffb7e25627240"
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
"id": "examples/protocol",
|
|
643
|
+
"title": "Versioned envelopes and the dependency-free client",
|
|
644
|
+
"summary": "Negotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.",
|
|
645
|
+
"source": "docs/reference.json",
|
|
646
|
+
"markdown": "# Versioned envelopes and the dependency-free client\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nNegotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nconst { SocketRoute } = require('redweb')\n\nclass ProtocolRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [MoveHandler],\n protocol: {\n versions: ['2', '1'],\n binary: {\n maxBytes: 64 * 1024,\n encode: (state) => codec.encode(state),\n decode: (bytes) => codec.decode(bytes),\n },\n },\n })\n }\n}\n\n// Browser client\nconst { ProtocolClient, ERROR_CODES } = require('redweb/client')\nconst socket = new WebSocket(\n 'wss://game.example/match?redwebVersion=2'\n)\nconst client = new ProtocolClient(socket, '2')\n\nsocket.addEventListener('message', (event) => {\n const message = client.parse(event)\n if (message.error?.code === ERROR_CODES.RATE_LIMITED) backOff()\n})\n\nclient.send('move', { x: 4, y: 2 }, { sequence: 17 })\n```\n\n## Notes and boundaries\n\n- Browsers negotiate with redwebVersion in the query.\n- requestId correlates; sequence expresses application ordering.\n- Neither field promises durability or exactly-once delivery.\n",
|
|
647
|
+
"url": "/docs/reference/0.13.2/examples/protocol.md",
|
|
648
|
+
"sha256": "940d9878560088102c4f10fe58c7ce7c21456e2c4c3f74a985edaa838c4bba2a"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
"id": "examples/distribution",
|
|
652
|
+
"title": "Bring your own broker adapter",
|
|
653
|
+
"summary": "Redweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.",
|
|
654
|
+
"source": "docs/reference.json",
|
|
655
|
+
"markdown": "# Bring your own broker adapter\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nRedweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nconst { SocketRoute } = require('redweb')\n\nclass DistributedMatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n rooms: true,\n distribution: {\n adapter: brokerAdapter,\n channel: 'matches',\n nodeId: process.env.INSTANCE_ID,\n required: true,\n maxEventBytes: 64 * 1024,\n maxConcurrentPublishes: 32,\n onEvent(event, route) {\n route.rooms.broadcast(event.payload.roomId, {\n type: event.type,\n payload: event.payload,\n })\n },\n },\n })\n }\n}\n\n// From a connected socket:\nawait socket.publishEvent('match:update', update)\n```\n\n## Notes and boundaries\n\n- Required adapters affect readiness; best-effort adapters do not.\n- Source-node events are ignored to prevent reflection loops.\n- Authoritative state and partition reconciliation remain application work.\n",
|
|
656
|
+
"url": "/docs/reference/0.13.2/examples/distribution.md",
|
|
657
|
+
"sha256": "b8aa72c81d996815acc9c71329a65cb3e0630d3f5a378b4450602a5745d187cd"
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
"id": "examples/draining",
|
|
661
|
+
"title": "Readiness first, then bounded shutdown",
|
|
662
|
+
"summary": "Stop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.",
|
|
663
|
+
"source": "docs/reference.json",
|
|
664
|
+
"markdown": "# Readiness first, then bounded shutdown\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nStop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.\n\nThis pattern demonstrates one API area. Application-specific names, credentials, assets, and policies may need to be supplied. Complete starter recipes include all required application files.\n\n```js\nconst { HttpServer, SocketServer } = require('redweb')\n\nconst http = new HttpServer({ listen: false })\nconst socketServer = new SocketServer({\n server: http.server,\n routes: [MatchRoute],\n})\n\nhttp.app.get('/ready', (_request, response) => {\n response.sendStatus(socketServer.isReady() ? 200 : 503)\n})\n\nhttp.server.listen(3000)\n\nprocess.once('SIGTERM', async () => {\n socketServer.beginDrain()\n await stopExternalPlacement()\n await socketServer.shutdown()\n await http.shutdown()\n})\n\n// In a handler with drainHandlers: true\nawait saveCheckpoint({ signal: socket.context.signal })\n```\n\n## Notes and boundaries\n\n- New upgrades receive 503 once draining starts.\n- The route signal is shared through socket.context.signal.\n- A hard deadline terminates non-cooperating peers.\n",
|
|
665
|
+
"url": "/docs/reference/0.13.2/examples/draining.md",
|
|
666
|
+
"sha256": "ff4d39ae2c60ce3170311d535239624ee42231cfa4ef7c4f7ec9b991ce21f736"
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
"id": "api/httpserver",
|
|
670
|
+
"title": "HttpServer",
|
|
671
|
+
"summary": "Wraps Express with sensible defaults (JSON body parsing, CORS, and static asset folders) and starts listening immediately unless `listen: false` is supplied. You get the underlying Express instance back via `app`.",
|
|
672
|
+
"source": "docs/reference.json",
|
|
673
|
+
"markdown": "# HttpServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nWraps Express with sensible defaults (JSON body parsing, CORS, and static asset folders) and starts listening immediately unless `listen: false` is supplied. You get the underlying Express instance back via `app`.\n\n## Explain it like I’m five\n\nThink of HttpServer as a furnished storefront: Express is the building, while Redweb installs the front door, service counter, signs, and sensible safety rails before you open.\n\n## When should I use it?\n\nChoose it when Redweb should own a normal HTTP listener and you still want direct access to Express for middleware or one-off routes.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { HttpServer, METHODS } = require('redweb')\n\nconst server = new HttpServer({\n port: 4000,\n bind: '0.0.0.0',\n publicPaths: ['./static'],\n services: [\n { serviceName: '/ping', method: METHODS.GET, function: (req, res) => res.json({ pong: true }) },\n ],\n})\n\n// Express is still available:\nserver.app.get('/health', (req, res) => res.send('ok'))\n```\n\n1. Redweb creates the Express application and installs the configured parsers, CORS policy, static folders, and services.\n2. The server binds to the requested interface unless listen is false.\n3. The app property remains the same Express application, so adding /health does not require a Redweb abstraction.\n\n## Options\n\n- port: number (default 80)\n- bind: string (default 0.0.0.0)\n- publicPaths: string[] (default [\"./public\"])\n- services: array of { serviceName, method, function }\n- listen: boolean (default true); set false to build app without binding a port\n- listenCallback: function invoked after listen\n- encoding: \"json\" | \"urlencoded\" (default json)\n- corsOptions: passed to cors\n\n## Methods and members\n\n### constructor(options)\n\nMerges defaults, wires body parsing, CORS, static serving, registers REST services, and starts listening unless listen is false.\n\n### app (Express instance)\n\nUse the returned `app` to add middleware or routes exactly like a normal Express server.\n\n## What should I watch for?\n\nUse listen: false when another object must own the Node listener; two owners trying to bind the same port is an application design error.\n",
|
|
674
|
+
"url": "/docs/reference/0.13.2/api/httpserver.md",
|
|
675
|
+
"sha256": "a67cfff212a0ddad41b1606f9cef6ea33a9d9f055a4f61ae98887bdcad2244ac"
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
"id": "api/basehttpserver",
|
|
679
|
+
"title": "BaseHttpServer",
|
|
680
|
+
"summary": "Public Express app builder used by HttpServer and HttpsServer. Use it for advanced composition when you want Redweb middleware, static files, and services without any listener behavior.",
|
|
681
|
+
"source": "docs/reference.json",
|
|
682
|
+
"markdown": "# BaseHttpServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nPublic Express app builder used by HttpServer and HttpsServer. Use it for advanced composition when you want Redweb middleware, static files, and services without any listener behavior.\n\n## Explain it like I’m five\n\nBaseHttpServer prepares the kitchen but does not open the restaurant. You get a fully arranged Express app and decide which Node server will serve it.\n\n## When should I use it?\n\nUse it for custom composition, tests, serverless adapters, or any setup where creating and listening on the HTTP server belongs to your application.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { BaseHttpServer, METHODS } = require('redweb')\n\nconst base = new BaseHttpServer({\n publicPaths: ['./public'],\n services: [\n { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})\n\nbase.app.get('/extra', (req, res) => res.send('ok'))\n```\n\n1. The constructor configures either your Express app or a new one.\n2. Static folders and service definitions are registered in deterministic order.\n3. You pass base.app to http.createServer, a test harness, or another host when you are ready.\n\n## Options\n\n- All `HttpServer` app-building options\n- server: existing Express application to configure\n- listen is ignored because BaseHttpServer never binds a port\n\n## Methods and members\n\n### constructor(options)\n\nBuilds or configures an Express app with body parsing, CORS, static serving, and REST services.\n\n### app (Express instance)\n\nThe configured Express application. Pass it to http.createServer(app) for custom server ownership.\n\n## What should I watch for?\n\nIt intentionally ignores listener settings. If requests are not arriving, confirm that your own server is listening and forwarding them to base.app.\n",
|
|
683
|
+
"url": "/docs/reference/0.13.2/api/basehttpserver.md",
|
|
684
|
+
"sha256": "0a64e9f0c24b9a72185b0cce192db41f3e698095f26377620edd6f1e0d5fafab"
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
"id": "api/httpsserver",
|
|
688
|
+
"title": "HttpsServer",
|
|
689
|
+
"summary": "TLS-enabled variant of `HttpServer`. Accepts `ssl.key` and `ssl.cert` file paths, wraps them in an https server, and bootstraps the same middleware pipeline.",
|
|
690
|
+
"source": "docs/reference.json",
|
|
691
|
+
"markdown": "# HttpsServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nTLS-enabled variant of `HttpServer`. Accepts `ssl.key` and `ssl.cert` file paths, wraps them in an https server, and bootstraps the same middleware pipeline.\n\n## Explain it like I’m five\n\nHttpsServer is HttpServer with a locked, encrypted front door. It reads your certificate and key, then serves the same Express application through TLS.\n\n## When should I use it?\n\nUse it when the Node process terminates TLS itself instead of sitting behind a reverse proxy or managed load balancer.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { HttpsServer, METHODS } = require('redweb')\n\nnew HttpsServer({\n port: 4443,\n ssl: { key: './certs/dev.key', cert: './certs/dev.crt' },\n services: [\n { serviceName: '/secure', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})\n```\n\n1. The key and certificate files are loaded before the listener starts.\n2. The standard HTTP middleware and services are attached to the Express app.\n3. The resulting HTTPS server binds on the configured port and handles encrypted requests.\n\n## Options\n\n- All `HttpServer` options\n- ssl.key: path to private key (required)\n- ssl.cert: path to certificate (required)\n\n## Methods and members\n\n### constructor(options)\n\nLoads the provided key/cert pair, builds the Express app with BaseHttpServer, then creates and starts the HTTPS listener unless listen is false.\n\n## What should I watch for?\n\nCertificate rotation, filesystem permissions, and secure protocol policy remain deployment concerns. Behind a TLS-terminating proxy, HttpServer is usually simpler.\n",
|
|
692
|
+
"url": "/docs/reference/0.13.2/api/httpsserver.md",
|
|
693
|
+
"sha256": "c7101e8f312a2f09314b79b854c6fe02b15afc986aae8d9d57fd7ad6b27dade0"
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
"id": "api/socketserver",
|
|
697
|
+
"title": "SocketServer",
|
|
698
|
+
"summary": "HTTP-upgrade WebSocket server on top of `ws`. Builds and listens on its own HTTP server by default; if you pass a Node `server`, it attaches upgrade handling and leaves `.listen()` to you unless `listen: true` is explicit.",
|
|
699
|
+
"source": "docs/reference.json",
|
|
700
|
+
"markdown": "# SocketServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nHTTP-upgrade WebSocket server on top of `ws`. Builds and listens on its own HTTP server by default; if you pass a Node `server`, it attaches upgrade handling and leaves `.listen()` to you unless `listen: true` is explicit.\n\n## Explain it like I’m five\n\nSocketServer is a switchboard for persistent conversations. It accepts a WebSocket upgrade, finds the route for that path, and lets that route handle the connection.\n\n## When should I use it?\n\nChoose it for ws:// endpoints or when a reverse proxy already handles TLS and you need one or more independently configured socket routes.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst http = require('http')\nconst { HttpServer, METHODS, SocketServer } = require('redweb')\n\nconst httpServer = new HttpServer({\n listen: false,\n publicPaths: ['./public'],\n services: [\n { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})\n\nconst server = http.createServer(httpServer.app)\n\nnew SocketServer({\n server,\n routes: [ChatRoute],\n})\n\nserver.listen(3030)\n```\n\n1. The HTTP server is built or reused according to the options.\n2. Upgrade requests are matched to routes instead of being sent to every handler.\n3. Each route owns its clients, handlers, services, limits, and cleanup lifecycle.\n\n## Options\n\n- port: number (default 3000)\n- listen: boolean (default true for owned servers); supplied servers do not listen unless explicitly true\n- server: existing http.Server to attach to without double-listening (optional)\n- routes: array of SocketRoute subclasses (defaults to a single DefaultRoute at \"/\")\n\n## Methods and members\n\n### constructor(options)\n\nCreates or reuses an HTTP server, instantiates supplied routes or a DefaultRoute, attaches upgrade handling, and starts listening only when Redweb owns the server or listen is explicitly true.\n\n### addRoute(RouteClass)\n\nInstantiate and register another `SocketRoute` at runtime.\n\n### handleUpgrade(req, socket, head)\n\nInternal: normalises the request path, picks a matching route (or \"/\"), and forwards the upgrade to that route's server.\n\n### shutdown()\n\nCloses all registered routes and the underlying HTTP server.\n\n## What should I watch for?\n\nWhen supplying an existing Node server, Redweb does not assume it should call listen. Make listener ownership explicit and shut down in the reverse order of startup.\n",
|
|
701
|
+
"url": "/docs/reference/0.13.2/api/socketserver.md",
|
|
702
|
+
"sha256": "e2e3e562a0aba90510821b93d82d4f4234e43fdc4b2b2df967b2d2653a636f85"
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
"id": "api/securesocketserver",
|
|
706
|
+
"title": "SecureSocketServer",
|
|
707
|
+
"summary": "HTTPS + WebSocket pairing. Mirrors `SocketServer` but wraps an HTTPS server built from the provided TLS files.",
|
|
708
|
+
"source": "docs/reference.json",
|
|
709
|
+
"markdown": "# SecureSocketServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nHTTPS + WebSocket pairing. Mirrors `SocketServer` but wraps an HTTPS server built from the provided TLS files.\n\n## Explain it like I’m five\n\nSecureSocketServer is the encrypted version of the WebSocket switchboard: clients use wss:// and the connection stays protected from the first handshake onward.\n\n## When should I use it?\n\nUse it when Redweb directly owns a TLS WebSocket listener rather than sharing an externally terminated HTTPS connection.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { SecureSocketServer } = require('redweb')\nconst { GameRoute } = require('./routes/GameRoute')\n\nnew SecureSocketServer({\n port: 3443,\n ssl: { key: './certs/dev.key', cert: './certs/dev.crt' },\n routes: [GameRoute],\n})\n```\n\n1. TLS material is loaded to create or configure the HTTPS listener.\n2. WebSocket upgrades travel through the same route selection used by SocketServer.\n3. Route handlers see ordinary Redweb sockets after the secure handshake completes.\n\n## Options\n\n- port: number (default 3000)\n- listen: boolean (default true for owned servers); supplied servers do not listen unless explicitly true\n- server: existing https.Server to attach to without double-listening (optional)\n- ssl.key and ssl.cert: required file paths\n- routes: array of SocketRoute subclasses\n\n## Methods and members\n\n### constructor(options)\n\nLoads TLS files or reuses a supplied HTTPS server, registers the provided routes, attaches upgrade handling, and starts listening only when Redweb owns the server or listen is explicitly true.\n\n### addRoute(RouteClass)\n\nSame runtime route attachment as `SocketServer`.\n\n### shutdown()\n\nStops routes, services, and the HTTPS listener.\n\n## What should I watch for?\n\nDo not duplicate TLS termination accidentally. If a proxy already provides wss:// publicly, attach SocketServer behind it and validate forwarded origin information.\n",
|
|
710
|
+
"url": "/docs/reference/0.13.2/api/securesocketserver.md",
|
|
711
|
+
"sha256": "2e70fb69938c01cb06ecc2ac5d2de5c6ac7233fb58ba51b6173b109fc06c52c7"
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
"id": "api/socketroute",
|
|
715
|
+
"title": "SocketRoute",
|
|
716
|
+
"summary": "Defines a WebSocket endpoint and owns its handlers, services, clients, and opt-in multiplayer policies. Routes can add bounded admission, transport limits, ordered work, heartbeat, rooms, resumable sessions, distribution, draining, metrics, and protocol negotiation without changing legacy routes.",
|
|
717
|
+
"source": "docs/reference.json",
|
|
718
|
+
"markdown": "# SocketRoute\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDefines a WebSocket endpoint and owns its handlers, services, clients, and opt-in multiplayer policies. Routes can add bounded admission, transport limits, ordered work, heartbeat, rooms, resumable sessions, distribution, draining, metrics, and protocol negotiation without changing legacy routes.\n\n## Explain it like I’m five\n\nA SocketRoute is a room with its own door and rules. The URL chooses the room; message.type chooses which handler inside the room receives the message.\n\n## When should I use it?\n\nCreate one whenever a WebSocket path represents a distinct protocol, trust boundary, workload, or group of multiplayer resources.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { SocketRoute } = require('redweb')\nconst { ChatHandler } = require('./handlers/ChatHandler')\nconst { ClockService } = require('./services/ClockService')\n\nclass ChatRoute extends SocketRoute {\n constructor() {\n super({\n path: '/chat',\n handlers: [ChatHandler],\n services: [ClockService],\n allowDuplicateConnections: true,\n websocketOptions: {\n maxPayload: 1024 * 1024,\n perMessageDeflate: false,\n },\n })\n }\n}\n```\n\n1. The /match path selects the route during the WebSocket upgrade.\n2. Admission and capacity checks run before the connection becomes an active client.\n3. Messages are dispatched by type to BaseHandler instances while route services and registries share the same lifecycle.\n\n## Options\n\n- path: WebSocket path (required)\n- handlers: array of handler classes (required)\n- services: array of SocketService subclasses (optional)\n- allowDuplicateConnections: allow multiple clients from the same IP\n- websocketOptions: options passed to ws WebSocketServer, such as maxPayload or perMessageDeflate\n- admission: authenticate, validate origins, and optionally place a client before upgrade\n- maxPendingUpgrades: finite concurrent admission/negotiation work (default 64)\n- limits: connection, message-rate, pending-message, and outbound-buffer ceilings\n- orderedMessages: serialize each connection through a bounded queue\n- heartbeat: one route-level half-open connection monitor\n- rooms and sessions: bounded grouping and expiring application-issued ownership\n- distribution: optional bounded broker adapter; no broker is bundled or required\n- drainHandlers: track handler work and expose a cooperative shutdown signal\n- protocol: version negotiation, stable envelopes/error codes, and optional binary codecs\n\n## Methods and members\n\n### constructor({ path, handlers, services, allowDuplicateConnections, websocketOptions })\n\nValidates input, instantiates handlers/services, sets up a `ws` server for the path with any websocketOptions, and registers connection listeners.\n\n### addHandler(HandlerClass)\n\nAdds another handler class unless a handler with the same name already exists.\n\n### handleConnection(socket, req)\n\nStores the client (deduping by IP unless allowed), decorates the socket with `sendJson`/`broadcast`, and wires close/error/message listeners.\n\n### handleMessage(socket, data)\n\nParses JSON text frames, finds the handler matching `data.type`; on success delegates to handler.handleMessage, otherwise replies with an error and closes the socket.\n\n### handleBinaryMessage(socket, buffer)\n\nHandles binary frames separately from JSON text frames and delegates to a handler selected by acceptsBinary(socket, buffer).\n\n### handleClose(socket, ip)\n\nRemoves the client from the registry and triggers an optional `connectionCloseCallback`.\n\n### shutdown()\n\nMarks the route draining, stops services, bounds handler/adapter cleanup, closes clients, and releases every route-owned resource.\n\n### beginDrain()\n\nFlips readiness and rejects new upgrades before shutdown work begins.\n\n### isReady()\n\nReports whether the route is accepting upgrades and any required distribution adapter is healthy.\n\n### publish(type, payload)\n\nPublishes through the optional bounded distribution adapter and resolves to a success boolean.\n\n### handleError(socket, error, ip)\n\nLogs socket errors; override for custom reporting.\n\n## What should I watch for?\n\nKeep routing decisions out of message.action branches. Prefer one route per protocol area and one handler per message type.\n",
|
|
719
|
+
"url": "/docs/reference/0.13.2/api/socketroute.md",
|
|
720
|
+
"sha256": "4376db0693515f6ad9dac4075b2c767f1e4616cf51de3af65b4968edccbe15bc"
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
"id": "api/socketservice",
|
|
724
|
+
"title": "SocketService",
|
|
725
|
+
"summary": "Route-scoped background worker. Used by `SocketRoute` to run ticks or lifecycle hooks tied to a specific route.",
|
|
726
|
+
"source": "docs/reference.json",
|
|
727
|
+
"markdown": "# SocketService\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nRoute-scoped background worker. Used by `SocketRoute` to run ticks or lifecycle hooks tied to a specific route.\n\n## Explain it like I’m five\n\nA SocketService is a helper that clocks in when its route starts and clocks out when the route stops, such as presence tracking or a periodic snapshot publisher.\n\n## When should I use it?\n\nUse it for route-scoped background behavior that needs explicit startup, shutdown, and access to the owning route.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { SocketService } = require('redweb')\n\nclass ClockService extends SocketService {\n constructor() { super('clock', 1000) }\n onTick() {\n this.route.clients.forEach((socket) => socket.sendJson({ type: 'time', now: Date.now() }))\n }\n}\n```\n\n1. The service is constructed with a stable name.\n2. SocketRoute starts it once the route is ready.\n3. Shutdown awaits the service so timers, subscriptions, and external connections cannot leak.\n\n## Methods and members\n\n### constructor(name, tickRateMs = null)\n\nStores a service id and optional tick interval; the interval is activated in onInit if onTick exists.\n\n### onInit(route)\n\nCalled once by SocketRoute, sets `this.route` and, if a tick interval was supplied, schedules recurring onTick execution.\n\n### onTick()\n\nOptional; implement to run on the configured interval.\n\n### onShutdown()\n\nClears the tick interval; extend for cleanup hooks.\n\n## What should I watch for?\n\nEvery resource acquired in start must have a bounded and idempotent release in stop. Avoid detached timers or promises that outlive the route.\n",
|
|
728
|
+
"url": "/docs/reference/0.13.2/api/socketservice.md",
|
|
729
|
+
"sha256": "8976d5c582f743c85cb746291daf3b2399cb729c5602028ffbdad3b42b662f19"
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
"id": "api/fixedstepservice",
|
|
733
|
+
"title": "FixedStepService",
|
|
734
|
+
"summary": "Route-scoped simulation clock that compensates for drift, prevents overlapping async ticks, bounds catch-up work, and reports dropped retained lag instead of replaying forever.",
|
|
735
|
+
"source": "docs/reference.json",
|
|
736
|
+
"markdown": "# FixedStepService\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nRoute-scoped simulation clock that compensates for drift, prevents overlapping async ticks, bounds catch-up work, and reports dropped retained lag instead of replaying forever.\n\n## Explain it like I’m five\n\nFixedStepService is a metronome for game logic. Even when the computer hesitates, it advances the simulation in measured beats without starting two beats at once.\n\n## When should I use it?\n\nUse it for authoritative simulations or periodic work that needs stable step sizes, bounded catch-up, and explicit handling of excessive lag.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { FixedStepService } = require('redweb')\n\nclass Simulation extends FixedStepService {\n constructor() { super('simulation', 50, 3) }\n async onTick(stepMs, tick) {\n await game.update(stepMs, tick)\n }\n}\n```\n\n1. The service schedules ticks using the configured fixed interval.\n2. A slow asynchronous tick finishes before another begins.\n3. Limited catch-up reduces drift, while old excess lag is reported and discarded instead of causing an endless spiral.\n\n## Methods and members\n\n### constructor(name, tickRateMs, maxCatchUpTicks?, maxRetainedLagMs?)\n\nCreates a fixed-step route service with finite catch-up and retained-lag limits.\n\n### onTick(stepMs, tick)\n\nImplement one simulation step. Async work never overlaps the next pulse.\n\n### onLagDropped(milliseconds)\n\nOptional observability hook called when retained lag is deliberately discarded.\n\n## What should I watch for?\n\nA fixed step does not make expensive work free. Measure onTick duration, set conservative catch-up limits, and keep network I/O outside the critical simulation path.\n",
|
|
737
|
+
"url": "/docs/reference/0.13.2/api/fixedstepservice.md",
|
|
738
|
+
"sha256": "2f12ec4a4f23892eab4f010986eefa1bd67cc4931938cd74cfb180f2901f1740"
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
"id": "api/roomregistry",
|
|
742
|
+
"title": "RoomRegistry",
|
|
743
|
+
"summary": "Bounded route-local connection groups. Sockets normally use joinRoom, leaveRoom, and roomBroadcast; disconnect cleanup removes all memberships and reclaims empty rooms.",
|
|
744
|
+
"source": "docs/reference.json",
|
|
745
|
+
"markdown": "# RoomRegistry\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nBounded route-local connection groups. Sockets normally use joinRoom, leaveRoom, and roomBroadcast; disconnect cleanup removes all memberships and reclaims empty rooms.\n\n## Explain it like I’m five\n\nRoomRegistry is a set of labeled group chats. A socket can join a label, and one message can be delivered to everyone carrying that label.\n\n## When should I use it?\n\nUse rooms for match participants, parties, regions, spectators, or any bounded route-local fan-out group.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nclass MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n rooms: { maxRooms: 1000, maxMembersPerRoom: 32 },\n })\n }\n}\n```\n\n1. A join handler adds the socket to the requested match room.\n2. The move handler broadcasts the accepted update to that room and can exclude the sender.\n3. Disconnect cleanup removes every membership and empty rooms are reclaimed automatically.\n\n## Methods and members\n\n### socket.joinRoom(roomId)\n\nIdempotently joins a bounded room and returns whether membership is active.\n\n### socket.leaveRoom(roomId)\n\nIdempotently leaves one room and reclaims it when empty.\n\n### socket.roomBroadcast(roomId, data, options?)\n\nSerializes once and sends to selected connected members.\n\n## What should I watch for?\n\nA room is a delivery group, not authoritative game state. Validate membership and permissions before broadcasting, and configure hard room and membership limits.\n",
|
|
746
|
+
"url": "/docs/reference/0.13.2/api/roomregistry.md",
|
|
747
|
+
"sha256": "d98972ed91ec35ffcdd27e36e73357837f547deecac3a77e3167dbd9a09c9dfe"
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
"id": "api/sessionregistry",
|
|
751
|
+
"title": "SessionRegistry",
|
|
752
|
+
"summary": "Bounded, expiring ownership records for application-issued opaque session IDs. Redweb handles takeover and expiry; the application owns credential issuance and payload validation.",
|
|
753
|
+
"source": "docs/reference.json",
|
|
754
|
+
"markdown": "# SessionRegistry\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nBounded, expiring ownership records for application-issued opaque session IDs. Redweb handles takeover and expiry; the application owns credential issuance and payload validation.\n\n## Explain it like I’m five\n\nSessionRegistry is a numbered coat-check ticket. A reconnecting player presents the ticket and safely takes ownership of the stored session from an older connection.\n\n## When should I use it?\n\nUse it for short reconnect windows, controlled connection takeover, and small pieces of application-issued resumable state.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nclass MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n sessions: { ttlMs: 30000, maxSessions: 10000 },\n })\n }\n}\n```\n\n1. The application creates an opaque session ID and stores bounded state against the connected socket.\n2. After a disconnect, the record remains available for the configured TTL.\n3. Resume atomically transfers ownership and closes the former owner if it is still connected.\n\n## Methods and members\n\n### socket.createSession(sessionId, data)\n\nCreates a bounded application-issued session owned by the current connection.\n\n### socket.resumeSession(sessionId)\n\nAtomically transfers ownership, closes the former owner, and returns stored data.\n\n### stop()\n\nStops the one route-level sweep timer and clears every retained record.\n\n## What should I watch for?\n\nSession IDs are credentials: issue them securely, never trust client-selected identity, limit stored data, and persist important state outside this in-memory registry.\n",
|
|
755
|
+
"url": "/docs/reference/0.13.2/api/sessionregistry.md",
|
|
756
|
+
"sha256": "3a2d18b06ff499e641005db21b7396699e46ce8ecda2aa8aa720fc8ac3e2394c"
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
"id": "api/protocolclient",
|
|
760
|
+
"title": "ProtocolClient",
|
|
761
|
+
"summary": "Dependency-free helper from redweb/client for opt-in versioned routes. It builds, sends, and validates stable envelopes from the same checked-in schema used by server constants and TypeScript declarations.",
|
|
762
|
+
"source": "docs/reference.json",
|
|
763
|
+
"markdown": "# ProtocolClient\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDependency-free helper from redweb/client for opt-in versioned routes. It builds, sends, and validates stable envelopes from the same checked-in schema used by server constants and TypeScript declarations.\n\n## Explain it like I’m five\n\nProtocolClient is a phrasebook shared with the browser. It puts outgoing messages into Redweb’s expected envelope and checks incoming envelopes before your code trusts them.\n\n## When should I use it?\n\nUse it for opt-in versioned routes when browser clients should share protocol constants, parsing, sequencing, and error handling with the server.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { ProtocolClient, ERROR_CODES } = require('redweb/client')\n\nconst socket = new WebSocket('wss://game.example/match?redwebVersion=1')\nconst client = new ProtocolClient(socket, '1')\nclient.send('move', { x: 4, y: 2 }, { sequence: 17 })\n```\n\n1. The client connects to the versioned route and creates a ProtocolClient for that version.\n2. send builds a stable typed envelope containing payload and optional sequence metadata.\n3. parse validates server messages so application code can respond to known errors such as rate limiting.\n\n## Methods and members\n\n### constructor(socket, version)\n\nWraps any socket-like object with send(data) and selects the envelope version.\n\n### envelope(type, payload, metadata?)\n\nBuilds a stable versioned event with optional requestId and sequence.\n\n### send(type, payload, metadata?)\n\nSerializes and sends one versioned event through the wrapped socket.\n\n### parse(value)\n\nParses and validates a protocol event or error envelope.\n\n## What should I watch for?\n\nProtocol validation is not domain validation. Continue checking payload shape, authorization, and game rules on the authoritative server.\n",
|
|
764
|
+
"url": "/docs/reference/0.13.2/api/protocolclient.md",
|
|
765
|
+
"sha256": "9d1681319811175747557e818cb58dfdedd2569ca00e66e280fb172920081b85"
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
"id": "api/socketregistry",
|
|
769
|
+
"title": "SocketRegistry",
|
|
770
|
+
"summary": "Small EventEmitter-backed list for socket-scoped entities (players, rooms, etc.). Emits `added` and `removed` events.",
|
|
771
|
+
"source": "docs/reference.json",
|
|
772
|
+
"markdown": "# SocketRegistry\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nSmall EventEmitter-backed list for socket-scoped entities (players, rooms, etc.). Emits `added` and `removed` events.\n\n## Explain it like I’m five\n\nSocketRegistry is the route’s attendance sheet. It knows which sockets are active and provides a controlled way to visit or disconnect them.\n\n## When should I use it?\n\nUse it when route logic needs bounded connection tracking, fan-out, observability, or coordinated draining.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { SocketRegistry } = require('redweb')\n\nclass PlayerRegistry extends SocketRegistry {\n addPlayer(player) {\n this.add(player)\n this.emit('playerJoined', player)\n }\n}\n```\n\n1. Accepted connections are registered once and removed during close cleanup.\n2. Iteration works over the route-owned set rather than an application-global list.\n3. Shutdown can stop admissions, notify clients, and close the remaining registry deterministically.\n\n## Methods and members\n\n### add(item)\n\nStores the item and emits an `added` event.\n\n### remove(itemOrId, by = \"id\")\n\nRemove by object reference or by matching a property (defaults to \"id\"); returns true when removal occurred and emits `removed`.\n\n### all()\n\nReturns a shallow copy of all stored items.\n\n### count()\n\nConvenience getter for `all().length`.\n\n## What should I watch for?\n\nDo not retain sockets in parallel collections without cleanup. Prefer rooms or socket context for indexes with clear ownership.\n",
|
|
773
|
+
"url": "/docs/reference/0.13.2/api/socketregistry.md",
|
|
774
|
+
"sha256": "076e269529798ef710d90d26a69c27295b98139d92eb6d5fe047d389c3ba88b0"
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
"id": "api/basehandler",
|
|
778
|
+
"title": "BaseHandler",
|
|
779
|
+
"summary": "Abstract message handler. Provide a name in the constructor; clients send `{ type: name, ... }` to target JSON messages, while binary frames can be accepted and handled as raw Buffer payloads.",
|
|
780
|
+
"source": "docs/reference.json",
|
|
781
|
+
"markdown": "# BaseHandler\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nAbstract message handler. Provide a name in the constructor; clients send `{ type: name, ... }` to target JSON messages, while binary frames can be accepted and handled as raw Buffer payloads.\n\n## Explain it like I’m five\n\nBaseHandler is a labeled mailbox. A message with the matching type goes directly into that mailbox, so your code does not need a switch statement.\n\n## When should I use it?\n\nCreate a small handler for each message type that deserves its own validation, authorization, rate policy, and behavior.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { BaseHandler } = require('redweb')\n\nclass UploadHandler extends BaseHandler {\n constructor() { super('upload') }\n\n onMessage(socket, message) {\n socket.sendJson({ type: 'upload:control', action: message.action })\n }\n\n acceptsBinary(socket, buffer) {\n return buffer.length > 0\n }\n\n onBinaryMessage(socket, buffer) {\n socket.sendJson({ type: 'upload:chunk', bytes: buffer.length })\n }\n}\n```\n\n1. The handler name declares the message type it accepts.\n2. SocketRoute performs dispatch before onMessage runs.\n3. The handler validates the payload, changes authoritative state, and sends or broadcasts the result.\n\n## Methods and members\n\n### constructor(name)\n\nStores the handler name used by incoming messages.\n\n### handleMessage(socket, message)\n\nCalls onMessage; override only if you need pre/post handling logic.\n\n### onMessage(socket, message)\n\nRequired; implement your message processing here. Throwing will close the socket with an error.\n\n### acceptsBinary(socket, buffer)\n\nOptional selector used by SocketRoute to choose a handler for binary frames. Return true when this handler should receive the Buffer.\n\n### handleBinaryMessage(socket, buffer)\n\nCalls onBinaryMessage. If onBinaryMessage is not implemented, Redweb sends a \"Binary messages are not supported by this handler\" error.\n\n### onBinaryMessage(socket, buffer)\n\nOptional; override for normal binary-message handling. The second argument is the raw Buffer payload.\n\n### onInitialContact(socket)\n\nOptional hook for first-touch logic (not used by default route).\n\n## What should I watch for?\n\nDo not add a second message.action dispatcher inside one handler. That hides protocol operations and defeats type-based routing.\n",
|
|
782
|
+
"url": "/docs/reference/0.13.2/api/basehandler.md",
|
|
783
|
+
"sha256": "d3f4e2a58956c529546a769a264c104967a81c7500bd98e6c783d0b57e5019a8"
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
"id": "api/sendjson",
|
|
787
|
+
"title": "sendJson",
|
|
788
|
+
"summary": "Utility to JSON.stringify data and send it over a `ws` socket.",
|
|
789
|
+
"source": "docs/reference.json",
|
|
790
|
+
"markdown": "# sendJson\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nUtility to JSON.stringify data and send it over a `ws` socket.\n\n## Explain it like I’m five\n\nsendJson is a careful packer: give it a JavaScript value and it turns that value into one JSON message before placing it on the socket.\n\n## When should I use it?\n\nUse it for structured server-to-client messages instead of repeating JSON.stringify and transport checks throughout handlers.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { sendJson } = require('redweb')\n\nsendJson(socket, { type: 'ping' })\n```\n\n1. Application code creates a normal object with a type and payload.\n2. sendJson serializes it once using the route’s safe sending path.\n3. The client receives one complete text frame and parses the matching JSON value.\n\n## Methods and members\n\n### sendJson(socket, data)\n\nSerialises data and writes it to the socket.\n\n## What should I watch for?\n\nSerialization can fail on cycles and BigInt values, and large objects still consume memory. Bound payload sizes and keep messages purpose-specific.\n",
|
|
791
|
+
"url": "/docs/reference/0.13.2/api/sendjson.md",
|
|
792
|
+
"sha256": "34ba0b0dbfec779abc3f75c07b5c699ae1086df575e368998e54f7fce44b7416"
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
"id": "api/errorcodes",
|
|
796
|
+
"title": "ERROR_CODES",
|
|
797
|
+
"summary": "Stable framework error codes shared by protocol-enabled servers and redweb/client.",
|
|
798
|
+
"source": "docs/reference.json",
|
|
799
|
+
"markdown": "# ERROR_CODES\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nStable framework error codes shared by protocol-enabled servers and redweb/client.\n\n## Explain it like I’m five\n\nERROR_CODES is a shared list of machine-readable reasons, like standardized traffic signs that every client interprets the same way.\n\n## When should I use it?\n\nUse these constants whenever application behavior depends on a Redweb protocol failure rather than human-facing wording.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { ERROR_CODES } = require('redweb')\n\n// INVALID_MESSAGE, UNKNOWN_HANDLER, HANDLER_FAILED,\n// BINARY_UNSUPPORTED, RATE_LIMITED, QUEUE_FULL,\n// CAPACITY_REACHED, INITIALIZATION_FAILED\n```\n\n1. The server emits a stable code inside its error envelope.\n2. The client compares it with ERROR_CODES instead of copying a string literal.\n3. UI or retry policy can change independently of the readable error message.\n\n## Methods and members\n\n### Message errors\n\nINVALID_MESSAGE, UNKNOWN_HANDLER, HANDLER_FAILED, and BINARY_UNSUPPORTED.\n\n### Capacity errors\n\nRATE_LIMITED, QUEUE_FULL, and CAPACITY_REACHED.\n\n### Lifecycle errors\n\nINITIALIZATION_FAILED.\n\n## What should I watch for?\n\nCodes describe protocol outcomes, not every domain failure. Add your own namespaced application codes without changing Redweb’s meanings.\n",
|
|
800
|
+
"url": "/docs/reference/0.13.2/api/errorcodes.md",
|
|
801
|
+
"sha256": "2858b98862a5f70725976291e359f8e8baf62e1003736ac2274a3bf3fe671fbe"
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
"id": "api/socketoptions",
|
|
805
|
+
"title": "SOCKET_OPTIONS",
|
|
806
|
+
"summary": "Default WebSocket server options used by BaseSocketServer.",
|
|
807
|
+
"source": "docs/reference.json",
|
|
808
|
+
"markdown": "# SOCKET_OPTIONS\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDefault WebSocket server options used by BaseSocketServer.\n\n## Explain it like I’m five\n\nSOCKET_OPTIONS is the default settings card Redweb starts from before applying the socket choices you provide.\n\n## When should I use it?\n\nRead it to understand defaults or build tooling that presents Redweb configuration, but pass explicit options for production decisions.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { SOCKET_OPTIONS } = require('redweb')\n// { port: 3000, ssl: null, listen: true, routes: [] }\n```\n\n1. Redweb begins with immutable documented defaults.\n2. User configuration overrides only the supplied fields.\n3. The normalized result is used consistently when the socket server starts.\n\n## Methods and members\n\n### port\n\nDefault WebSocket port (3000).\n\n### ssl\n\nDefault TLS config (null).\n\n### listen\n\nOwned socket servers listen by default. Supplied servers remain caller-owned unless listen is explicitly true.\n\n### routes\n\nRoutes array default (empty; a DefaultRoute is created when none are provided).\n\n## What should I watch for?\n\nTreat exported defaults as documentation, not mutable global configuration. Never change the object to configure one server.\n",
|
|
809
|
+
"url": "/docs/reference/0.13.2/api/socketoptions.md",
|
|
810
|
+
"sha256": "6a16197d1110c65f152ee071128c9286aa7d4b8b58e7a60b6f3e75589114b9e9"
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
"id": "api/httpoptions",
|
|
814
|
+
"title": "HTTP_OPTIONS",
|
|
815
|
+
"summary": "Frozen defaults used by the HTTP and HTTPS server constructors.",
|
|
816
|
+
"source": "docs/reference.json",
|
|
817
|
+
"markdown": "# HTTP_OPTIONS\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nFrozen defaults used by the HTTP and HTTPS server constructors.\n\n## Explain it like I’m five\n\nHTTP_OPTIONS is Redweb’s starter checklist for HTTP servers: port, bind address, public folders, encoding, and related defaults.\n\n## When should I use it?\n\nConsult it when you need to know what omitted HttpServer options mean or when generating configuration documentation.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { HTTP_OPTIONS } = require('redweb')\n// port 80, bind 0.0.0.0, publicPaths ['./public'],\n// listen true, encoding 'json', and safe error exposure disabled\n```\n\n1. The server copies its baseline HTTP choices.\n2. Your provided values are validated and merged.\n3. The completed configuration drives app creation and optional listener startup.\n\n## Methods and members\n\n### Server defaults\n\nport, bind, listen, ssl, logger, and static public paths.\n\n### Application defaults\n\nservices, encoding, CORS, and safe error exposure.\n\n## What should I watch for?\n\nDefaults are convenient locally but production networking should be explicit, especially bind address, port, CORS, and listener ownership.\n",
|
|
818
|
+
"url": "/docs/reference/0.13.2/api/httpoptions.md",
|
|
819
|
+
"sha256": "22a2b302afb9fc452e6e3db6967a14d71f48ed5c827653b03fc4a7d13d7ef14e"
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
"id": "api/encodings",
|
|
823
|
+
"title": "ENCODINGS",
|
|
824
|
+
"summary": "Supported request-body parser names for HTTP server configuration.",
|
|
825
|
+
"source": "docs/reference.json",
|
|
826
|
+
"markdown": "# ENCODINGS\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nSupported request-body parser names for HTTP server configuration.\n\n## Explain it like I’m five\n\nENCODINGS is a tiny menu that lets you choose whether request bodies arrive as JSON or traditional URL-encoded form data.\n\n## When should I use it?\n\nUse the constants when configuring HTTP body parsing so spelling stays aligned with supported Redweb values.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { ENCODINGS } = require('redweb')\n// ENCODINGS.json, ENCODINGS.urlencoded\n```\n\n1. The selected constant is passed in HttpServer options.\n2. Redweb installs the corresponding Express body parser.\n3. Route handlers then read the parsed value from req.body.\n\n## Methods and members\n\n### json\n\nUse Express JSON body parsing.\n\n### urlencoded\n\nUse Express URL-encoded body parsing.\n\n## What should I watch for?\n\nBody parsing is not schema validation. Limit body size and validate every field before using it.\n",
|
|
827
|
+
"url": "/docs/reference/0.13.2/api/encodings.md",
|
|
828
|
+
"sha256": "faffdb3cbd159299207d23a7fb02a201e1672592ea78e9cf4d45359d448aefe3"
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
"id": "api/methods",
|
|
832
|
+
"title": "METHODS",
|
|
833
|
+
"summary": "Lowercase HTTP verb helpers passed straight to Express route registration.",
|
|
834
|
+
"source": "docs/reference.json",
|
|
835
|
+
"markdown": "# METHODS\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nLowercase HTTP verb helpers passed straight to Express route registration.\n\n## Explain it like I’m five\n\nMETHODS is a spelling-safe list of HTTP verbs—the labels on requests that say whether they read, create, replace, change, or delete something.\n\n## When should I use it?\n\nUse it in Redweb service definitions to avoid scattered uppercase strings and accidental unsupported verbs.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nconst { METHODS } = require('redweb')\n// METHODS.GET, METHODS.POST, METHODS.PUT, METHODS.DELETE\n```\n\n1. A service definition pairs its path with a METHODS value.\n2. BaseHttpServer registers the matching Express operation.\n3. Requests with another verb do not accidentally invoke that service.\n\n## Methods and members\n\n### GET\n\nUse with services array or Express: METHODS.GET\n\n### POST\n\nUse with services array or Express: METHODS.POST\n\n### PUT\n\nUse with services array or Express: METHODS.PUT\n\n### DELETE\n\nUse with services array or Express: METHODS.DELETE\n\n## What should I watch for?\n\nThe HTTP verb is only one part of API semantics. Implement authentication, idempotency, validation, and appropriate status codes in the service.\n",
|
|
836
|
+
"url": "/docs/reference/0.13.2/api/methods.md",
|
|
837
|
+
"sha256": "9ee649234284b8b1285987650bef36a2c4d44a53d60dc46e033cbdf5f9c2fbc4"
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
"id": "api/basesocketserver",
|
|
841
|
+
"title": "BaseSocketServer",
|
|
842
|
+
"summary": "Shared lifecycle and route-composition base for SocketServer and SecureSocketServer. Extend the concrete servers for normal applications; use this type when building infrastructure integrations.",
|
|
843
|
+
"source": "docs/reference.json",
|
|
844
|
+
"markdown": "# BaseSocketServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nShared lifecycle and route-composition base for SocketServer and SecureSocketServer. Extend the concrete servers for normal applications; use this type when building infrastructure integrations.\n\n## Explain it like I’m five\n\nBaseSocketServer is the engine room beneath both plain and secure socket servers. It coordinates routes and upgrades without deciding how the outer listener was created.\n\n## When should I use it?\n\nUse this advanced surface for custom integrations that need Redweb routing on a specially managed Node HTTP or HTTPS server.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```js\nimport { BaseSocketServer } from 'redweb'\n\n// SocketServer and SecureSocketServer inherit:\n// addRoute(), handleUpgrade(), beginDrain(), and shutdown().\n```\n\n1. Your application supplies or prepares the listener.\n2. BaseSocketServer attaches bounded WebSocket upgrade and route lifecycle behavior.\n3. Concrete server ownership remains visible, including whether Redweb may listen or close it.\n\n## Methods and members\n\n### addRoute(RouteClass)\n\nCreates and registers one route class while enforcing unique paths.\n\n### beginDrain()\n\nStops accepting new upgrades across every registered route.\n\n### shutdown()\n\nDrains routes and closes owned listeners without closing caller-owned servers.\n\n## What should I watch for?\n\nMost applications should use SocketServer or SecureSocketServer. Reach for the base class only when listener ownership cannot be expressed by their options.\n",
|
|
845
|
+
"url": "/docs/reference/0.13.2/api/basesocketserver.md",
|
|
846
|
+
"sha256": "c42255062dd8afc801e108c0d7c95d7ed897c661797f16de7bcac5ba8d12010b"
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
"id": "api/livehtmlserver",
|
|
850
|
+
"title": "LiveHtmlServer",
|
|
851
|
+
"summary": "Decorator-first server rendering and realtime browser updates on Redweb’s existing HTTP and WebSocket stack. Pages can be connection-scoped or intentionally shared.",
|
|
852
|
+
"source": "docs/reference.json",
|
|
853
|
+
"markdown": "# LiveHtmlServer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDecorator-first server rendering and realtime browser updates on Redweb’s existing HTTP and WebSocket stack. Pages can be connection-scoped or intentionally shared.\n\n## Explain it like I’m five\n\nLiveHtmlServer is a stage manager for server-rendered pages. It serves the first complete HTML scene, then carries approved actions backstage and sends updated pieces back.\n\n## When should I use it?\n\nUse it for decorator-first HTML applications that need server state and realtime interaction without React, hydration, or a separate client API layer.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\nimport { LiveHtmlServer } from 'redweb'\nimport { DocsPage, StatusPage } from './pages.js'\n\nconst server = new LiveHtmlServer({\n pages: [DocsPage, StatusPage],\n port: 8080,\n heartbeat: { intervalMs: 15_000, timeoutMs: 10_000 },\n})\n```\n\n1. HTTP rendering creates complete HTML and binds a stable page identity.\n2. The generated live socket accepts only declared actions for that page.\n3. State changes produce bounded updates while authentication and resource limits cover both transports.\n\n## Options\n\n- pages: non-empty array of classes decorated with page()\n- templateRoot: optional root for colocated HTML and CSS assets\n- sessionTtlMs and maxSessions: bound pending and reconnectable sessions\n- maxConcurrentRenders: independent HTTP render concurrency ceiling\n- heartbeat: detects half-open browser connections\n- authenticate: binds HTTP renders and socket upgrades to one stable identity\n- origins: exact allowlist or asynchronous origin predicate\n\n## Methods and members\n\n### constructor(options)\n\nBuilds the page renderer, HTTP routes, generated assets, and live WebSocket route on one listener.\n\n### shutdown()\n\nAborts active renders, drains routes, disposes pages and components, and closes owned resources.\n\n## What should I watch for?\n\nDecide deliberately whether page state is per connection or shared. Authenticate both HTTP and upgrade paths with the same identity and cap render concurrency.\n",
|
|
854
|
+
"url": "/docs/reference/0.13.2/api/livehtmlserver.md",
|
|
855
|
+
"sha256": "1910ac9b1587e6c89934a7e9886acb7a86b4c6d3131d1c7f098f7adcca5046e7"
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
"id": "api/livepage",
|
|
859
|
+
"title": "LivePage and start",
|
|
860
|
+
"summary": "A page is an ordinary decorated class; extending LivePage is optional. start() is the concise entry point that creates a LiveHtmlServer for one or more page classes.",
|
|
861
|
+
"source": "docs/reference.json",
|
|
862
|
+
"markdown": "# LivePage and start\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nA page is an ordinary decorated class; extending LivePage is optional. start() is the concise entry point that creates a LiveHtmlServer for one or more page classes.\n\n## Explain it like I’m five\n\nLivePage is one server-owned screen; start is the power button that publishes your collection of screens and their realtime connection.\n\n## When should I use it?\n\nUse a LivePage class when a route owns state, actions, lifecycle, and rendered output; use start to launch the assembled application.\n\n## Follow the example\n\nThis source is part of the [complete realtime recipe](/docs/reference/0.13.2/recipes/realtime.md). Follow its setup and tests.\n\n```tsx\nimport { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n1. The page decorator assigns the HTTP route and rendering metadata.\n2. A new page instance is created according to its configured scope.\n3. start builds the HTTP and socket surfaces, then returns a handle for orderly shutdown.\n\n## Methods and members\n\n### start(PageClass, options?)\n\nStarts one decorated page, or an array of pages, with the concise Live HTML server API.\n\n### loading(context)\n\nOptional cancellable hook that runs before the initial server render.\n\n### connected(context)\n\nOptional hook that runs after the authenticated live socket connects.\n\n### disconnected(context)\n\nOptional hook for stopping connection-owned timers and subscriptions.\n\n### disposed()\n\nOptional idempotent final cleanup hook for pages and components.\n\n## What should I watch for?\n\nKeep page constructors cheap and move cancellable preparation into lifecycle hooks. Always retain and await the returned shutdown handle.\n",
|
|
863
|
+
"url": "/docs/reference/0.13.2/api/livepage.md",
|
|
864
|
+
"sha256": "145e008ede5f604d8f303d147fc27b9083ba405cd9df16412e898d8d64910a35"
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
"id": "api/livedecorators",
|
|
868
|
+
"title": "page, component, state, action, view",
|
|
869
|
+
"summary": "Small TypeScript decorators declare routes, reusable component ownership, reactive server state, browser-callable actions, and collection item views.",
|
|
870
|
+
"source": "docs/reference.json",
|
|
871
|
+
"markdown": "# page, component, state, action, view\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nSmall TypeScript decorators declare routes, reusable component ownership, reactive server state, browser-callable actions, and collection item views.\n\n## Explain it like I’m five\n\nThe decorators are small labels: page says where a screen lives, component says what can be reused, state says what may change, action says what the browser may request, and view describes repeated items.\n\n## When should I use it?\n\nUse them to make server-rendered ownership visible next to the class member it affects instead of maintaining a separate routing and binding manifest.\n\n## Follow the example\n\nThis source is part of the [complete realtime recipe](/docs/reference/0.13.2/recipes/realtime.md). Follow its setup and tests.\n\n```tsx\nimport { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n```\n\n1. @page registers the outer route and page policy.\n2. @state and @action expose only explicitly declared reactive behavior.\n3. The counter renders ordinary TSX over its state; changes reach both tabs because the page explicitly opts into shared state. Use the chat recipe to explore reusable class components.\n\n## Methods and members\n\n### page(path, options?)\n\nRegisters a unique route plus template, CSS, sharing, metadata, caching, and live/static behavior.\n\n### component()\n\nMarks a class as a reusable state/action/lifecycle namespace.\n\n### component(render)\n\nCreates a concise synchronous function component for stateless reusable HTML.\n\n### state(options?)\n\nPublishes reassigned values; writable state may also receive bounded browser input.\n\n### action({ input? })\n\nExplicitly exposes one method to rw-click or rw-submit. An optional Standard Schema input validates and transforms one submitted argument before invocation; ActionInput<typeof schema> describes its output. Invalid input stays recoverable, while validator bugs remain server failures. Undecorated methods stay unreachable.\n\n### rw-status=\"action\"\n\nOptional component-scoped placement for built-in action feedback. Without a slot, buttons/forms get an automatic status message. Pending duplicates from one control are suppressed; late responses preserve changed drafts and replacement forms. Disconnected actions are never queued or replayed.\n\n### view(stateName)\n\nRenders each item of one decorated array for an rw-each collection.\n\n## What should I watch for?\n\nDecorators are an allow-list, not decoration. Keep actions narrow, validate their arguments, and avoid exposing arbitrary method invocation.\n",
|
|
872
|
+
"url": "/docs/reference/0.13.2/api/livedecorators.md",
|
|
873
|
+
"sha256": "da58596b695dc03cdcc27f0dc47678820f69e183eca349b47e489878946434c3"
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
"id": "api/jsxruntime",
|
|
877
|
+
"title": "JSX rendering",
|
|
878
|
+
"summary": "Dependency-free server-side TSX that renders directly to HtmlFragment values. It provides readable components, fragments, arrays, automatic escaping, safe attributes, and existing html-fragment interoperability without React, a virtual DOM, or hydration.",
|
|
879
|
+
"source": "docs/reference.json",
|
|
880
|
+
"markdown": "# JSX rendering\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDependency-free server-side TSX that renders directly to HtmlFragment values. It provides readable components, fragments, arrays, automatic escaping, safe attributes, and existing html-fragment interoperability without React, a virtual DOM, or hydration.\n\n## Explain it like I’m five\n\nRedweb JSX is a readable HTML-shaped pencil. It turns TSX into safe server HTML directly—there is no React engine or browser copy hiding behind it.\n\n## When should I use it?\n\nUse it whenever nested template strings become difficult to read or reusable server-rendered components make page structure clearer.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\n// tsconfig.json\n// { \"compilerOptions\": { \"jsx\": \"react-jsx\", \"jsxImportSource\": \"redweb\" } }\n\nimport { component, page } from 'redweb'\nimport type { Child } from 'redweb/jsx-runtime'\n\nconst Card = component((props: { title: string; children?: Child }) => (\n <article class=\"card\">\n <h2>{props.title}</h2>\n {props.children}\n </article>\n))\n\n@page('/docs', { css: 'docs.css', live: false })\nclass DocsPage {\n render() {\n return <main><Card title=\"Redweb\">Readable server TSX</Card></main>\n }\n}\n```\n\n1. TypeScript sends JSX calls to redweb/jsx-runtime.\n2. Text and attributes are escaped while fragments, arrays, and components are flattened predictably.\n3. The resulting HtmlFragment works with static pages and Live HTML without hydration.\n\n## Options\n\n- TypeScript: jsx = react-jsx and jsxImportSource = redweb\n- Production runtime: redweb/jsx-runtime; development runtime: redweb/jsx-dev-runtime\n- External CSS and rw-* server directives replace inline styles and browser event functions\n\n## Methods and members\n\n### Intrinsic elements\n\nSerialize standard, SVG, custom, data-*, aria-*, and rw-* attributes with HTML-correct boolean handling.\n\n### Fragments and arrays\n\nCompose nested fragments and readonly child arrays without wrapper markup or comma coercion.\n\n### Function components\n\nSynchronous functions receive typed props and children and must return an HtmlFragment or fragment array.\n\n### Escaping and URLs\n\nText and attributes escape automatically; URL attributes retain Redweb’s safe-protocol validation.\n\n### Interoperability\n\nExisting html fragments nest in TSX and TSX fragments nest in html for incremental migration.\n\n## What should I watch for?\n\nJSX is syntax, not automatic client reactivity. Only Live HTML state and actions create a realtime channel; static-site TSX remains zero-runtime HTML.\n",
|
|
881
|
+
"url": "/docs/reference/0.13.2/api/jsxruntime.md",
|
|
882
|
+
"sha256": "16bcb98d78beb910ae8f086b195f28d3606e6bb93ea5023a90b9e3b8a17f6f21"
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
"id": "api/safehtml",
|
|
886
|
+
"title": "html, attribute, url, each, codeBlock",
|
|
887
|
+
"summary": "Safe composition primitives escape text and quoted primitive attributes by default. URL attributes additionally reject executable, protocol-relative, and malformed values. Arrays must contain trusted HtmlFragment values.",
|
|
888
|
+
"source": "docs/reference.json",
|
|
889
|
+
"markdown": "# html, attribute, url, each, codeBlock\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nSafe composition primitives escape text and quoted primitive attributes by default. URL attributes additionally reject executable, protocol-relative, and malformed values. Arrays must contain trusted HtmlFragment values.\n\n## Explain it like I’m five\n\nThese helpers are different safety tools: html builds trusted structure, attribute and url escape risky contexts, each joins lists, and codeBlock displays code without executing it.\n\n## When should I use it?\n\nUse them for low-level templates, dynamic attributes, URLs, collections, and code samples when JSX is not the clearest representation.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\nimport { codeBlock, each, html } from 'redweb'\n\nconst links = sections.map(section => ({\n id: section.id,\n href: `#${section.id}`,\n label: section.name,\n}))\n\nconst navigation = each(links, link => html`\n <a id=\"${link.id}\" href=\"${link.href}\">${link.label}</a>\n`)\n\nconst example = codeBlock(source, {\n language: 'ts',\n label: 'TypeScript',\n highlight: highlightTypeScript,\n})\n```\n\n1. Untrusted values are passed through the helper matching their HTML context.\n2. HtmlFragment values preserve the distinction between approved markup and ordinary text.\n3. Collection and code helpers produce predictable escaped output without hand-built concatenation.\n\n## Methods and members\n\n### html`...`\n\nCreates an HtmlFragment and escapes every ordinary interpolation.\n\n### attribute(value)\n\nOptionally brands a primitive for a quoted, non-URL attribute when explicit intent improves readability.\n\n### url(value)\n\nOptionally brands a safe relative, HTTP, HTTPS, mail, or telephone URL; direct strings receive the same validation.\n\n### each(items, render)\n\nValidates and joins a mutable or readonly list of HtmlFragment results.\n\n### codeBlock(code, options?)\n\nBuilds an escaped figure/pre/code fragment and can invoke a safe server-side highlighter.\n\n## What should I watch for?\n\nEscaping is context-specific. Never treat an escaped attribute as a safe URL or mark user-provided HTML as trusted.\n",
|
|
890
|
+
"url": "/docs/reference/0.13.2/api/safehtml.md",
|
|
891
|
+
"sha256": "8548b0bc816ecc62de4427a866eacd7730c84dbffbb8025e8e0705833ac00131"
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
"id": "api/definesite",
|
|
895
|
+
"title": "defineSite",
|
|
896
|
+
"summary": "Defines shared static-site CSS, metadata, caching, layout, canonical URLs, and export behavior once. Site pages are always runtime-free.",
|
|
897
|
+
"source": "docs/reference.json",
|
|
898
|
+
"markdown": "# defineSite\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nDefines shared static-site CSS, metadata, caching, layout, canonical URLs, and export behavior once. Site pages are always runtime-free.\n\n## Explain it like I’m five\n\ndefineSite is the shared blueprint for a collection of pages: one place for the frame, colors, metadata, cache policy, canonical links, and export rules.\n\n## When should I use it?\n\nUse it for documentation, marketing, or content sites where many runtime-free pages should share layout and production behavior.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\nimport { defineSite } from 'redweb'\n\nconst docs = defineSite({\n origin: 'https://example.com',\n css: 'site.css',\n head: { description: 'Product documentation', image: '/og.png' },\n cache: { maxAge: 300 },\n layout: content => <body><nav>Product</nav><main>{content}</main></body>,\n})\n\n@docs.page('/docs', { head: { title: 'Documentation' } })\nclass DocsPage {\n render() { return <h1>Documentation</h1> }\n}\n\nawait docs.export(DocsPage, {\n outDir: 'dist',\n publicDir: 'public',\n})\n```\n\n1. The site definition establishes origin, common CSS, head metadata, cache policy, and layout.\n2. Decorated pages contribute their own route, title, description, and stylesheet.\n3. site.export stages every page and asset into one consistent static output.\n\n## Options\n\n- origin: optional HTTP(S) origin used to derive canonical and root-relative social-image URLs\n- css, head, cache, and layout: defaults inherited by every site.page() decorator\n- layout: synchronous function receiving the trusted page fragment and portable request context\n- publicDir: optional link-free asset tree staged with generated output\n\n## Methods and members\n\n### site.page(path, options?)\n\nCreates a non-live page decorator while merging shared defaults and page-specific overrides.\n\n### site.export(pageOrPages, options)\n\nStages all pages and public assets, rejects path collisions, then writes the destination and returns every output path.\n\n## What should I watch for?\n\nThe layout must handle every exported route. Keep route-specific chrome derived from stable metadata rather than scattered path checks.\n",
|
|
899
|
+
"url": "/docs/reference/0.13.2/api/definesite.md",
|
|
900
|
+
"sha256": "71e4af3f21d3c96f4683ba13a0b3985357d0ebbaa1ccbd2f312de15ffc00f188"
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
"id": "api/exportstatic",
|
|
904
|
+
"title": "exportStatic",
|
|
905
|
+
"summary": "Renders non-live decorated pages to deterministic directory indexes and content-addressed CSS. It is intended for docs, marketing pages, and static hosting.",
|
|
906
|
+
"source": "docs/reference.json",
|
|
907
|
+
"markdown": "# exportStatic\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nRenders non-live decorated pages to deterministic directory indexes and content-addressed CSS. It is intended for docs, marketing pages, and static hosting.\n\n## Explain it like I’m five\n\nexportStatic is a printing press: give it pages, and it writes finished HTML and assets that any ordinary static host can serve.\n\n## When should I use it?\n\nUse it when you need direct static export without defining a reusable site object and its shared layout policy.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\nimport path from 'node:path'\nimport { exportStatic, page } from 'redweb'\n\n@page('/docs', { live: false, css: 'docs.css' })\nclass DocsPage {\n render() { return '<h1>Redweb docs</h1>' }\n}\n\nawait exportStatic(DocsPage, {\n outDir: path.resolve('dist'),\n templateRoot: path.resolve('src/pages'),\n})\n```\n\n1. Page metadata determines output routes and page assets.\n2. Every page renders on the server into a staging destination.\n3. The returned paths let build tooling audit or publish exactly what was produced.\n\n## Options\n\n- outDir: required output directory; existing unrelated files are preserved\n- templateRoot: optional explicit root for templates and CSS\n- logger: optional framework logger or null\n\n## Methods and members\n\n### exportStatic(pageOrPages, options)\n\nReturns frozen page and asset path lists after every page renders and its CSS is written.\n\n## What should I watch for?\n\nExport into a staging directory and replace the live build atomically. A failed render should never leave a partially updated site.\n",
|
|
908
|
+
"url": "/docs/reference/0.13.2/api/exportstatic.md",
|
|
909
|
+
"sha256": "b9e899b545b6eacf4cc1d88960afe0ece57509549beaca557d677ca6477d99d7"
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
"id": "api/htmlrenderer",
|
|
913
|
+
"title": "HtmlRenderer",
|
|
914
|
+
"summary": "Lower-level rendering utility behind Live HTML. Most applications should use page(), start(), defineSite(), and exportStatic(); this surface supports advanced integrations and tooling.",
|
|
915
|
+
"source": "docs/reference.json",
|
|
916
|
+
"markdown": "# HtmlRenderer\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nLower-level rendering utility behind Live HTML. Most applications should use page(), start(), defineSite(), and exportStatic(); this surface supports advanced integrations and tooling.\n\n## Explain it like I’m five\n\nHtmlRenderer is the machinery under the hood that turns a page object and its declared bindings into final markup.\n\n## When should I use it?\n\nUse it for tooling or advanced integrations that genuinely need lower-level rendering control; normal applications should prefer pages, start, defineSite, or exportStatic.\n\n## Follow the example\n\nThis API pattern illustrates the named surface; it may require application-owned classes, credentials, or assets. Start from a complete recipe for a runnable application.\n\n```tsx\nimport { HtmlRenderer } from 'redweb'\n\nconst markup = HtmlRenderer.render(\n '<h1>{{ title }}</h1>',\n { title: 'Reference' },\n { live: false },\n)\n\nconst document = HtmlRenderer.document(markup, null, [], {\n title: 'Reference',\n})\n```\n\n1. A source template or fragment and page instance enter the renderer.\n2. Declared state, actions, views, and safe values are resolved under the render options.\n3. The caller receives final HTML suitable for its own response or build pipeline.\n\n## Methods and members\n\n### render(source, page, options?)\n\nRenders declarative bindings and collection views against a page object.\n\n### document(markup, config?, stylesheets?, metadata?)\n\nWraps markup in a complete document and injects metadata, CSS, and optional live bootstrap.\n\n### template() / stylesheet()\n\nLoads a validated page asset inside an explicit root.\n\n### statePayload()\n\nBuilds the text or trusted-HTML state payload used by live updates.\n\n## What should I watch for?\n\nThe lower-level API gives you more lifecycle responsibility. Preserve escaping, cancellation, ownership, and bounded concurrency rather than rebuilding them casually.\n",
|
|
917
|
+
"url": "/docs/reference/0.13.2/api/htmlrenderer.md",
|
|
918
|
+
"sha256": "f78643b7e85437cf5979c0e46f34ed74dea9d6eddf2564293b684cbe6fb68e58"
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
"id": "api/socketcontract",
|
|
922
|
+
"title": "defineSocketContract",
|
|
923
|
+
"summary": "One shared Standard Schema contract validates wire payloads and infers client/server types. Route URLs choose the service; individual handler factories dispatch by message type.",
|
|
924
|
+
"source": "docs/reference.json",
|
|
925
|
+
"markdown": "# defineSocketContract\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\nOne shared Standard Schema contract validates wire payloads and infers client/server types. Route URLs choose the service; individual handler factories dispatch by message type.\n\n## Explain it like I’m five\n\nA socket contract is a shared form for both sides of a conversation. It says which messages exist and what each message must contain, so the server checks a message before handing it to the matching handler.\n\n## When should I use it?\n\nUse it when a site, app, or game client should share payload types and runtime validation with a routed Redweb service.\n\n## Follow the example\n\nThis source is part of the [complete socket recipe](/docs/reference/0.13.2/recipes/socket.md). Follow its setup and tests.\n\n```ts\nimport { defineSocketContract } from 'redweb/contract';\nimport { z } from 'zod';\n\nconst position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };\n\n// Share this module with a browser or Node client. It imports no server application code.\nexport const match = defineSocketContract('1', {\n join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),\n move: z.object(position).strict(),\n resume: z.object({ session: z.string().uuid() }).strict(),\n state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),\n});\n```\n\n1. The shared module defines join, move, resume, and state payload schemas once.\n2. The match route registers separate handlers created by the contract instead of switching on a secondary action field.\n3. The client and server validate their outgoing messages and parse incoming envelopes against the same schema.\n\n## Methods and members\n\n### defineSocketContract(version, schemas, options?)\n\nCreates an immutable contract and negotiated protocol policy from Standard Schema validators. Redweb does not require a particular validator at runtime.\n\n### handler(type, callback)\n\nCreates a BaseHandler subclass. Validation completes before the callback receives its typed payload.\n\n### client(socket)\n\nWraps an existing WebSocket with typed, validated send and parse methods. It does not open or reconnect the connection.\n\n### send(socket, type, payload, metadata?)\n\nValidates server output before sending through the normal transport and protocol policy.\n\n## What should I watch for?\n\nSchema validation is not authentication or game-rule validation. Keep bearer session IDs private, apply authorization in handlers, and persist important data outside the starter's bounded in-memory sessions. Async validation deadlines cannot preempt synchronous JavaScript.\n",
|
|
926
|
+
"url": "/docs/reference/0.13.2/api/socketcontract.md",
|
|
927
|
+
"sha256": "176397c073d7ba58b028174d81e49f56223baa8637458b0e6ef16b6c285c5a93"
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
"id": "api-types",
|
|
931
|
+
"title": "Complete public TypeScript declarations",
|
|
932
|
+
"summary": "Exact shipped signatures, options, and public types; not standalone application snippets.",
|
|
933
|
+
"source": "index.d.ts",
|
|
934
|
+
"markdown": "# Public TypeScript API\n\n> Documentation for Redweb 0.13.2. Install that exact version when following these examples.\n\n## index.d.ts\n\n```ts\ndeclare module 'redweb' {\n export { defineSocketContract } from 'redweb/contract';\n export type { SocketContract, ContractClient, SocketSchema, ContractInput, ContractOutput, ContractMessage } from 'redweb/contract';\n import { Application, RequestHandler } from 'express';\n import { CorsOptions } from 'cors';\n import { Server as NodeHttpServer } from 'http';\n import { Server as NodeHttpsServer } from 'https';\n import { WebSocket, ServerOptions } from 'ws';\n import { Buffer } from 'buffer';\n import { EventEmitter } from 'events';\n\n /** ─────────────────── HTTP / CORE ─────────────────── */\n\n export type RedWebEncoding = 'json' | 'urlencoded';\n\n export interface RedWebOptions {\n port?: number;\n bind?: string;\n publicPaths?: string[];\n services?: Array<{ serviceName: string; method: string; function: RequestHandler }>;\n listen?: boolean;\n listenCallback?: () => void;\n encoding?: RedWebEncoding;\n ssl?: { key: string; cert: string };\n server?: Application;\n corsOptions?: CorsOptions | false;\n exposeErrors?: boolean;\n logger?: RedWebLogger | null;\n }\n\n export type RedWebSocket = WebSocket & {\n clientKey: string;\n __redwebClientKey: string;\n remoteAddress: string;\n isAssigned: boolean;\n sendJson(data: unknown): boolean;\n broadcast(data: unknown): number;\n context?: RedWebConnectionContext;\n joinRoom?(roomId: string): boolean;\n /** Bounded permission check followed by atomic membership insertion. */\n enterRoom?(roomId: string): Promise<boolean>;\n leaveRoom?(roomId: string): boolean;\n roomBroadcast?(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;\n createSession?(sessionId: string, data: unknown): boolean;\n resumeSession?(sessionId: string): unknown | null;\n publishEvent?(type: string, payload: unknown): Promise<boolean>;\n sendEvent?(type: string, payload: unknown, metadata?: ProtocolMetadata): boolean;\n sendProtocolError?(code: string, message: string, metadata?: ProtocolMetadata): boolean;\n sendBinaryEvent?(value: unknown): Promise<boolean>;\n };\n\n export interface RedWebConnectionContext extends RequestContext {\n readonly connectionId: string;\n readonly principal: unknown;\n session: unknown | null;\n metadata: Record<string, unknown>;\n readonly protocol?: Readonly<{ version: string }>;\n }\n\n export interface AdmissionContext {\n signal: AbortSignal;\n networkIdentity: string;\n route: SocketRoute;\n }\n\n export interface AdmissionOptions {\n authenticate?: (\n request: import('http').IncomingMessage,\n context: AdmissionContext\n ) => unknown | false | Promise<unknown | false>;\n origins?: string[] | ((\n origin: string | undefined,\n request: import('http').IncomingMessage\n ) => boolean | Promise<boolean>);\n place?: (\n principal: unknown,\n request: import('http').IncomingMessage,\n context: AdmissionContext\n ) => string | false | null | undefined | Promise<string | false | null | undefined>;\n allowedPlacementOrigins?: string[];\n allowInsecurePlacement?: boolean;\n timeoutMs?: number;\n }\n\n export interface MessageRateLimit {\n capacity: number;\n refillPerSecond: number;\n action?: 'drop' | 'disconnect';\n }\n\n export interface TransportLimits {\n maxConnections?: number;\n maxBufferedBytes?: number;\n maxPendingMessages?: number;\n messageRate?: MessageRateLimit;\n slowConsumerAction?: 'drop' | 'disconnect';\n }\n\n export interface HeartbeatOptions {\n intervalMs: number;\n timeoutMs: number;\n }\n\n export type RoomOptions = {\n maxRooms?: number;\n maxMembersPerRoom?: number;\n maxRoomsPerConnection?: number;\n maxRoomIdLength?: number;\n } & ({ authorize?: undefined; authorizationTimeoutMs?: never; maxPendingAuthorizations?: never; maxPendingPerConnection?: never } | {\n /** Grants subscription until explicit leave/disconnect; not per-message receive authorization. */\n authorize: (context: Readonly<RedWebConnectionContext>, roomId: string) => boolean | Promise<boolean>;\n authorizationTimeoutMs?: number;\n maxPendingAuthorizations?: number;\n maxPendingPerConnection?: number;\n });\n\n export interface SessionOptions {\n ttlMs?: number;\n maxSessions?: number;\n maxSessionIdLength?: number;\n sweepIntervalMs?: number;\n }\n\n export interface MetricsSink {\n increment?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;\n gauge?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;\n observe?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;\n }\n\n export interface DistributionEvent<T = unknown> {\n id: string;\n source: string;\n type: string;\n payload: T;\n }\n\n export interface DistributionAdapter {\n start?(signal?: AbortSignal): void | Promise<void>;\n publish(channel: string, serializedEvent: string, signal?: AbortSignal): void | Promise<void>;\n subscribe(\n channel: string,\n onEvent: (serializedEvent: string | DistributionEvent) => void,\n signal?: AbortSignal\n ): void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;\n unsubscribe?(channel: string, signal?: AbortSignal): void | Promise<void>;\n close?(signal?: AbortSignal): void | Promise<void>;\n }\n\n export interface DistributionOptions {\n adapter: DistributionAdapter;\n channel: string;\n nodeId?: string;\n maxEventBytes?: number;\n maxSeenEvents?: number;\n seenTtlMs?: number;\n lifecycleTimeoutMs?: number;\n publishTimeoutMs?: number;\n maxConcurrentPublishes?: number;\n maxConcurrentEvents?: number;\n required?: boolean;\n onEvent(event: DistributionEvent, route: SocketRoute): void | Promise<void>;\n }\n\n export interface ProtocolMetadata {\n requestId?: string;\n sequence?: number;\n }\n\n export interface ProtocolBinaryCodec {\n maxBytes?: number;\n encode(value: unknown, context: RedWebConnectionContext): Buffer | Uint8Array | ArrayBuffer | Promise<Buffer | Uint8Array | ArrayBuffer>;\n decode(buffer: Buffer, context: RedWebConnectionContext): unknown | Promise<unknown>;\n }\n\n export interface ProtocolOptions {\n versions: readonly string[];\n required?: boolean;\n queryParameter?: string;\n header?: string;\n binary?: false | ProtocolBinaryCodec;\n }\n\n /** ─────────────────── SOCKET SERVER ─────────────────── */\n\n export interface DevelopmentOptions {\n /** Explicit local-only inspection; rejected when NODE_ENV is production. */\n inspect?: boolean;\n }\n\n export interface LiveDevelopmentOptions extends DevelopmentOptions {\n /** Loopback-only browser refresh. False overrides REDWEB_DEV_REFRESH=1. */\n refresh?: boolean;\n }\n\n export interface InspectionList<T> {\n readonly items: readonly T[];\n readonly total: number;\n readonly truncated: boolean;\n }\n export type InspectionSection<T> = { readonly available: false } | ({ readonly available: true } & T);\n export interface InspectionMembers {\n readonly className: string;\n readonly actions: InspectionList<string>;\n readonly states: InspectionList<string>;\n }\n export interface InspectionPage extends InspectionMembers {\n readonly path: string;\n readonly live: boolean;\n readonly shared: boolean;\n readonly instanceMetadata: 'observed' | 'unobserved';\n readonly instances: InspectionList<{\n readonly id: number;\n readonly disposed: boolean;\n readonly components: InspectionList<InspectionMembers & { readonly id: string }>;\n }>;\n }\n export interface InspectionSession {\n /** Inspector-local IDs; never page tokens, credentials or socket IDs. */\n readonly render: number;\n readonly instance: number;\n readonly route: string;\n readonly status: 'connected' | 'detaching' | 'pending' | 'retained';\n readonly reactive: boolean;\n }\n export interface InspectionEvent {\n readonly sequence: number;\n readonly render: number;\n readonly route: string;\n readonly kind: 'state-invalidated' | 'flush-started' | 'flush-completed' | 'flush-superseded' | 'flush-failed';\n readonly state?: string;\n readonly component?: string;\n readonly affectedOwners?: InspectionList<string>;\n readonly snapshot?: boolean;\n readonly dirtyOwners?: InspectionList<string>;\n readonly durationMs?: number;\n }\n export interface DevelopmentSnapshot {\n readonly schemaVersion: 1;\n readonly mode: 'development';\n readonly pages: InspectionSection<{\n readonly registrations: InspectionList<InspectionPage>;\n readonly sessions: InspectionList<InspectionSession>;\n readonly closing?: boolean;\n readonly rendering?: number;\n readonly connections?: Readonly<Record<InspectionSession['status'], number>>;\n }>;\n readonly sockets: InspectionSection<{\n readonly routes: InspectionList<{\n readonly path: string;\n readonly handlers: InspectionList<string>;\n readonly registeredConnections: number;\n readonly draining: boolean;\n readonly rooms: number;\n readonly sessions: number;\n }>;\n readonly pendingUpgrades: number;\n readonly draining: boolean;\n }>;\n /** Flush completion is not a network delivery guarantee. */\n readonly history: InspectionList<InspectionEvent> & { readonly limit: number };\n }\n\n export interface SocketServerOptions {\n development?: DevelopmentOptions;\n server?: NodeHttpServer;\n port?: number;\n bind?: string;\n listen?: boolean;\n routes?: Array<new () => SocketRoute>;\n ssl?: { key: string; cert: string };\n fallbackToRoot?: boolean;\n closeServerOnShutdown?: boolean;\n listenCallback?: () => void;\n logger?: RedWebLogger | null;\n }\n\n export interface RedWebLogger {\n log?(message?: any, ...optionalParams: any[]): void;\n warn?(message?: any, ...optionalParams: any[]): void;\n error?(message?: any, ...optionalParams: any[]): void;\n }\n\n /** ─────────────────── ROUTES & HANDLERS ─────────────────── */\n\n export interface SocketRouteConfig {\n path: string;\n handlers: Array<new () => BaseHandler>;\n services?: Array<new () => SocketService>;\n allowDuplicateConnections?: boolean;\n websocketOptions?: Omit<ServerOptions, 'noServer' | 'path' | 'server' | 'port'>;\n trustProxy?: boolean;\n getClientKey?: (request: import('http').IncomingMessage) => string;\n exposeErrors?: boolean;\n logger?: RedWebLogger | null;\n shutdownTimeoutMs?: number;\n admission?: AdmissionOptions | AdmissionOptions['authenticate'];\n limits?: TransportLimits;\n orderedMessages?: boolean;\n heartbeat?: HeartbeatOptions;\n rooms?: boolean | RoomOptions;\n sessions?: boolean | SessionOptions;\n metrics?: MetricsSink;\n distribution?: false | DistributionOptions;\n drainHandlers?: boolean;\n protocol?: false | ProtocolOptions;\n maxPendingUpgrades?: number;\n }\n\n /** Socket‑side autonomous service (game loops, timers, etc.) */\n export abstract class SocketService {\n name: string;\n tickRateMs: number | null;\n route: SocketRoute;\n protected _tickHandle: NodeJS.Timeout | null;\n\n constructor(name: string, tickRateMs?: number);\n\n /** Called once when the route is initialised */\n onInit(route: SocketRoute): void;\n\n /** Optional recurring tick (respecting tickRateMs) */\n onTick?(...args: any[]): unknown;\n\n /** Called on process shutdown / route removal */\n onShutdown(): void;\n }\n\n export abstract class FixedStepService extends SocketService {\n maxCatchUpTicks: number;\n maxRetainedLagMs: number;\n tick: number;\n accumulatorMs: number;\n\n constructor(name: string, tickRateMs: number, maxCatchUpTicks?: number, maxRetainedLagMs?: number);\n onTick?(stepMs: number, tick: number): void | Promise<void>;\n onLagDropped?(droppedLagMs: number): void;\n pulse(): Promise<void>;\n onShutdown(): Promise<void>;\n }\n\n /** Message handler, triggered by client messages */\n export class BaseHandler {\n name: string;\n constructor(name: string);\n\n handleMessage(\n socket: RedWebSocket,\n message: any\n ): Promise<unknown>;\n\n validateMessage(message: any, socket: RedWebSocket): boolean | Promise<boolean>;\n onMessage(socket: RedWebSocket, message: any): unknown;\n acceptsBinary?(socket: RedWebSocket, buffer: Buffer): boolean;\n handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<unknown>;\n onBinaryMessage(socket: RedWebSocket, buffer: Buffer): unknown;\n onInitialContact(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;\n }\n\n export class SocketRoute {\n path: string;\n handlers: BaseHandler[];\n services: SocketService[];\n clients: Map<string, RedWebSocket>;\n rooms: RoomRegistry | null;\n sessions: SessionRegistry | null;\n distribution: unknown | null;\n protocolPolicy: unknown | null;\n draining: boolean;\n allowDuplicateConnections?: boolean;\n websocketOptions?: SocketRouteConfig['websocketOptions'];\n\n constructor(config: SocketRouteConfig);\n\n addHandler(handler: new () => BaseHandler): boolean;\n resolveRemoteAddress(request: import('http').IncomingMessage): string;\n connectionOpenCallback(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;\n connectionCloseCallback?(socket: RedWebSocket): unknown;\n handleMessage(sock: RedWebSocket, data: any): Promise<boolean>;\n handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<boolean>;\n beginDrain(): boolean;\n isReady(): boolean;\n publish(type: string, payload: unknown): Promise<boolean>;\n shutdown(): Promise<void>;\n }\n\n /** ─────────────────── SERVER BASE ─────────────────── */\n\n export class BaseSocketServer {\n server: NodeHttpServer;\n routes: SocketRoute[];\n ownsServer: boolean;\n\n constructor(server: NodeHttpServer, options?: SocketServerOptions);\n\n addRoute(route: new () => SocketRoute): SocketRoute;\n isReady(): boolean;\n inspect(): DevelopmentSnapshot | null;\n beginDrain(): boolean;\n shutdown(): Promise<void>;\n }\n\n /** ─────────────────── REGISTRY & UTIL TYPES ─────────────────── */\n\n export function sendJson(socket: WebSocket, data: unknown): boolean;\n\n export class RoomRegistry {\n constructor(options?: RoomOptions);\n join(roomId: string, socket: RedWebSocket): boolean;\n enter(roomId: string, socket: RedWebSocket): Promise<boolean>;\n leave(roomId: string, socket: RedWebSocket): boolean;\n leaveAll(socket: RedWebSocket): number;\n members(roomId: string): RedWebSocket[];\n has(roomId: string, socket: RedWebSocket): boolean;\n broadcast(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;\n broadcastFrom(socket: RedWebSocket, roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;\n clear(): void;\n close(): boolean;\n readonly size: number;\n }\n\n export class SessionRegistry<T = unknown> {\n constructor(options?: SessionOptions, logger?: RedWebLogger | null);\n create(sessionId: string, data: T, socket?: RedWebSocket): boolean;\n resume(sessionId: string, socket: RedWebSocket): T | null;\n release(socket: RedWebSocket): boolean;\n remove(sessionId: string): boolean;\n get(sessionId: string): T | undefined;\n sweep(): void;\n stop(): void;\n readonly size: number;\n }\n\n export interface SocketWrapper {\n socket: RedWebSocket;\n id: string;\n send: (type: string, payload: Record<string, any>) => void;\n getSanitized?(): Record<string, any>;\n }\n\n export type SocketMessage = {\n type: string;\n [key: string]: any;\n };\n\n /** Generic event‑driven registry for socket objects */\n /** Generic event-driven registry for socket objects */\n export class SocketRegistry<T extends SocketWrapper = SocketWrapper> extends EventEmitter {\n protected items: T[];\n\n constructor();\n\n /** Adds a socket-bound object to the registry */\n add(item: T): void;\n\n /**\n * Removes a socket-bound object by reference or id (default key: 'id')\n * @param itemOrId Object or ID string\n * @param by Key name to match against (default is 'id')\n */\n remove(itemOrId: T | string, by?: keyof T): boolean;\n\n /** Returns a shallow copy of all registered items */\n all(): T[];\n\n /** Returns the number of registered items */\n count(): number;\n }\n\n /** ─────────────────── CONCRETE SERVERS ─────────────────── */\n\n export class SocketServer extends BaseSocketServer {\n constructor(options?: SocketServerOptions);\n }\n\n export class SecureSocketServer extends BaseSocketServer {\n constructor(options?: SocketServerOptions);\n }\n\n export class BaseHttpServer {\n app: Application;\n server?: NodeHttpServer;\n constructor(options?: RedWebOptions);\n shutdown?(): Promise<void>;\n }\n\n export class HttpServer extends BaseHttpServer {\n constructor(options?: RedWebOptions);\n shutdown(): Promise<void>;\n }\n\n export class HttpsServer extends BaseHttpServer {\n constructor(options?: RedWebOptions);\n shutdown(): Promise<void>;\n }\n\n /** ─────────────────── LIVE HTML ─────────────────── */\n\n const htmlFragmentBrand: unique symbol;\n const htmlAttributeBrand: unique symbol;\n const htmlUrlBrand: unique symbol;\n\n export interface HtmlFragment {\n readonly [htmlFragmentBrand]: true;\n toString(): string;\n }\n\n export interface HtmlAttribute {\n readonly [htmlAttributeBrand]: true;\n }\n\n export interface HtmlUrl {\n readonly [htmlUrlBrand]: true;\n }\n\n export interface RedWebRequest {\n readonly path: string;\n readonly url: string;\n readonly method: string;\n readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>;\n readonly params: Readonly<Record<string, string>>;\n readonly query: Readonly<Record<string, unknown>>;\n readonly body: unknown;\n get(name: string): string | undefined;\n }\n\n export interface LivePageRequest extends RedWebRequest {}\n\n export interface RequestContext<Principal = unknown> {\n readonly request: RedWebRequest;\n readonly params: Readonly<Record<string, string>>;\n readonly query: Readonly<Record<string, unknown>>;\n readonly body: unknown;\n readonly principal?: Principal;\n readonly signal: AbortSignal;\n }\n\n export interface LivePageRequestContext extends RequestContext<string | number | bigint | boolean> {}\n\n /** The original page request/identity is retained across normal reconnects. */\n export interface LivePageConnectionContext extends LivePageRequestContext {\n socket: RedWebSocket;\n }\n\n export abstract class LivePage {\n protected readonly _connections: Set<RedWebSocket>;\n loading?(context: LivePageRequestContext): void | Promise<void>;\n render?(context: LivePageRequestContext): string | HtmlFragment | Promise<string | HtmlFragment>;\n connected?(context: LivePageConnectionContext): void | Promise<void>;\n disconnected?(context: LivePageConnectionContext): void | Promise<void>;\n disposed?(): void | Promise<void>;\n dispose(): Promise<boolean>;\n }\n\n export type PageOptions = {\n template?: string;\n css?: string | readonly string[];\n live?: boolean;\n head?: PageHead;\n cache?: PageCache;\n layout?: PageLayout;\n } & ({\n scope?: 'connection' | 'shared'; shared?: boolean;\n authorize?: undefined; authorizationTimeoutMs?: never;\n } | {\n scope?: 'connection'; shared?: false;\n /** Checked before construction/loading, on connection, and before actions/state writes. */\n authorize: (context: LivePageRequestContext) => boolean | Promise<boolean>;\n authorizationTimeoutMs?: number;\n });\n\n export type PageLayout = (content: HtmlFragment, context: LivePageRequestContext) => HtmlFragment;\n\n export interface PageHead {\n title?: string;\n description?: string;\n canonical?: string;\n image?: string;\n robots?: string;\n }\n\n export interface PageCache {\n maxAge?: number;\n staleWhileRevalidate?: number;\n immutable?: boolean;\n }\n\n export interface StateOptions {\n writable?: boolean;\n }\n\n export interface LiveStateDecorator {\n (target: object, propertyKey: string): void;\n <This, Value>(value: undefined, context: ClassFieldDecoratorContext<This, Value>):\n (this: This, initialValue: Value) => Value;\n }\n\n export interface LiveActionDecorator {\n (target: object, propertyKey: string, descriptor: PropertyDescriptor): void | PropertyDescriptor;\n <This, Value extends (this: This, ...args: any[]) => any>(\n value: Value,\n context: ClassMethodDecoratorContext<This, Value>\n ): Value;\n }\n\n /** The validated (possibly transformed) value passed as an action's first argument. */\n export type ActionInput<Schema extends import('redweb/contract').SocketSchema> = import('redweb/contract').ContractOutput<Schema>;\n\n export interface ValidatedActionDecorator<Input> {\n <Value extends (input: Input, context: LivePageConnectionContext) => any>(target: object, propertyKey: string,\n descriptor: TypedPropertyDescriptor<Value>): void;\n <This, Value extends (this: This, input: Input, context: LivePageConnectionContext) => any>(\n value: Value, context: ClassMethodDecoratorContext<This, Value>\n ): Value;\n }\n\n export interface LiveViewDecorator {\n (target: object, propertyKey: string, descriptor: PropertyDescriptor): void | PropertyDescriptor;\n <This, Value extends (this: This, item: any, index: number) => HtmlFragment>(\n value: Value,\n context: ClassMethodDecoratorContext<This, Value>\n ): Value;\n }\n\n export function page(path: string, options?: PageOptions): ClassDecorator;\n export function component(): ClassDecorator;\n export function component<Props = void>(render: (properties: Props) => HtmlFragment):\n (properties: Props) => HtmlFragment;\n export function state(options?: StateOptions): LiveStateDecorator;\n export function action(): LiveActionDecorator;\n export interface ActionAuthorization<Input> {\n authorize: (context: LivePageConnectionContext, input: Input) => boolean | Promise<boolean>;\n /** Bounds permission checks, not application execution; defaults to 5000ms. */\n authorizationTimeoutMs?: number;\n }\n export function action<Schema extends import('redweb/contract').SocketSchema>(options: {\n input: Schema;\n /** Bounds input validation, not application execution; defaults to 5000ms. */\n validationTimeoutMs?: number;\n } & (ActionAuthorization<ActionInput<Schema>> | { authorize?: undefined; authorizationTimeoutMs?: never })): ValidatedActionDecorator<ActionInput<Schema>>;\n /** Authorized actions use a fixed (input, context) shape, including buttons without a payload. */\n export function action(options: ActionAuthorization<unknown>): ValidatedActionDecorator<unknown>;\n export function view(stateName: string): LiveViewDecorator;\n export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment;\n export function attribute(value: string | number | bigint | boolean): HtmlAttribute;\n export function url(value: string): HtmlUrl;\n export function each<Item>(items: readonly Item[], render: (item: Item, index: number) => HtmlFragment): HtmlFragment;\n export function codeBlock(code: unknown, options?: {\n language?: string;\n label?: string;\n highlight?: (source: string, language: string) => HtmlFragment;\n }): HtmlFragment;\n\n export type LivePageClass = new () => object;\n\n export interface LiveHtmlServerBaseOptions extends Omit<RedWebOptions, 'enableHtmxRendering'> {\n development?: LiveDevelopmentOptions;\n pages: readonly LivePageClass[];\n templateRoot?: string;\n livePaths?: {\n socket?: string;\n client?: string;\n runtime?: string;\n css?: string;\n };\n sessionTtlMs?: number;\n maxSessions?: number;\n maxConcurrentRenders?: number;\n /** Phase-local render/route and final owned-HTTP cleanup bound; defaults to 1000ms, not a total application deadline. */\n shutdownTimeoutMs?: number;\n heartbeat?: HeartbeatOptions;\n origins?: string[] | ((origin: string | undefined, request: import('http').IncomingMessage) => boolean | Promise<boolean>);\n }\n\n export type LiveHtmlAuthentication = {\n authenticate(request: import('http').IncomingMessage | import('express').Request):\n string | number | bigint | boolean | null | undefined |\n Promise<string | number | bigint | boolean | null | undefined>;\n /** Bounds identity lookup, not external application work; defaults to 5000ms. */\n authenticationTimeoutMs?: number;\n } | { authenticate?: undefined; authenticationTimeoutMs?: never };\n export type LiveHtmlServerOptions = LiveHtmlServerBaseOptions & LiveHtmlAuthentication;\n export type LiveHtmlStartOptions = Omit<LiveHtmlServerBaseOptions, 'pages'> & LiveHtmlAuthentication;\n\n export class LiveHtmlServer {\n app: Application;\n server: NodeHttpServer | NodeHttpsServer;\n http: HttpServer | HttpsServer;\n sockets: SocketServer | null;\n constructor(options: LiveHtmlServerOptions);\n /** Revoke matching in-process sessions/renders; credential invalidation remains application-owned. */\n revoke(principal: string | number | bigint | true): Promise<number>;\n /** Local metadata only; null unless development inspection was explicitly enabled. */\n inspect(): DevelopmentSnapshot | null;\n shutdown(): Promise<void>;\n }\n\n export function start(\n pageOrPages: LivePageClass | readonly LivePageClass[],\n options?: LiveHtmlStartOptions\n ): LiveHtmlServer;\n\n export interface StaticExportOptions {\n outDir: string;\n templateRoot?: string;\n logger?: RedWebLogger | null;\n }\n\n export interface StaticExportResult {\n readonly pages: readonly string[];\n readonly assets: readonly string[];\n }\n\n export function exportStatic(\n pageOrPages: LivePageClass | readonly LivePageClass[],\n options: StaticExportOptions\n ): Promise<StaticExportResult>;\n\n export interface SiteOptions {\n origin?: string;\n css?: string | readonly string[];\n head?: PageHead;\n cache?: PageCache;\n layout?: PageLayout;\n }\n\n export type SitePageOptions = PageOptions & { live?: false };\n\n export interface SiteExportOptions extends StaticExportOptions {\n publicDir?: string;\n }\n\n export interface StaticSite {\n page(path: string, options?: SitePageOptions): ClassDecorator;\n export(\n pageOrPages: LivePageClass | readonly LivePageClass[],\n options: SiteExportOptions\n ): Promise<StaticExportResult>;\n }\n\n export function defineSite(options?: SiteOptions): StaticSite;\n\n export class HtmlRenderer {\n static template(filePath: string, rootDir: string): string;\n static stylesheet(filePath: string, rootDir: string): string;\n static render(source: string, page: object, options?: { live?: boolean }): string;\n static collection(page: object, name: string, value: unknown): string;\n static statePayload(name: string, value: unknown, page?: object): { name: string; value: string; html: boolean };\n static head(metadata?: PageHead): string;\n static document(\n markup: string,\n config?: (Record<string, unknown> & { runtimePath: string }) | null,\n stylesheets?: string[],\n metadata?: PageHead\n ): string;\n }\n\n /** ─────────────────── CONSTANTS ─────────────────── */\n\n export const METHODS: {\n GET: 'get';\n POST: 'post';\n PUT: 'put';\n PATCH: 'patch';\n DELETE: 'delete';\n OPTIONS: 'options';\n HEAD: 'head';\n ALL: 'all';\n };\n\n export const ENCODINGS: {\n json: 'json';\n urlencoded: 'urlencoded';\n };\n\n export const HTTP_OPTIONS: RedWebOptions;\n export const SOCKET_OPTIONS: SocketServerOptions;\n\n export const ERROR_CODES: typeof import('redweb/client').ERROR_CODES;\n}\n```\n\n## client.d.ts\n\n```ts\n// Generated from src/ws/protocol-schema.json by scripts/generate-protocol-types.js.\nexport type RedWebProtocolErrorCode =\n | 'INVALID_MESSAGE'\n | 'INVALID_PAYLOAD'\n | 'UNKNOWN_HANDLER'\n | 'HANDLER_FAILED'\n | 'BINARY_UNSUPPORTED'\n | 'RATE_LIMITED'\n | 'QUEUE_FULL'\n | 'CAPACITY_REACHED'\n | 'INITIALIZATION_FAILED'\n | 'ACCESS_DENIED'\n | 'ACCESS_TIMEOUT'\n | 'ACCESS_CANCELLED'\n | 'ACCESS_CAPACITY';\n\nexport interface ProtocolMetadata {\n requestId?: string;\n sequence?: number;\n}\n\nexport interface ProtocolEnvelope<T = unknown> extends ProtocolMetadata {\n v: string;\n type: string;\n payload: T;\n}\n\nexport interface ProtocolErrorEnvelope extends ProtocolMetadata {\n v: string;\n type: 'error';\n error: { code: RedWebProtocolErrorCode | string; message: string };\n}\n\nexport interface SendableSocket {\n send(data: string): unknown;\n}\n\nexport class ProtocolClient {\n constructor(socket: SendableSocket, version: string);\n readonly socket: SendableSocket;\n readonly version: string;\n envelope<T>(type: string, payload: T, metadata?: ProtocolMetadata): ProtocolEnvelope<T>;\n send<T>(type: string, payload: T, metadata?: ProtocolMetadata): void;\n parse<T = unknown>(input: string | Uint8Array | ArrayBuffer | { data: string | Uint8Array | ArrayBuffer }): ProtocolEnvelope<T> | ProtocolErrorEnvelope;\n}\n\nexport const ERROR_CODES: { readonly [Code in RedWebProtocolErrorCode]: Code };\n```\n\n## contract.d.ts\n\n```ts\nimport type { BaseHandler, ProtocolMetadata, RedWebSocket } from 'redweb';\nimport type { ProtocolEnvelope, ProtocolErrorEnvelope, SendableSocket } from './client';\n\n/** Structural Standard Schema v1 support; use an existing compatible validator library. */\nexport interface SocketSchema<Input = unknown, Output = Input> {\n readonly '~standard': {\n readonly version: 1;\n readonly validate: (input: unknown) =>\n { readonly value: Output; readonly issues?: undefined } | { readonly issues: readonly unknown[] } |\n Promise<{ readonly value: Output; readonly issues?: undefined } | { readonly issues: readonly unknown[] }>;\n readonly types?: { readonly input: Input; readonly output: Output };\n };\n}\n\nexport type SocketSchemas = Readonly<Record<string, SocketSchema>>;\nexport type ContractInput<Schema extends SocketSchema> = NonNullable<Schema['~standard']['types']>['input'];\nexport type ContractOutput<Schema extends SocketSchema> = Awaited<NonNullable<Schema['~standard']['types']>['output']>;\nexport type ContractMessage<Schemas extends SocketSchemas> = {\n [Type in keyof Schemas & string]: ProtocolEnvelope<ContractOutput<Schemas[Type]>> & { type: Type };\n}[keyof Schemas & string];\n\nexport interface ContractClient<Schemas extends SocketSchemas> {\n envelope<Type extends keyof Schemas & string>(type: Type, payload: ContractInput<Schemas[Type]>, metadata?: ProtocolMetadata):\n Promise<ProtocolEnvelope<ContractInput<Schemas[Type]>> & { type: Type }>;\n send<Type extends keyof Schemas & string>(type: Type, payload: ContractInput<Schemas[Type]>, metadata?: ProtocolMetadata): Promise<void>;\n parse(input: string | Uint8Array | ArrayBuffer | { data: string | Uint8Array | ArrayBuffer }):\n Promise<ContractMessage<Schemas> | ProtocolErrorEnvelope>;\n}\n\nexport interface SocketContract<Schemas extends SocketSchemas> {\n readonly version: string;\n readonly types: readonly (keyof Schemas & string)[];\n readonly validationTimeoutMs: number;\n readonly protocol: { readonly versions: readonly string[] };\n parse<Type extends keyof Schemas & string>(type: Type, payload: unknown): Promise<ContractOutput<Schemas[Type]>>;\n handler<Type extends keyof Schemas & string>(type: Type, callback: (\n socket: RedWebSocket, payload: ContractOutput<Schemas[Type]>,\n message: ProtocolEnvelope<ContractOutput<Schemas[Type]>> & { type: Type },\n ) => unknown): new () => BaseHandler;\n client(socket: SendableSocket): ContractClient<Schemas>;\n send<Type extends keyof Schemas & string>(socket: RedWebSocket, type: Type, payload: ContractInput<Schemas[Type]>, metadata?: ProtocolMetadata): Promise<boolean>;\n}\n\nexport function defineSocketContract<const Schemas extends SocketSchemas>(version: string, schemas: Schemas,\n options?: { validationTimeoutMs?: number }): SocketContract<Schemas>;\n```\n\n## jsx-runtime.d.ts\n\n```ts\nimport type { HtmlFragment, LivePageRequestContext } from 'redweb';\n\n/** A synchronous @component() instance owned by a page/component field. Ownership is checked at runtime. */\nexport interface ServerComponent {\n render(context: LivePageRequestContext): string | HtmlFragment | readonly HtmlFragment[];\n}\n\nexport type Child = HtmlFragment | ServerComponent | string | number | bigint | boolean | null | undefined | readonly Child[];\n\nexport interface IntrinsicAttributes {\n key?: string | number;\n}\n\nexport interface IntrinsicProperties extends IntrinsicAttributes {\n children?: Child;\n class?: string;\n className?: string;\n id?: string;\n htmlFor?: string;\n [name: string]: unknown;\n}\n\nexport namespace JSX {\n type Element = HtmlFragment;\n interface ElementChildrenAttribute { children: {}; }\n interface IntrinsicAttributes { key?: string | number; }\n interface IntrinsicElements { [name: string]: IntrinsicProperties; }\n}\n\nexport const Fragment: unique symbol;\nexport type ElementType = string | typeof Fragment | ((properties: any) => HtmlFragment | readonly HtmlFragment[]);\nexport function jsx(type: ElementType, properties: IntrinsicProperties | null, key?: string | number): HtmlFragment;\nexport const jsxs: typeof jsx;\n```\n\n## jsx-dev-runtime.d.ts\n\n```ts\nexport { Fragment } from './jsx-runtime';\nexport type { Child, ElementType, IntrinsicAttributes, IntrinsicProperties, JSX } from './jsx-runtime';\nimport type { HtmlFragment } from 'redweb';\nimport type { ElementType, IntrinsicProperties } from './jsx-runtime';\n\nexport function jsxDEV(\n type: ElementType,\n properties: IntrinsicProperties | null,\n key?: string | number,\n isStaticChildren?: boolean,\n source?: unknown,\n self?: unknown,\n): HtmlFragment;\n```\n",
|
|
935
|
+
"url": "/docs/reference/0.13.2/api-types.md",
|
|
936
|
+
"sha256": "f887d73403b0787aebc2c421b93a80b2590c58a8edf5581af15ca46888c48f12"
|
|
937
|
+
}
|
|
938
|
+
],
|
|
939
|
+
"api": [
|
|
940
|
+
{
|
|
941
|
+
"id": "httpserver",
|
|
942
|
+
"name": "HttpServer",
|
|
943
|
+
"type": "HTTP",
|
|
944
|
+
"summary": "Wraps Express with sensible defaults (JSON body parsing, CORS, and static asset folders) and starts listening immediately unless `listen: false` is supplied. You get the underlying Express instance back via `app`.",
|
|
945
|
+
"usage": "const { HttpServer, METHODS } = require('redweb')\n\nconst server = new HttpServer({\n port: 4000,\n bind: '0.0.0.0',\n publicPaths: ['./static'],\n services: [\n { serviceName: '/ping', method: METHODS.GET, function: (req, res) => res.json({ pong: true }) },\n ],\n})\n\n// Express is still available:\nserver.app.get('/health', (req, res) => res.send('ok'))",
|
|
946
|
+
"options": [
|
|
947
|
+
"port: number (default 80)",
|
|
948
|
+
"bind: string (default 0.0.0.0)",
|
|
949
|
+
"publicPaths: string[] (default [\"./public\"])",
|
|
950
|
+
"services: array of { serviceName, method, function }",
|
|
951
|
+
"listen: boolean (default true); set false to build app without binding a port",
|
|
952
|
+
"listenCallback: function invoked after listen",
|
|
953
|
+
"encoding: \"json\" | \"urlencoded\" (default json)",
|
|
954
|
+
"corsOptions: passed to cors"
|
|
955
|
+
],
|
|
956
|
+
"methods": [
|
|
957
|
+
{
|
|
958
|
+
"name": "constructor(options)",
|
|
959
|
+
"detail": "Merges defaults, wires body parsing, CORS, static serving, registers REST services, and starts listening unless listen is false."
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
"name": "app (Express instance)",
|
|
963
|
+
"detail": "Use the returned `app` to add middleware or routes exactly like a normal Express server."
|
|
964
|
+
}
|
|
965
|
+
],
|
|
966
|
+
"article": {
|
|
967
|
+
"eli5": "Think of HttpServer as a furnished storefront: Express is the building, while Redweb installs the front door, service counter, signs, and sensible safety rails before you open.",
|
|
968
|
+
"useWhen": "Choose it when Redweb should own a normal HTTP listener and you still want direct access to Express for middleware or one-off routes.",
|
|
969
|
+
"walkthrough": [
|
|
970
|
+
"Redweb creates the Express application and installs the configured parsers, CORS policy, static folders, and services.",
|
|
971
|
+
"The server binds to the requested interface unless listen is false.",
|
|
972
|
+
"The app property remains the same Express application, so adding /health does not require a Redweb abstraction."
|
|
973
|
+
],
|
|
974
|
+
"watchFor": "Use listen: false when another object must own the Node listener; two owners trying to bind the same port is an application design error."
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
"id": "basehttpserver",
|
|
979
|
+
"name": "BaseHttpServer",
|
|
980
|
+
"type": "HTTP",
|
|
981
|
+
"summary": "Public Express app builder used by HttpServer and HttpsServer. Use it for advanced composition when you want Redweb middleware, static files, and services without any listener behavior.",
|
|
982
|
+
"usage": "const { BaseHttpServer, METHODS } = require('redweb')\n\nconst base = new BaseHttpServer({\n publicPaths: ['./public'],\n services: [\n { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})\n\nbase.app.get('/extra', (req, res) => res.send('ok'))",
|
|
983
|
+
"options": [
|
|
984
|
+
"All `HttpServer` app-building options",
|
|
985
|
+
"server: existing Express application to configure",
|
|
986
|
+
"listen is ignored because BaseHttpServer never binds a port"
|
|
987
|
+
],
|
|
988
|
+
"methods": [
|
|
989
|
+
{
|
|
990
|
+
"name": "constructor(options)",
|
|
991
|
+
"detail": "Builds or configures an Express app with body parsing, CORS, static serving, and REST services."
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"name": "app (Express instance)",
|
|
995
|
+
"detail": "The configured Express application. Pass it to http.createServer(app) for custom server ownership."
|
|
996
|
+
}
|
|
997
|
+
],
|
|
998
|
+
"article": {
|
|
999
|
+
"eli5": "BaseHttpServer prepares the kitchen but does not open the restaurant. You get a fully arranged Express app and decide which Node server will serve it.",
|
|
1000
|
+
"useWhen": "Use it for custom composition, tests, serverless adapters, or any setup where creating and listening on the HTTP server belongs to your application.",
|
|
1001
|
+
"walkthrough": [
|
|
1002
|
+
"The constructor configures either your Express app or a new one.",
|
|
1003
|
+
"Static folders and service definitions are registered in deterministic order.",
|
|
1004
|
+
"You pass base.app to http.createServer, a test harness, or another host when you are ready."
|
|
1005
|
+
],
|
|
1006
|
+
"watchFor": "It intentionally ignores listener settings. If requests are not arriving, confirm that your own server is listening and forwarding them to base.app."
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
"id": "httpsserver",
|
|
1011
|
+
"name": "HttpsServer",
|
|
1012
|
+
"type": "HTTP",
|
|
1013
|
+
"summary": "TLS-enabled variant of `HttpServer`. Accepts `ssl.key` and `ssl.cert` file paths, wraps them in an https server, and bootstraps the same middleware pipeline.",
|
|
1014
|
+
"usage": "const { HttpsServer, METHODS } = require('redweb')\n\nnew HttpsServer({\n port: 4443,\n ssl: { key: './certs/dev.key', cert: './certs/dev.crt' },\n services: [\n { serviceName: '/secure', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})",
|
|
1015
|
+
"options": [
|
|
1016
|
+
"All `HttpServer` options",
|
|
1017
|
+
"ssl.key: path to private key (required)",
|
|
1018
|
+
"ssl.cert: path to certificate (required)"
|
|
1019
|
+
],
|
|
1020
|
+
"methods": [
|
|
1021
|
+
{
|
|
1022
|
+
"name": "constructor(options)",
|
|
1023
|
+
"detail": "Loads the provided key/cert pair, builds the Express app with BaseHttpServer, then creates and starts the HTTPS listener unless listen is false."
|
|
1024
|
+
}
|
|
1025
|
+
],
|
|
1026
|
+
"article": {
|
|
1027
|
+
"eli5": "HttpsServer is HttpServer with a locked, encrypted front door. It reads your certificate and key, then serves the same Express application through TLS.",
|
|
1028
|
+
"useWhen": "Use it when the Node process terminates TLS itself instead of sitting behind a reverse proxy or managed load balancer.",
|
|
1029
|
+
"walkthrough": [
|
|
1030
|
+
"The key and certificate files are loaded before the listener starts.",
|
|
1031
|
+
"The standard HTTP middleware and services are attached to the Express app.",
|
|
1032
|
+
"The resulting HTTPS server binds on the configured port and handles encrypted requests."
|
|
1033
|
+
],
|
|
1034
|
+
"watchFor": "Certificate rotation, filesystem permissions, and secure protocol policy remain deployment concerns. Behind a TLS-terminating proxy, HttpServer is usually simpler."
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
"id": "socketserver",
|
|
1039
|
+
"name": "SocketServer",
|
|
1040
|
+
"type": "WebSocket",
|
|
1041
|
+
"summary": "HTTP-upgrade WebSocket server on top of `ws`. Builds and listens on its own HTTP server by default; if you pass a Node `server`, it attaches upgrade handling and leaves `.listen()` to you unless `listen: true` is explicit.",
|
|
1042
|
+
"usage": "const http = require('http')\nconst { HttpServer, METHODS, SocketServer } = require('redweb')\n\nconst httpServer = new HttpServer({\n listen: false,\n publicPaths: ['./public'],\n services: [\n { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },\n ],\n})\n\nconst server = http.createServer(httpServer.app)\n\nnew SocketServer({\n server,\n routes: [ChatRoute],\n})\n\nserver.listen(3030)",
|
|
1043
|
+
"options": [
|
|
1044
|
+
"port: number (default 3000)",
|
|
1045
|
+
"listen: boolean (default true for owned servers); supplied servers do not listen unless explicitly true",
|
|
1046
|
+
"server: existing http.Server to attach to without double-listening (optional)",
|
|
1047
|
+
"routes: array of SocketRoute subclasses (defaults to a single DefaultRoute at \"/\")"
|
|
1048
|
+
],
|
|
1049
|
+
"methods": [
|
|
1050
|
+
{
|
|
1051
|
+
"name": "constructor(options)",
|
|
1052
|
+
"detail": "Creates or reuses an HTTP server, instantiates supplied routes or a DefaultRoute, attaches upgrade handling, and starts listening only when Redweb owns the server or listen is explicitly true."
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
"name": "addRoute(RouteClass)",
|
|
1056
|
+
"detail": "Instantiate and register another `SocketRoute` at runtime."
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
"name": "handleUpgrade(req, socket, head)",
|
|
1060
|
+
"detail": "Internal: normalises the request path, picks a matching route (or \"/\"), and forwards the upgrade to that route's server."
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
"name": "shutdown()",
|
|
1064
|
+
"detail": "Closes all registered routes and the underlying HTTP server."
|
|
1065
|
+
}
|
|
1066
|
+
],
|
|
1067
|
+
"article": {
|
|
1068
|
+
"eli5": "SocketServer is a switchboard for persistent conversations. It accepts a WebSocket upgrade, finds the route for that path, and lets that route handle the connection.",
|
|
1069
|
+
"useWhen": "Choose it for ws:// endpoints or when a reverse proxy already handles TLS and you need one or more independently configured socket routes.",
|
|
1070
|
+
"walkthrough": [
|
|
1071
|
+
"The HTTP server is built or reused according to the options.",
|
|
1072
|
+
"Upgrade requests are matched to routes instead of being sent to every handler.",
|
|
1073
|
+
"Each route owns its clients, handlers, services, limits, and cleanup lifecycle."
|
|
1074
|
+
],
|
|
1075
|
+
"watchFor": "When supplying an existing Node server, Redweb does not assume it should call listen. Make listener ownership explicit and shut down in the reverse order of startup."
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
"id": "securesocketserver",
|
|
1080
|
+
"name": "SecureSocketServer",
|
|
1081
|
+
"type": "WebSocket",
|
|
1082
|
+
"summary": "HTTPS + WebSocket pairing. Mirrors `SocketServer` but wraps an HTTPS server built from the provided TLS files.",
|
|
1083
|
+
"usage": "const { SecureSocketServer } = require('redweb')\nconst { GameRoute } = require('./routes/GameRoute')\n\nnew SecureSocketServer({\n port: 3443,\n ssl: { key: './certs/dev.key', cert: './certs/dev.crt' },\n routes: [GameRoute],\n})",
|
|
1084
|
+
"options": [
|
|
1085
|
+
"port: number (default 3000)",
|
|
1086
|
+
"listen: boolean (default true for owned servers); supplied servers do not listen unless explicitly true",
|
|
1087
|
+
"server: existing https.Server to attach to without double-listening (optional)",
|
|
1088
|
+
"ssl.key and ssl.cert: required file paths",
|
|
1089
|
+
"routes: array of SocketRoute subclasses"
|
|
1090
|
+
],
|
|
1091
|
+
"methods": [
|
|
1092
|
+
{
|
|
1093
|
+
"name": "constructor(options)",
|
|
1094
|
+
"detail": "Loads TLS files or reuses a supplied HTTPS server, registers the provided routes, attaches upgrade handling, and starts listening only when Redweb owns the server or listen is explicitly true."
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
"name": "addRoute(RouteClass)",
|
|
1098
|
+
"detail": "Same runtime route attachment as `SocketServer`."
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
"name": "shutdown()",
|
|
1102
|
+
"detail": "Stops routes, services, and the HTTPS listener."
|
|
1103
|
+
}
|
|
1104
|
+
],
|
|
1105
|
+
"article": {
|
|
1106
|
+
"eli5": "SecureSocketServer is the encrypted version of the WebSocket switchboard: clients use wss:// and the connection stays protected from the first handshake onward.",
|
|
1107
|
+
"useWhen": "Use it when Redweb directly owns a TLS WebSocket listener rather than sharing an externally terminated HTTPS connection.",
|
|
1108
|
+
"walkthrough": [
|
|
1109
|
+
"TLS material is loaded to create or configure the HTTPS listener.",
|
|
1110
|
+
"WebSocket upgrades travel through the same route selection used by SocketServer.",
|
|
1111
|
+
"Route handlers see ordinary Redweb sockets after the secure handshake completes."
|
|
1112
|
+
],
|
|
1113
|
+
"watchFor": "Do not duplicate TLS termination accidentally. If a proxy already provides wss:// publicly, attach SocketServer behind it and validate forwarded origin information."
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
"id": "socketroute",
|
|
1118
|
+
"name": "SocketRoute",
|
|
1119
|
+
"type": "WebSocket",
|
|
1120
|
+
"summary": "Defines a WebSocket endpoint and owns its handlers, services, clients, and opt-in multiplayer policies. Routes can add bounded admission, transport limits, ordered work, heartbeat, rooms, resumable sessions, distribution, draining, metrics, and protocol negotiation without changing legacy routes.",
|
|
1121
|
+
"usage": "const { SocketRoute } = require('redweb')\nconst { ChatHandler } = require('./handlers/ChatHandler')\nconst { ClockService } = require('./services/ClockService')\n\nclass ChatRoute extends SocketRoute {\n constructor() {\n super({\n path: '/chat',\n handlers: [ChatHandler],\n services: [ClockService],\n allowDuplicateConnections: true,\n websocketOptions: {\n maxPayload: 1024 * 1024,\n perMessageDeflate: false,\n },\n })\n }\n}",
|
|
1122
|
+
"options": [
|
|
1123
|
+
"path: WebSocket path (required)",
|
|
1124
|
+
"handlers: array of handler classes (required)",
|
|
1125
|
+
"services: array of SocketService subclasses (optional)",
|
|
1126
|
+
"allowDuplicateConnections: allow multiple clients from the same IP",
|
|
1127
|
+
"websocketOptions: options passed to ws WebSocketServer, such as maxPayload or perMessageDeflate",
|
|
1128
|
+
"admission: authenticate, validate origins, and optionally place a client before upgrade",
|
|
1129
|
+
"maxPendingUpgrades: finite concurrent admission/negotiation work (default 64)",
|
|
1130
|
+
"limits: connection, message-rate, pending-message, and outbound-buffer ceilings",
|
|
1131
|
+
"orderedMessages: serialize each connection through a bounded queue",
|
|
1132
|
+
"heartbeat: one route-level half-open connection monitor",
|
|
1133
|
+
"rooms and sessions: bounded grouping and expiring application-issued ownership",
|
|
1134
|
+
"distribution: optional bounded broker adapter; no broker is bundled or required",
|
|
1135
|
+
"drainHandlers: track handler work and expose a cooperative shutdown signal",
|
|
1136
|
+
"protocol: version negotiation, stable envelopes/error codes, and optional binary codecs"
|
|
1137
|
+
],
|
|
1138
|
+
"methods": [
|
|
1139
|
+
{
|
|
1140
|
+
"name": "constructor({ path, handlers, services, allowDuplicateConnections, websocketOptions })",
|
|
1141
|
+
"detail": "Validates input, instantiates handlers/services, sets up a `ws` server for the path with any websocketOptions, and registers connection listeners."
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
"name": "addHandler(HandlerClass)",
|
|
1145
|
+
"detail": "Adds another handler class unless a handler with the same name already exists."
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
"name": "handleConnection(socket, req)",
|
|
1149
|
+
"detail": "Stores the client (deduping by IP unless allowed), decorates the socket with `sendJson`/`broadcast`, and wires close/error/message listeners."
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
"name": "handleMessage(socket, data)",
|
|
1153
|
+
"detail": "Parses JSON text frames, finds the handler matching `data.type`; on success delegates to handler.handleMessage, otherwise replies with an error and closes the socket."
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
"name": "handleBinaryMessage(socket, buffer)",
|
|
1157
|
+
"detail": "Handles binary frames separately from JSON text frames and delegates to a handler selected by acceptsBinary(socket, buffer)."
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
"name": "handleClose(socket, ip)",
|
|
1161
|
+
"detail": "Removes the client from the registry and triggers an optional `connectionCloseCallback`."
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
"name": "shutdown()",
|
|
1165
|
+
"detail": "Marks the route draining, stops services, bounds handler/adapter cleanup, closes clients, and releases every route-owned resource."
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
"name": "beginDrain()",
|
|
1169
|
+
"detail": "Flips readiness and rejects new upgrades before shutdown work begins."
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
"name": "isReady()",
|
|
1173
|
+
"detail": "Reports whether the route is accepting upgrades and any required distribution adapter is healthy."
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
"name": "publish(type, payload)",
|
|
1177
|
+
"detail": "Publishes through the optional bounded distribution adapter and resolves to a success boolean."
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
"name": "handleError(socket, error, ip)",
|
|
1181
|
+
"detail": "Logs socket errors; override for custom reporting."
|
|
1182
|
+
}
|
|
1183
|
+
],
|
|
1184
|
+
"article": {
|
|
1185
|
+
"eli5": "A SocketRoute is a room with its own door and rules. The URL chooses the room; message.type chooses which handler inside the room receives the message.",
|
|
1186
|
+
"useWhen": "Create one whenever a WebSocket path represents a distinct protocol, trust boundary, workload, or group of multiplayer resources.",
|
|
1187
|
+
"walkthrough": [
|
|
1188
|
+
"The /match path selects the route during the WebSocket upgrade.",
|
|
1189
|
+
"Admission and capacity checks run before the connection becomes an active client.",
|
|
1190
|
+
"Messages are dispatched by type to BaseHandler instances while route services and registries share the same lifecycle."
|
|
1191
|
+
],
|
|
1192
|
+
"watchFor": "Keep routing decisions out of message.action branches. Prefer one route per protocol area and one handler per message type."
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
"id": "socketservice",
|
|
1197
|
+
"name": "SocketService",
|
|
1198
|
+
"type": "WebSocket",
|
|
1199
|
+
"summary": "Route-scoped background worker. Used by `SocketRoute` to run ticks or lifecycle hooks tied to a specific route.",
|
|
1200
|
+
"usage": "const { SocketService } = require('redweb')\n\nclass ClockService extends SocketService {\n constructor() { super('clock', 1000) }\n onTick() {\n this.route.clients.forEach((socket) => socket.sendJson({ type: 'time', now: Date.now() }))\n }\n}",
|
|
1201
|
+
"methods": [
|
|
1202
|
+
{
|
|
1203
|
+
"name": "constructor(name, tickRateMs = null)",
|
|
1204
|
+
"detail": "Stores a service id and optional tick interval; the interval is activated in onInit if onTick exists."
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
"name": "onInit(route)",
|
|
1208
|
+
"detail": "Called once by SocketRoute, sets `this.route` and, if a tick interval was supplied, schedules recurring onTick execution."
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
"name": "onTick()",
|
|
1212
|
+
"detail": "Optional; implement to run on the configured interval."
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
"name": "onShutdown()",
|
|
1216
|
+
"detail": "Clears the tick interval; extend for cleanup hooks."
|
|
1217
|
+
}
|
|
1218
|
+
],
|
|
1219
|
+
"article": {
|
|
1220
|
+
"eli5": "A SocketService is a helper that clocks in when its route starts and clocks out when the route stops, such as presence tracking or a periodic snapshot publisher.",
|
|
1221
|
+
"useWhen": "Use it for route-scoped background behavior that needs explicit startup, shutdown, and access to the owning route.",
|
|
1222
|
+
"walkthrough": [
|
|
1223
|
+
"The service is constructed with a stable name.",
|
|
1224
|
+
"SocketRoute starts it once the route is ready.",
|
|
1225
|
+
"Shutdown awaits the service so timers, subscriptions, and external connections cannot leak."
|
|
1226
|
+
],
|
|
1227
|
+
"watchFor": "Every resource acquired in start must have a bounded and idempotent release in stop. Avoid detached timers or promises that outlive the route."
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
"id": "fixedstepservice",
|
|
1232
|
+
"name": "FixedStepService",
|
|
1233
|
+
"type": "Multiplayer",
|
|
1234
|
+
"summary": "Route-scoped simulation clock that compensates for drift, prevents overlapping async ticks, bounds catch-up work, and reports dropped retained lag instead of replaying forever.",
|
|
1235
|
+
"usage": "const { FixedStepService } = require('redweb')\n\nclass Simulation extends FixedStepService {\n constructor() { super('simulation', 50, 3) }\n async onTick(stepMs, tick) {\n await game.update(stepMs, tick)\n }\n}",
|
|
1236
|
+
"methods": [
|
|
1237
|
+
{
|
|
1238
|
+
"name": "constructor(name, tickRateMs, maxCatchUpTicks?, maxRetainedLagMs?)",
|
|
1239
|
+
"detail": "Creates a fixed-step route service with finite catch-up and retained-lag limits."
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
"name": "onTick(stepMs, tick)",
|
|
1243
|
+
"detail": "Implement one simulation step. Async work never overlaps the next pulse."
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
"name": "onLagDropped(milliseconds)",
|
|
1247
|
+
"detail": "Optional observability hook called when retained lag is deliberately discarded."
|
|
1248
|
+
}
|
|
1249
|
+
],
|
|
1250
|
+
"article": {
|
|
1251
|
+
"eli5": "FixedStepService is a metronome for game logic. Even when the computer hesitates, it advances the simulation in measured beats without starting two beats at once.",
|
|
1252
|
+
"useWhen": "Use it for authoritative simulations or periodic work that needs stable step sizes, bounded catch-up, and explicit handling of excessive lag.",
|
|
1253
|
+
"walkthrough": [
|
|
1254
|
+
"The service schedules ticks using the configured fixed interval.",
|
|
1255
|
+
"A slow asynchronous tick finishes before another begins.",
|
|
1256
|
+
"Limited catch-up reduces drift, while old excess lag is reported and discarded instead of causing an endless spiral."
|
|
1257
|
+
],
|
|
1258
|
+
"watchFor": "A fixed step does not make expensive work free. Measure onTick duration, set conservative catch-up limits, and keep network I/O outside the critical simulation path."
|
|
1259
|
+
}
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
"id": "roomregistry",
|
|
1263
|
+
"name": "RoomRegistry",
|
|
1264
|
+
"type": "Multiplayer",
|
|
1265
|
+
"summary": "Bounded route-local connection groups. Sockets normally use joinRoom, leaveRoom, and roomBroadcast; disconnect cleanup removes all memberships and reclaims empty rooms.",
|
|
1266
|
+
"usage": "class MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n rooms: { maxRooms: 1000, maxMembersPerRoom: 32 },\n })\n }\n}",
|
|
1267
|
+
"methods": [
|
|
1268
|
+
{
|
|
1269
|
+
"name": "socket.joinRoom(roomId)",
|
|
1270
|
+
"detail": "Idempotently joins a bounded room and returns whether membership is active."
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
"name": "socket.leaveRoom(roomId)",
|
|
1274
|
+
"detail": "Idempotently leaves one room and reclaims it when empty."
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
"name": "socket.roomBroadcast(roomId, data, options?)",
|
|
1278
|
+
"detail": "Serializes once and sends to selected connected members."
|
|
1279
|
+
}
|
|
1280
|
+
],
|
|
1281
|
+
"article": {
|
|
1282
|
+
"eli5": "RoomRegistry is a set of labeled group chats. A socket can join a label, and one message can be delivered to everyone carrying that label.",
|
|
1283
|
+
"useWhen": "Use rooms for match participants, parties, regions, spectators, or any bounded route-local fan-out group.",
|
|
1284
|
+
"walkthrough": [
|
|
1285
|
+
"A join handler adds the socket to the requested match room.",
|
|
1286
|
+
"The move handler broadcasts the accepted update to that room and can exclude the sender.",
|
|
1287
|
+
"Disconnect cleanup removes every membership and empty rooms are reclaimed automatically."
|
|
1288
|
+
],
|
|
1289
|
+
"watchFor": "A room is a delivery group, not authoritative game state. Validate membership and permissions before broadcasting, and configure hard room and membership limits."
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
"id": "sessionregistry",
|
|
1294
|
+
"name": "SessionRegistry",
|
|
1295
|
+
"type": "Multiplayer",
|
|
1296
|
+
"summary": "Bounded, expiring ownership records for application-issued opaque session IDs. Redweb handles takeover and expiry; the application owns credential issuance and payload validation.",
|
|
1297
|
+
"usage": "class MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n sessions: { ttlMs: 30000, maxSessions: 10000 },\n })\n }\n}",
|
|
1298
|
+
"methods": [
|
|
1299
|
+
{
|
|
1300
|
+
"name": "socket.createSession(sessionId, data)",
|
|
1301
|
+
"detail": "Creates a bounded application-issued session owned by the current connection."
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
"name": "socket.resumeSession(sessionId)",
|
|
1305
|
+
"detail": "Atomically transfers ownership, closes the former owner, and returns stored data."
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
"name": "stop()",
|
|
1309
|
+
"detail": "Stops the one route-level sweep timer and clears every retained record."
|
|
1310
|
+
}
|
|
1311
|
+
],
|
|
1312
|
+
"article": {
|
|
1313
|
+
"eli5": "SessionRegistry is a numbered coat-check ticket. A reconnecting player presents the ticket and safely takes ownership of the stored session from an older connection.",
|
|
1314
|
+
"useWhen": "Use it for short reconnect windows, controlled connection takeover, and small pieces of application-issued resumable state.",
|
|
1315
|
+
"walkthrough": [
|
|
1316
|
+
"The application creates an opaque session ID and stores bounded state against the connected socket.",
|
|
1317
|
+
"After a disconnect, the record remains available for the configured TTL.",
|
|
1318
|
+
"Resume atomically transfers ownership and closes the former owner if it is still connected."
|
|
1319
|
+
],
|
|
1320
|
+
"watchFor": "Session IDs are credentials: issue them securely, never trust client-selected identity, limit stored data, and persist important state outside this in-memory registry."
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
"id": "protocolclient",
|
|
1325
|
+
"name": "ProtocolClient",
|
|
1326
|
+
"type": "Client",
|
|
1327
|
+
"summary": "Dependency-free helper from redweb/client for opt-in versioned routes. It builds, sends, and validates stable envelopes from the same checked-in schema used by server constants and TypeScript declarations.",
|
|
1328
|
+
"usage": "const { ProtocolClient, ERROR_CODES } = require('redweb/client')\n\nconst socket = new WebSocket('wss://game.example/match?redwebVersion=1')\nconst client = new ProtocolClient(socket, '1')\nclient.send('move', { x: 4, y: 2 }, { sequence: 17 })",
|
|
1329
|
+
"methods": [
|
|
1330
|
+
{
|
|
1331
|
+
"name": "constructor(socket, version)",
|
|
1332
|
+
"detail": "Wraps any socket-like object with send(data) and selects the envelope version."
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
"name": "envelope(type, payload, metadata?)",
|
|
1336
|
+
"detail": "Builds a stable versioned event with optional requestId and sequence."
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
"name": "send(type, payload, metadata?)",
|
|
1340
|
+
"detail": "Serializes and sends one versioned event through the wrapped socket."
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
"name": "parse(value)",
|
|
1344
|
+
"detail": "Parses and validates a protocol event or error envelope."
|
|
1345
|
+
}
|
|
1346
|
+
],
|
|
1347
|
+
"article": {
|
|
1348
|
+
"eli5": "ProtocolClient is a phrasebook shared with the browser. It puts outgoing messages into Redweb’s expected envelope and checks incoming envelopes before your code trusts them.",
|
|
1349
|
+
"useWhen": "Use it for opt-in versioned routes when browser clients should share protocol constants, parsing, sequencing, and error handling with the server.",
|
|
1350
|
+
"walkthrough": [
|
|
1351
|
+
"The client connects to the versioned route and creates a ProtocolClient for that version.",
|
|
1352
|
+
"send builds a stable typed envelope containing payload and optional sequence metadata.",
|
|
1353
|
+
"parse validates server messages so application code can respond to known errors such as rate limiting."
|
|
1354
|
+
],
|
|
1355
|
+
"watchFor": "Protocol validation is not domain validation. Continue checking payload shape, authorization, and game rules on the authoritative server."
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
"id": "socketregistry",
|
|
1360
|
+
"name": "SocketRegistry",
|
|
1361
|
+
"type": "WebSocket",
|
|
1362
|
+
"summary": "Small EventEmitter-backed list for socket-scoped entities (players, rooms, etc.). Emits `added` and `removed` events.",
|
|
1363
|
+
"usage": "const { SocketRegistry } = require('redweb')\n\nclass PlayerRegistry extends SocketRegistry {\n addPlayer(player) {\n this.add(player)\n this.emit('playerJoined', player)\n }\n}",
|
|
1364
|
+
"methods": [
|
|
1365
|
+
{
|
|
1366
|
+
"name": "add(item)",
|
|
1367
|
+
"detail": "Stores the item and emits an `added` event."
|
|
1368
|
+
},
|
|
1369
|
+
{
|
|
1370
|
+
"name": "remove(itemOrId, by = \"id\")",
|
|
1371
|
+
"detail": "Remove by object reference or by matching a property (defaults to \"id\"); returns true when removal occurred and emits `removed`."
|
|
1372
|
+
},
|
|
1373
|
+
{
|
|
1374
|
+
"name": "all()",
|
|
1375
|
+
"detail": "Returns a shallow copy of all stored items."
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
"name": "count()",
|
|
1379
|
+
"detail": "Convenience getter for `all().length`."
|
|
1380
|
+
}
|
|
1381
|
+
],
|
|
1382
|
+
"article": {
|
|
1383
|
+
"eli5": "SocketRegistry is the route’s attendance sheet. It knows which sockets are active and provides a controlled way to visit or disconnect them.",
|
|
1384
|
+
"useWhen": "Use it when route logic needs bounded connection tracking, fan-out, observability, or coordinated draining.",
|
|
1385
|
+
"walkthrough": [
|
|
1386
|
+
"Accepted connections are registered once and removed during close cleanup.",
|
|
1387
|
+
"Iteration works over the route-owned set rather than an application-global list.",
|
|
1388
|
+
"Shutdown can stop admissions, notify clients, and close the remaining registry deterministically."
|
|
1389
|
+
],
|
|
1390
|
+
"watchFor": "Do not retain sockets in parallel collections without cleanup. Prefer rooms or socket context for indexes with clear ownership."
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
"id": "basehandler",
|
|
1395
|
+
"name": "BaseHandler",
|
|
1396
|
+
"type": "WebSocket",
|
|
1397
|
+
"summary": "Abstract message handler. Provide a name in the constructor; clients send `{ type: name, ... }` to target JSON messages, while binary frames can be accepted and handled as raw Buffer payloads.",
|
|
1398
|
+
"usage": "const { BaseHandler } = require('redweb')\n\nclass UploadHandler extends BaseHandler {\n constructor() { super('upload') }\n\n onMessage(socket, message) {\n socket.sendJson({ type: 'upload:control', action: message.action })\n }\n\n acceptsBinary(socket, buffer) {\n return buffer.length > 0\n }\n\n onBinaryMessage(socket, buffer) {\n socket.sendJson({ type: 'upload:chunk', bytes: buffer.length })\n }\n}",
|
|
1399
|
+
"methods": [
|
|
1400
|
+
{
|
|
1401
|
+
"name": "constructor(name)",
|
|
1402
|
+
"detail": "Stores the handler name used by incoming messages."
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
"name": "handleMessage(socket, message)",
|
|
1406
|
+
"detail": "Calls onMessage; override only if you need pre/post handling logic."
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
"name": "onMessage(socket, message)",
|
|
1410
|
+
"detail": "Required; implement your message processing here. Throwing will close the socket with an error."
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
"name": "acceptsBinary(socket, buffer)",
|
|
1414
|
+
"detail": "Optional selector used by SocketRoute to choose a handler for binary frames. Return true when this handler should receive the Buffer."
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
"name": "handleBinaryMessage(socket, buffer)",
|
|
1418
|
+
"detail": "Calls onBinaryMessage. If onBinaryMessage is not implemented, Redweb sends a \"Binary messages are not supported by this handler\" error."
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
"name": "onBinaryMessage(socket, buffer)",
|
|
1422
|
+
"detail": "Optional; override for normal binary-message handling. The second argument is the raw Buffer payload."
|
|
1423
|
+
},
|
|
1424
|
+
{
|
|
1425
|
+
"name": "onInitialContact(socket)",
|
|
1426
|
+
"detail": "Optional hook for first-touch logic (not used by default route)."
|
|
1427
|
+
}
|
|
1428
|
+
],
|
|
1429
|
+
"article": {
|
|
1430
|
+
"eli5": "BaseHandler is a labeled mailbox. A message with the matching type goes directly into that mailbox, so your code does not need a switch statement.",
|
|
1431
|
+
"useWhen": "Create a small handler for each message type that deserves its own validation, authorization, rate policy, and behavior.",
|
|
1432
|
+
"walkthrough": [
|
|
1433
|
+
"The handler name declares the message type it accepts.",
|
|
1434
|
+
"SocketRoute performs dispatch before onMessage runs.",
|
|
1435
|
+
"The handler validates the payload, changes authoritative state, and sends or broadcasts the result."
|
|
1436
|
+
],
|
|
1437
|
+
"watchFor": "Do not add a second message.action dispatcher inside one handler. That hides protocol operations and defeats type-based routing."
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
"id": "sendjson",
|
|
1442
|
+
"name": "sendJson",
|
|
1443
|
+
"type": "Utility",
|
|
1444
|
+
"summary": "Utility to JSON.stringify data and send it over a `ws` socket.",
|
|
1445
|
+
"usage": "const { sendJson } = require('redweb')\n\nsendJson(socket, { type: 'ping' })",
|
|
1446
|
+
"methods": [
|
|
1447
|
+
{
|
|
1448
|
+
"name": "sendJson(socket, data)",
|
|
1449
|
+
"detail": "Serialises data and writes it to the socket."
|
|
1450
|
+
}
|
|
1451
|
+
],
|
|
1452
|
+
"article": {
|
|
1453
|
+
"eli5": "sendJson is a careful packer: give it a JavaScript value and it turns that value into one JSON message before placing it on the socket.",
|
|
1454
|
+
"useWhen": "Use it for structured server-to-client messages instead of repeating JSON.stringify and transport checks throughout handlers.",
|
|
1455
|
+
"walkthrough": [
|
|
1456
|
+
"Application code creates a normal object with a type and payload.",
|
|
1457
|
+
"sendJson serializes it once using the route’s safe sending path.",
|
|
1458
|
+
"The client receives one complete text frame and parses the matching JSON value."
|
|
1459
|
+
],
|
|
1460
|
+
"watchFor": "Serialization can fail on cycles and BigInt values, and large objects still consume memory. Bound payload sizes and keep messages purpose-specific."
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
"id": "errorcodes",
|
|
1465
|
+
"name": "ERROR_CODES",
|
|
1466
|
+
"type": "Constants",
|
|
1467
|
+
"summary": "Stable framework error codes shared by protocol-enabled servers and redweb/client.",
|
|
1468
|
+
"usage": "const { ERROR_CODES } = require('redweb')\n\n// INVALID_MESSAGE, UNKNOWN_HANDLER, HANDLER_FAILED,\n// BINARY_UNSUPPORTED, RATE_LIMITED, QUEUE_FULL,\n// CAPACITY_REACHED, INITIALIZATION_FAILED",
|
|
1469
|
+
"methods": [
|
|
1470
|
+
{
|
|
1471
|
+
"name": "Message errors",
|
|
1472
|
+
"detail": "INVALID_MESSAGE, UNKNOWN_HANDLER, HANDLER_FAILED, and BINARY_UNSUPPORTED."
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
"name": "Capacity errors",
|
|
1476
|
+
"detail": "RATE_LIMITED, QUEUE_FULL, and CAPACITY_REACHED."
|
|
1477
|
+
},
|
|
1478
|
+
{
|
|
1479
|
+
"name": "Lifecycle errors",
|
|
1480
|
+
"detail": "INITIALIZATION_FAILED."
|
|
1481
|
+
}
|
|
1482
|
+
],
|
|
1483
|
+
"article": {
|
|
1484
|
+
"eli5": "ERROR_CODES is a shared list of machine-readable reasons, like standardized traffic signs that every client interprets the same way.",
|
|
1485
|
+
"useWhen": "Use these constants whenever application behavior depends on a Redweb protocol failure rather than human-facing wording.",
|
|
1486
|
+
"walkthrough": [
|
|
1487
|
+
"The server emits a stable code inside its error envelope.",
|
|
1488
|
+
"The client compares it with ERROR_CODES instead of copying a string literal.",
|
|
1489
|
+
"UI or retry policy can change independently of the readable error message."
|
|
1490
|
+
],
|
|
1491
|
+
"watchFor": "Codes describe protocol outcomes, not every domain failure. Add your own namespaced application codes without changing Redweb’s meanings."
|
|
1492
|
+
}
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
"id": "socketoptions",
|
|
1496
|
+
"name": "SOCKET_OPTIONS",
|
|
1497
|
+
"type": "Constants",
|
|
1498
|
+
"summary": "Default WebSocket server options used by BaseSocketServer.",
|
|
1499
|
+
"usage": "const { SOCKET_OPTIONS } = require('redweb')\n// { port: 3000, ssl: null, listen: true, routes: [] }",
|
|
1500
|
+
"methods": [
|
|
1501
|
+
{
|
|
1502
|
+
"name": "port",
|
|
1503
|
+
"detail": "Default WebSocket port (3000)."
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
"name": "ssl",
|
|
1507
|
+
"detail": "Default TLS config (null)."
|
|
1508
|
+
},
|
|
1509
|
+
{
|
|
1510
|
+
"name": "listen",
|
|
1511
|
+
"detail": "Owned socket servers listen by default. Supplied servers remain caller-owned unless listen is explicitly true."
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
"name": "routes",
|
|
1515
|
+
"detail": "Routes array default (empty; a DefaultRoute is created when none are provided)."
|
|
1516
|
+
}
|
|
1517
|
+
],
|
|
1518
|
+
"article": {
|
|
1519
|
+
"eli5": "SOCKET_OPTIONS is the default settings card Redweb starts from before applying the socket choices you provide.",
|
|
1520
|
+
"useWhen": "Read it to understand defaults or build tooling that presents Redweb configuration, but pass explicit options for production decisions.",
|
|
1521
|
+
"walkthrough": [
|
|
1522
|
+
"Redweb begins with immutable documented defaults.",
|
|
1523
|
+
"User configuration overrides only the supplied fields.",
|
|
1524
|
+
"The normalized result is used consistently when the socket server starts."
|
|
1525
|
+
],
|
|
1526
|
+
"watchFor": "Treat exported defaults as documentation, not mutable global configuration. Never change the object to configure one server."
|
|
1527
|
+
}
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
"id": "httpoptions",
|
|
1531
|
+
"name": "HTTP_OPTIONS",
|
|
1532
|
+
"type": "Constants",
|
|
1533
|
+
"summary": "Frozen defaults used by the HTTP and HTTPS server constructors.",
|
|
1534
|
+
"usage": "const { HTTP_OPTIONS } = require('redweb')\n// port 80, bind 0.0.0.0, publicPaths ['./public'],\n// listen true, encoding 'json', and safe error exposure disabled",
|
|
1535
|
+
"methods": [
|
|
1536
|
+
{
|
|
1537
|
+
"name": "Server defaults",
|
|
1538
|
+
"detail": "port, bind, listen, ssl, logger, and static public paths."
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
"name": "Application defaults",
|
|
1542
|
+
"detail": "services, encoding, CORS, and safe error exposure."
|
|
1543
|
+
}
|
|
1544
|
+
],
|
|
1545
|
+
"article": {
|
|
1546
|
+
"eli5": "HTTP_OPTIONS is Redweb’s starter checklist for HTTP servers: port, bind address, public folders, encoding, and related defaults.",
|
|
1547
|
+
"useWhen": "Consult it when you need to know what omitted HttpServer options mean or when generating configuration documentation.",
|
|
1548
|
+
"walkthrough": [
|
|
1549
|
+
"The server copies its baseline HTTP choices.",
|
|
1550
|
+
"Your provided values are validated and merged.",
|
|
1551
|
+
"The completed configuration drives app creation and optional listener startup."
|
|
1552
|
+
],
|
|
1553
|
+
"watchFor": "Defaults are convenient locally but production networking should be explicit, especially bind address, port, CORS, and listener ownership."
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
"id": "encodings",
|
|
1558
|
+
"name": "ENCODINGS",
|
|
1559
|
+
"type": "Constants",
|
|
1560
|
+
"summary": "Supported request-body parser names for HTTP server configuration.",
|
|
1561
|
+
"usage": "const { ENCODINGS } = require('redweb')\n// ENCODINGS.json, ENCODINGS.urlencoded",
|
|
1562
|
+
"methods": [
|
|
1563
|
+
{
|
|
1564
|
+
"name": "json",
|
|
1565
|
+
"detail": "Use Express JSON body parsing."
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
"name": "urlencoded",
|
|
1569
|
+
"detail": "Use Express URL-encoded body parsing."
|
|
1570
|
+
}
|
|
1571
|
+
],
|
|
1572
|
+
"article": {
|
|
1573
|
+
"eli5": "ENCODINGS is a tiny menu that lets you choose whether request bodies arrive as JSON or traditional URL-encoded form data.",
|
|
1574
|
+
"useWhen": "Use the constants when configuring HTTP body parsing so spelling stays aligned with supported Redweb values.",
|
|
1575
|
+
"walkthrough": [
|
|
1576
|
+
"The selected constant is passed in HttpServer options.",
|
|
1577
|
+
"Redweb installs the corresponding Express body parser.",
|
|
1578
|
+
"Route handlers then read the parsed value from req.body."
|
|
1579
|
+
],
|
|
1580
|
+
"watchFor": "Body parsing is not schema validation. Limit body size and validate every field before using it."
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
"id": "methods",
|
|
1585
|
+
"name": "METHODS",
|
|
1586
|
+
"type": "Constants",
|
|
1587
|
+
"summary": "Lowercase HTTP verb helpers passed straight to Express route registration.",
|
|
1588
|
+
"usage": "const { METHODS } = require('redweb')\n// METHODS.GET, METHODS.POST, METHODS.PUT, METHODS.DELETE",
|
|
1589
|
+
"methods": [
|
|
1590
|
+
{
|
|
1591
|
+
"name": "GET",
|
|
1592
|
+
"detail": "Use with services array or Express: METHODS.GET"
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
"name": "POST",
|
|
1596
|
+
"detail": "Use with services array or Express: METHODS.POST"
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
"name": "PUT",
|
|
1600
|
+
"detail": "Use with services array or Express: METHODS.PUT"
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
"name": "DELETE",
|
|
1604
|
+
"detail": "Use with services array or Express: METHODS.DELETE"
|
|
1605
|
+
}
|
|
1606
|
+
],
|
|
1607
|
+
"article": {
|
|
1608
|
+
"eli5": "METHODS is a spelling-safe list of HTTP verbs—the labels on requests that say whether they read, create, replace, change, or delete something.",
|
|
1609
|
+
"useWhen": "Use it in Redweb service definitions to avoid scattered uppercase strings and accidental unsupported verbs.",
|
|
1610
|
+
"walkthrough": [
|
|
1611
|
+
"A service definition pairs its path with a METHODS value.",
|
|
1612
|
+
"BaseHttpServer registers the matching Express operation.",
|
|
1613
|
+
"Requests with another verb do not accidentally invoke that service."
|
|
1614
|
+
],
|
|
1615
|
+
"watchFor": "The HTTP verb is only one part of API semantics. Implement authentication, idempotency, validation, and appropriate status codes in the service."
|
|
1616
|
+
}
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
"id": "basesocketserver",
|
|
1620
|
+
"name": "BaseSocketServer",
|
|
1621
|
+
"type": "WebSocket",
|
|
1622
|
+
"summary": "Shared lifecycle and route-composition base for SocketServer and SecureSocketServer. Extend the concrete servers for normal applications; use this type when building infrastructure integrations.",
|
|
1623
|
+
"usage": "import { BaseSocketServer } from 'redweb'\n\n// SocketServer and SecureSocketServer inherit:\n// addRoute(), handleUpgrade(), beginDrain(), and shutdown().",
|
|
1624
|
+
"methods": [
|
|
1625
|
+
{
|
|
1626
|
+
"name": "addRoute(RouteClass)",
|
|
1627
|
+
"detail": "Creates and registers one route class while enforcing unique paths."
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
"name": "beginDrain()",
|
|
1631
|
+
"detail": "Stops accepting new upgrades across every registered route."
|
|
1632
|
+
},
|
|
1633
|
+
{
|
|
1634
|
+
"name": "shutdown()",
|
|
1635
|
+
"detail": "Drains routes and closes owned listeners without closing caller-owned servers."
|
|
1636
|
+
}
|
|
1637
|
+
],
|
|
1638
|
+
"article": {
|
|
1639
|
+
"eli5": "BaseSocketServer is the engine room beneath both plain and secure socket servers. It coordinates routes and upgrades without deciding how the outer listener was created.",
|
|
1640
|
+
"useWhen": "Use this advanced surface for custom integrations that need Redweb routing on a specially managed Node HTTP or HTTPS server.",
|
|
1641
|
+
"walkthrough": [
|
|
1642
|
+
"Your application supplies or prepares the listener.",
|
|
1643
|
+
"BaseSocketServer attaches bounded WebSocket upgrade and route lifecycle behavior.",
|
|
1644
|
+
"Concrete server ownership remains visible, including whether Redweb may listen or close it."
|
|
1645
|
+
],
|
|
1646
|
+
"watchFor": "Most applications should use SocketServer or SecureSocketServer. Reach for the base class only when listener ownership cannot be expressed by their options."
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
"id": "livehtmlserver",
|
|
1651
|
+
"name": "LiveHtmlServer",
|
|
1652
|
+
"type": "Live HTML",
|
|
1653
|
+
"summary": "Decorator-first server rendering and realtime browser updates on Redweb’s existing HTTP and WebSocket stack. Pages can be connection-scoped or intentionally shared.",
|
|
1654
|
+
"usage": "import { LiveHtmlServer } from 'redweb'\nimport { DocsPage, StatusPage } from './pages.js'\n\nconst server = new LiveHtmlServer({\n pages: [DocsPage, StatusPage],\n port: 8080,\n heartbeat: { intervalMs: 15_000, timeoutMs: 10_000 },\n})",
|
|
1655
|
+
"options": [
|
|
1656
|
+
"pages: non-empty array of classes decorated with page()",
|
|
1657
|
+
"templateRoot: optional root for colocated HTML and CSS assets",
|
|
1658
|
+
"sessionTtlMs and maxSessions: bound pending and reconnectable sessions",
|
|
1659
|
+
"maxConcurrentRenders: independent HTTP render concurrency ceiling",
|
|
1660
|
+
"heartbeat: detects half-open browser connections",
|
|
1661
|
+
"authenticate: binds HTTP renders and socket upgrades to one stable identity",
|
|
1662
|
+
"origins: exact allowlist or asynchronous origin predicate"
|
|
1663
|
+
],
|
|
1664
|
+
"methods": [
|
|
1665
|
+
{
|
|
1666
|
+
"name": "constructor(options)",
|
|
1667
|
+
"detail": "Builds the page renderer, HTTP routes, generated assets, and live WebSocket route on one listener."
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
"name": "shutdown()",
|
|
1671
|
+
"detail": "Aborts active renders, drains routes, disposes pages and components, and closes owned resources."
|
|
1672
|
+
}
|
|
1673
|
+
],
|
|
1674
|
+
"article": {
|
|
1675
|
+
"eli5": "LiveHtmlServer is a stage manager for server-rendered pages. It serves the first complete HTML scene, then carries approved actions backstage and sends updated pieces back.",
|
|
1676
|
+
"useWhen": "Use it for decorator-first HTML applications that need server state and realtime interaction without React, hydration, or a separate client API layer.",
|
|
1677
|
+
"walkthrough": [
|
|
1678
|
+
"HTTP rendering creates complete HTML and binds a stable page identity.",
|
|
1679
|
+
"The generated live socket accepts only declared actions for that page.",
|
|
1680
|
+
"State changes produce bounded updates while authentication and resource limits cover both transports."
|
|
1681
|
+
],
|
|
1682
|
+
"watchFor": "Decide deliberately whether page state is per connection or shared. Authenticate both HTTP and upgrade paths with the same identity and cap render concurrency."
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
"id": "livepage",
|
|
1687
|
+
"name": "LivePage and start",
|
|
1688
|
+
"type": "Live HTML",
|
|
1689
|
+
"summary": "A page is an ordinary decorated class; extending LivePage is optional. start() is the concise entry point that creates a LiveHtmlServer for one or more page classes.",
|
|
1690
|
+
"methods": [
|
|
1691
|
+
{
|
|
1692
|
+
"name": "start(PageClass, options?)",
|
|
1693
|
+
"detail": "Starts one decorated page, or an array of pages, with the concise Live HTML server API."
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
"name": "loading(context)",
|
|
1697
|
+
"detail": "Optional cancellable hook that runs before the initial server render."
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
"name": "connected(context)",
|
|
1701
|
+
"detail": "Optional hook that runs after the authenticated live socket connects."
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
"name": "disconnected(context)",
|
|
1705
|
+
"detail": "Optional hook for stopping connection-owned timers and subscriptions."
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
"name": "disposed()",
|
|
1709
|
+
"detail": "Optional idempotent final cleanup hook for pages and components."
|
|
1710
|
+
}
|
|
1711
|
+
],
|
|
1712
|
+
"article": {
|
|
1713
|
+
"eli5": "LivePage is one server-owned screen; start is the power button that publishes your collection of screens and their realtime connection.",
|
|
1714
|
+
"useWhen": "Use a LivePage class when a route owns state, actions, lifecycle, and rendered output; use start to launch the assembled application.",
|
|
1715
|
+
"walkthrough": [
|
|
1716
|
+
"The page decorator assigns the HTTP route and rendering metadata.",
|
|
1717
|
+
"A new page instance is created according to its configured scope.",
|
|
1718
|
+
"start builds the HTTP and socket surfaces, then returns a handle for orderly shutdown."
|
|
1719
|
+
],
|
|
1720
|
+
"watchFor": "Keep page constructors cheap and move cancellable preparation into lifecycle hooks. Always retain and await the returned shutdown handle."
|
|
1721
|
+
},
|
|
1722
|
+
"recipe": {
|
|
1723
|
+
"template": "realtime",
|
|
1724
|
+
"file": "src/app.tsx"
|
|
1725
|
+
},
|
|
1726
|
+
"language": "tsx",
|
|
1727
|
+
"usage": "import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
"id": "livedecorators",
|
|
1731
|
+
"name": "page, component, state, action, view",
|
|
1732
|
+
"type": "Live HTML",
|
|
1733
|
+
"summary": "Small TypeScript decorators declare routes, reusable component ownership, reactive server state, browser-callable actions, and collection item views.",
|
|
1734
|
+
"methods": [
|
|
1735
|
+
{
|
|
1736
|
+
"name": "page(path, options?)",
|
|
1737
|
+
"detail": "Registers a unique route plus template, CSS, sharing, metadata, caching, and live/static behavior."
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
"name": "component()",
|
|
1741
|
+
"detail": "Marks a class as a reusable state/action/lifecycle namespace."
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
"name": "component(render)",
|
|
1745
|
+
"detail": "Creates a concise synchronous function component for stateless reusable HTML."
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
"name": "state(options?)",
|
|
1749
|
+
"detail": "Publishes reassigned values; writable state may also receive bounded browser input."
|
|
1750
|
+
},
|
|
1751
|
+
{
|
|
1752
|
+
"name": "action({ input? })",
|
|
1753
|
+
"detail": "Explicitly exposes one method to rw-click or rw-submit. An optional Standard Schema input validates and transforms one submitted argument before invocation; ActionInput<typeof schema> describes its output. Invalid input stays recoverable, while validator bugs remain server failures. Undecorated methods stay unreachable."
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
"name": "rw-status=\"action\"",
|
|
1757
|
+
"detail": "Optional component-scoped placement for built-in action feedback. Without a slot, buttons/forms get an automatic status message. Pending duplicates from one control are suppressed; late responses preserve changed drafts and replacement forms. Disconnected actions are never queued or replayed."
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
"name": "view(stateName)",
|
|
1761
|
+
"detail": "Renders each item of one decorated array for an rw-each collection."
|
|
1762
|
+
}
|
|
1763
|
+
],
|
|
1764
|
+
"article": {
|
|
1765
|
+
"eli5": "The decorators are small labels: page says where a screen lives, component says what can be reused, state says what may change, action says what the browser may request, and view describes repeated items.",
|
|
1766
|
+
"useWhen": "Use them to make server-rendered ownership visible next to the class member it affects instead of maintaining a separate routing and binding manifest.",
|
|
1767
|
+
"walkthrough": [
|
|
1768
|
+
"@page registers the outer route and page policy.",
|
|
1769
|
+
"@state and @action expose only explicitly declared reactive behavior.",
|
|
1770
|
+
"The counter renders ordinary TSX over its state; changes reach both tabs because the page explicitly opts into shared state. Use the chat recipe to explore reusable class components."
|
|
1771
|
+
],
|
|
1772
|
+
"watchFor": "Decorators are an allow-list, not decoration. Keep actions narrow, validate their arguments, and avoid exposing arbitrary method invocation."
|
|
1773
|
+
},
|
|
1774
|
+
"recipe": {
|
|
1775
|
+
"template": "realtime",
|
|
1776
|
+
"file": "src/app.tsx"
|
|
1777
|
+
},
|
|
1778
|
+
"language": "tsx",
|
|
1779
|
+
"usage": "import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
"id": "jsxruntime",
|
|
1783
|
+
"name": "JSX rendering",
|
|
1784
|
+
"type": "Live HTML",
|
|
1785
|
+
"summary": "Dependency-free server-side TSX that renders directly to HtmlFragment values. It provides readable components, fragments, arrays, automatic escaping, safe attributes, and existing html-fragment interoperability without React, a virtual DOM, or hydration.",
|
|
1786
|
+
"usage": "// tsconfig.json\n// { \"compilerOptions\": { \"jsx\": \"react-jsx\", \"jsxImportSource\": \"redweb\" } }\n\nimport { component, page } from 'redweb'\nimport type { Child } from 'redweb/jsx-runtime'\n\nconst Card = component((props: { title: string; children?: Child }) => (\n <article class=\"card\">\n <h2>{props.title}</h2>\n {props.children}\n </article>\n))\n\n@page('/docs', { css: 'docs.css', live: false })\nclass DocsPage {\n render() {\n return <main><Card title=\"Redweb\">Readable server TSX</Card></main>\n }\n}",
|
|
1787
|
+
"options": [
|
|
1788
|
+
"TypeScript: jsx = react-jsx and jsxImportSource = redweb",
|
|
1789
|
+
"Production runtime: redweb/jsx-runtime; development runtime: redweb/jsx-dev-runtime",
|
|
1790
|
+
"External CSS and rw-* server directives replace inline styles and browser event functions"
|
|
1791
|
+
],
|
|
1792
|
+
"methods": [
|
|
1793
|
+
{
|
|
1794
|
+
"name": "Intrinsic elements",
|
|
1795
|
+
"detail": "Serialize standard, SVG, custom, data-*, aria-*, and rw-* attributes with HTML-correct boolean handling."
|
|
1796
|
+
},
|
|
1797
|
+
{
|
|
1798
|
+
"name": "Fragments and arrays",
|
|
1799
|
+
"detail": "Compose nested fragments and readonly child arrays without wrapper markup or comma coercion."
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
"name": "Function components",
|
|
1803
|
+
"detail": "Synchronous functions receive typed props and children and must return an HtmlFragment or fragment array."
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
"name": "Escaping and URLs",
|
|
1807
|
+
"detail": "Text and attributes escape automatically; URL attributes retain Redweb’s safe-protocol validation."
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
"name": "Interoperability",
|
|
1811
|
+
"detail": "Existing html fragments nest in TSX and TSX fragments nest in html for incremental migration."
|
|
1812
|
+
}
|
|
1813
|
+
],
|
|
1814
|
+
"article": {
|
|
1815
|
+
"eli5": "Redweb JSX is a readable HTML-shaped pencil. It turns TSX into safe server HTML directly—there is no React engine or browser copy hiding behind it.",
|
|
1816
|
+
"useWhen": "Use it whenever nested template strings become difficult to read or reusable server-rendered components make page structure clearer.",
|
|
1817
|
+
"walkthrough": [
|
|
1818
|
+
"TypeScript sends JSX calls to redweb/jsx-runtime.",
|
|
1819
|
+
"Text and attributes are escaped while fragments, arrays, and components are flattened predictably.",
|
|
1820
|
+
"The resulting HtmlFragment works with static pages and Live HTML without hydration."
|
|
1821
|
+
],
|
|
1822
|
+
"watchFor": "JSX is syntax, not automatic client reactivity. Only Live HTML state and actions create a realtime channel; static-site TSX remains zero-runtime HTML."
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
"id": "safehtml",
|
|
1827
|
+
"name": "html, attribute, url, each, codeBlock",
|
|
1828
|
+
"type": "Live HTML",
|
|
1829
|
+
"summary": "Safe composition primitives escape text and quoted primitive attributes by default. URL attributes additionally reject executable, protocol-relative, and malformed values. Arrays must contain trusted HtmlFragment values.",
|
|
1830
|
+
"usage": "import { codeBlock, each, html } from 'redweb'\n\nconst links = sections.map(section => ({\n id: section.id,\n href: `#${section.id}`,\n label: section.name,\n}))\n\nconst navigation = each(links, link => html`\n <a id=\"${link.id}\" href=\"${link.href}\">${link.label}</a>\n`)\n\nconst example = codeBlock(source, {\n language: 'ts',\n label: 'TypeScript',\n highlight: highlightTypeScript,\n})",
|
|
1831
|
+
"methods": [
|
|
1832
|
+
{
|
|
1833
|
+
"name": "html`...`",
|
|
1834
|
+
"detail": "Creates an HtmlFragment and escapes every ordinary interpolation."
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
"name": "attribute(value)",
|
|
1838
|
+
"detail": "Optionally brands a primitive for a quoted, non-URL attribute when explicit intent improves readability."
|
|
1839
|
+
},
|
|
1840
|
+
{
|
|
1841
|
+
"name": "url(value)",
|
|
1842
|
+
"detail": "Optionally brands a safe relative, HTTP, HTTPS, mail, or telephone URL; direct strings receive the same validation."
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
"name": "each(items, render)",
|
|
1846
|
+
"detail": "Validates and joins a mutable or readonly list of HtmlFragment results."
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
"name": "codeBlock(code, options?)",
|
|
1850
|
+
"detail": "Builds an escaped figure/pre/code fragment and can invoke a safe server-side highlighter."
|
|
1851
|
+
}
|
|
1852
|
+
],
|
|
1853
|
+
"article": {
|
|
1854
|
+
"eli5": "These helpers are different safety tools: html builds trusted structure, attribute and url escape risky contexts, each joins lists, and codeBlock displays code without executing it.",
|
|
1855
|
+
"useWhen": "Use them for low-level templates, dynamic attributes, URLs, collections, and code samples when JSX is not the clearest representation.",
|
|
1856
|
+
"walkthrough": [
|
|
1857
|
+
"Untrusted values are passed through the helper matching their HTML context.",
|
|
1858
|
+
"HtmlFragment values preserve the distinction between approved markup and ordinary text.",
|
|
1859
|
+
"Collection and code helpers produce predictable escaped output without hand-built concatenation."
|
|
1860
|
+
],
|
|
1861
|
+
"watchFor": "Escaping is context-specific. Never treat an escaped attribute as a safe URL or mark user-provided HTML as trusted."
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
"id": "definesite",
|
|
1866
|
+
"name": "defineSite",
|
|
1867
|
+
"type": "Live HTML",
|
|
1868
|
+
"summary": "Defines shared static-site CSS, metadata, caching, layout, canonical URLs, and export behavior once. Site pages are always runtime-free.",
|
|
1869
|
+
"usage": "import { defineSite } from 'redweb'\n\nconst docs = defineSite({\n origin: 'https://example.com',\n css: 'site.css',\n head: { description: 'Product documentation', image: '/og.png' },\n cache: { maxAge: 300 },\n layout: content => <body><nav>Product</nav><main>{content}</main></body>,\n})\n\n@docs.page('/docs', { head: { title: 'Documentation' } })\nclass DocsPage {\n render() { return <h1>Documentation</h1> }\n}\n\nawait docs.export(DocsPage, {\n outDir: 'dist',\n publicDir: 'public',\n})",
|
|
1870
|
+
"options": [
|
|
1871
|
+
"origin: optional HTTP(S) origin used to derive canonical and root-relative social-image URLs",
|
|
1872
|
+
"css, head, cache, and layout: defaults inherited by every site.page() decorator",
|
|
1873
|
+
"layout: synchronous function receiving the trusted page fragment and portable request context",
|
|
1874
|
+
"publicDir: optional link-free asset tree staged with generated output"
|
|
1875
|
+
],
|
|
1876
|
+
"methods": [
|
|
1877
|
+
{
|
|
1878
|
+
"name": "site.page(path, options?)",
|
|
1879
|
+
"detail": "Creates a non-live page decorator while merging shared defaults and page-specific overrides."
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
"name": "site.export(pageOrPages, options)",
|
|
1883
|
+
"detail": "Stages all pages and public assets, rejects path collisions, then writes the destination and returns every output path."
|
|
1884
|
+
}
|
|
1885
|
+
],
|
|
1886
|
+
"article": {
|
|
1887
|
+
"eli5": "defineSite is the shared blueprint for a collection of pages: one place for the frame, colors, metadata, cache policy, canonical links, and export rules.",
|
|
1888
|
+
"useWhen": "Use it for documentation, marketing, or content sites where many runtime-free pages should share layout and production behavior.",
|
|
1889
|
+
"walkthrough": [
|
|
1890
|
+
"The site definition establishes origin, common CSS, head metadata, cache policy, and layout.",
|
|
1891
|
+
"Decorated pages contribute their own route, title, description, and stylesheet.",
|
|
1892
|
+
"site.export stages every page and asset into one consistent static output."
|
|
1893
|
+
],
|
|
1894
|
+
"watchFor": "The layout must handle every exported route. Keep route-specific chrome derived from stable metadata rather than scattered path checks."
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
{
|
|
1898
|
+
"id": "exportstatic",
|
|
1899
|
+
"name": "exportStatic",
|
|
1900
|
+
"type": "Live HTML",
|
|
1901
|
+
"summary": "Renders non-live decorated pages to deterministic directory indexes and content-addressed CSS. It is intended for docs, marketing pages, and static hosting.",
|
|
1902
|
+
"usage": "import path from 'node:path'\nimport { exportStatic, page } from 'redweb'\n\n@page('/docs', { live: false, css: 'docs.css' })\nclass DocsPage {\n render() { return '<h1>Redweb docs</h1>' }\n}\n\nawait exportStatic(DocsPage, {\n outDir: path.resolve('dist'),\n templateRoot: path.resolve('src/pages'),\n})",
|
|
1903
|
+
"options": [
|
|
1904
|
+
"outDir: required output directory; existing unrelated files are preserved",
|
|
1905
|
+
"templateRoot: optional explicit root for templates and CSS",
|
|
1906
|
+
"logger: optional framework logger or null"
|
|
1907
|
+
],
|
|
1908
|
+
"methods": [
|
|
1909
|
+
{
|
|
1910
|
+
"name": "exportStatic(pageOrPages, options)",
|
|
1911
|
+
"detail": "Returns frozen page and asset path lists after every page renders and its CSS is written."
|
|
1912
|
+
}
|
|
1913
|
+
],
|
|
1914
|
+
"article": {
|
|
1915
|
+
"eli5": "exportStatic is a printing press: give it pages, and it writes finished HTML and assets that any ordinary static host can serve.",
|
|
1916
|
+
"useWhen": "Use it when you need direct static export without defining a reusable site object and its shared layout policy.",
|
|
1917
|
+
"walkthrough": [
|
|
1918
|
+
"Page metadata determines output routes and page assets.",
|
|
1919
|
+
"Every page renders on the server into a staging destination.",
|
|
1920
|
+
"The returned paths let build tooling audit or publish exactly what was produced."
|
|
1921
|
+
],
|
|
1922
|
+
"watchFor": "Export into a staging directory and replace the live build atomically. A failed render should never leave a partially updated site."
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
{
|
|
1926
|
+
"id": "htmlrenderer",
|
|
1927
|
+
"name": "HtmlRenderer",
|
|
1928
|
+
"type": "Live HTML",
|
|
1929
|
+
"summary": "Lower-level rendering utility behind Live HTML. Most applications should use page(), start(), defineSite(), and exportStatic(); this surface supports advanced integrations and tooling.",
|
|
1930
|
+
"usage": "import { HtmlRenderer } from 'redweb'\n\nconst markup = HtmlRenderer.render(\n '<h1>{{ title }}</h1>',\n { title: 'Reference' },\n { live: false },\n)\n\nconst document = HtmlRenderer.document(markup, null, [], {\n title: 'Reference',\n})",
|
|
1931
|
+
"methods": [
|
|
1932
|
+
{
|
|
1933
|
+
"name": "render(source, page, options?)",
|
|
1934
|
+
"detail": "Renders declarative bindings and collection views against a page object."
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
"name": "document(markup, config?, stylesheets?, metadata?)",
|
|
1938
|
+
"detail": "Wraps markup in a complete document and injects metadata, CSS, and optional live bootstrap."
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
"name": "template() / stylesheet()",
|
|
1942
|
+
"detail": "Loads a validated page asset inside an explicit root."
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
"name": "statePayload()",
|
|
1946
|
+
"detail": "Builds the text or trusted-HTML state payload used by live updates."
|
|
1947
|
+
}
|
|
1948
|
+
],
|
|
1949
|
+
"article": {
|
|
1950
|
+
"eli5": "HtmlRenderer is the machinery under the hood that turns a page object and its declared bindings into final markup.",
|
|
1951
|
+
"useWhen": "Use it for tooling or advanced integrations that genuinely need lower-level rendering control; normal applications should prefer pages, start, defineSite, or exportStatic.",
|
|
1952
|
+
"walkthrough": [
|
|
1953
|
+
"A source template or fragment and page instance enter the renderer.",
|
|
1954
|
+
"Declared state, actions, views, and safe values are resolved under the render options.",
|
|
1955
|
+
"The caller receives final HTML suitable for its own response or build pipeline."
|
|
1956
|
+
],
|
|
1957
|
+
"watchFor": "The lower-level API gives you more lifecycle responsibility. Preserve escaping, cancellation, ownership, and bounded concurrency rather than rebuilding them casually."
|
|
1958
|
+
}
|
|
1959
|
+
},
|
|
1960
|
+
{
|
|
1961
|
+
"id": "socketcontract",
|
|
1962
|
+
"name": "defineSocketContract",
|
|
1963
|
+
"type": "WebSocket",
|
|
1964
|
+
"language": "ts",
|
|
1965
|
+
"summary": "One shared Standard Schema contract validates wire payloads and infers client/server types. Route URLs choose the service; individual handler factories dispatch by message type.",
|
|
1966
|
+
"recipe": {
|
|
1967
|
+
"template": "socket",
|
|
1968
|
+
"file": "src/contract.ts"
|
|
1969
|
+
},
|
|
1970
|
+
"methods": [
|
|
1971
|
+
{
|
|
1972
|
+
"name": "defineSocketContract(version, schemas, options?)",
|
|
1973
|
+
"detail": "Creates an immutable contract and negotiated protocol policy from Standard Schema validators. Redweb does not require a particular validator at runtime."
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
"name": "handler(type, callback)",
|
|
1977
|
+
"detail": "Creates a BaseHandler subclass. Validation completes before the callback receives its typed payload."
|
|
1978
|
+
},
|
|
1979
|
+
{
|
|
1980
|
+
"name": "client(socket)",
|
|
1981
|
+
"detail": "Wraps an existing WebSocket with typed, validated send and parse methods. It does not open or reconnect the connection."
|
|
1982
|
+
},
|
|
1983
|
+
{
|
|
1984
|
+
"name": "send(socket, type, payload, metadata?)",
|
|
1985
|
+
"detail": "Validates server output before sending through the normal transport and protocol policy."
|
|
1986
|
+
}
|
|
1987
|
+
],
|
|
1988
|
+
"article": {
|
|
1989
|
+
"eli5": "A socket contract is a shared form for both sides of a conversation. It says which messages exist and what each message must contain, so the server checks a message before handing it to the matching handler.",
|
|
1990
|
+
"useWhen": "Use it when a site, app, or game client should share payload types and runtime validation with a routed Redweb service.",
|
|
1991
|
+
"walkthrough": [
|
|
1992
|
+
"The shared module defines join, move, resume, and state payload schemas once.",
|
|
1993
|
+
"The match route registers separate handlers created by the contract instead of switching on a secondary action field.",
|
|
1994
|
+
"The client and server validate their outgoing messages and parse incoming envelopes against the same schema."
|
|
1995
|
+
],
|
|
1996
|
+
"watchFor": "Schema validation is not authentication or game-rule validation. Keep bearer session IDs private, apply authorization in handlers, and persist important data outside the starter's bounded in-memory sessions. Async validation deadlines cannot preempt synchronous JavaScript."
|
|
1997
|
+
},
|
|
1998
|
+
"usage": "import { defineSocketContract } from 'redweb/contract';\nimport { z } from 'zod';\n\nconst position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };\n\n// Share this module with a browser or Node client. It imports no server application code.\nexport const match = defineSocketContract('1', {\n join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),\n move: z.object(position).strict(),\n resume: z.object({ session: z.string().uuid() }).strict(),\n state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),\n});\n"
|
|
1999
|
+
}
|
|
2000
|
+
],
|
|
2001
|
+
"examples": [
|
|
2002
|
+
{
|
|
2003
|
+
"id": "room-access",
|
|
2004
|
+
"label": "Private rooms",
|
|
2005
|
+
"language": "tsx",
|
|
2006
|
+
"title": "One identity for a page and a protected room",
|
|
2007
|
+
"summary": "A complete local demonstration of shared authentication, decorator-first server HTML and explicitly authorized raw socket room entry.",
|
|
2008
|
+
"notes": [
|
|
2009
|
+
"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.",
|
|
2010
|
+
"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.",
|
|
2011
|
+
"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."
|
|
2012
|
+
],
|
|
2013
|
+
"codeSource": "docs/snippets/room-access.tsx",
|
|
2014
|
+
"code": "import { randomBytes } from 'node:crypto';\nimport { page, start, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';\n\n// A runnable local demonstration, not a production credential store.\nexport function createApp(port = 8181) {\n const token = randomBytes(32).toString('base64url');\n let enabled = true;\n const authenticate = (request: Pick<RedWebRequest, 'headers'>) =>\n enabled && request.headers.authorization === `Bearer ${token}` ? 'alice' : false;\n\n @page('/', { authorize: context => context.principal === 'alice' })\n class Home {\n render({ principal }: LivePageRequestContext) { return <main><h1>Private workspace</h1><p>{principal}</p></main>; }\n }\n\n class Join extends BaseHandler {\n constructor() { super('join'); }\n async onMessage(socket: RedWebSocket) {\n socket.sendJson({ joined: await socket.enterRoom!('team'), principal: socket.context!.principal });\n }\n }\n class Team extends SocketRoute {\n constructor() {\n super({ path: '/team', handlers: [Join], allowDuplicateConnections: true, logger: null,\n admission: { authenticate },\n rooms: { authorize: (context, roomId) => enabled && context.principal === 'alice' && roomId === 'team' },\n });\n }\n }\n\n const app = start(Home, { listen: false, authenticate, logger: null });\n const team = app.sockets!.addRoute(Team);\n app.server.listen(port, '127.0.0.1');\n return {\n app, team, token,\n async revoke() {\n enabled = false; // Invalidate credentials and future permissions first.\n team.clients.forEach(socket => team.rooms!.leaveAll(socket));\n await app.revoke('alice');\n },\n shutdown: () => app.shutdown(),\n };\n}\n\nif (require.main === module) {\n const demo = createApp();\n console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');\n console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.\n process.once('SIGTERM', () => void demo.shutdown().catch(console.error));\n process.once('SIGINT', () => void demo.shutdown().catch(console.error));\n}\n"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
"id": "shared-server",
|
|
2018
|
+
"label": "Start here",
|
|
2019
|
+
"title": "HTTP and WebSockets on one listener",
|
|
2020
|
+
"summary": "Build the Express side without binding, attach route classes to the same Node server, and explicitly give the socket service responsibility for listening and cleanup.",
|
|
2021
|
+
"language": "tsx",
|
|
2022
|
+
"notes": [
|
|
2023
|
+
"GET /health returns JSON; ws://127.0.0.1:8181/chat accepts {\"type\":\"hello\"}. The HTTP endpoint reports liveness, not readiness.",
|
|
2024
|
+
"Use the complete http-ws starter for compiler configuration, the shared entrypoint helper, and actual HTTP/WebSocket tests. It binds loopback for local development.",
|
|
2025
|
+
"The socket service explicitly owns shared-listener cleanup with closeServerOnShutdown: true. Call its shutdown() rather than a second HTTP shutdown sequence. Configure authentication, trusted origins, limits, and HTTPS/WSS before public deployment."
|
|
2026
|
+
],
|
|
2027
|
+
"recipe": {
|
|
2028
|
+
"template": "http-ws",
|
|
2029
|
+
"file": "src/app.tsx"
|
|
2030
|
+
},
|
|
2031
|
+
"code": "import { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';\nimport { runApp } from './run-app';\n\nexport class Hello extends BaseHandler {\n constructor() { super('hello'); }\n\n onMessage(socket: RedWebSocket) {\n socket.sendJson({ type: 'hello', message: 'Hello from the server!' });\n }\n}\n\nexport class ChatRoute extends SocketRoute {\n constructor() {\n super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });\n }\n}\n\nexport function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {\n const http = new HttpServer({\n listen: false,\n publicPaths: [],\n services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],\n });\n\n return new SocketServer({\n port: options.port ?? Number(process.env.PORT ?? 8181),\n bind: options.bind ?? '127.0.0.1',\n logger: options.logger,\n server: http.server,\n routes: [ChatRoute],\n listen: true,\n closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.\n });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
"id": "live-html",
|
|
2035
|
+
"label": "Rendered UI",
|
|
2036
|
+
"language": "tsx",
|
|
2037
|
+
"title": "Server state and reusable TSX components",
|
|
2038
|
+
"summary": "Ordinary TSX expressions read server-owned state and update automatically after an action; no repeated binding names or browser component runtime.",
|
|
2039
|
+
"notes": [
|
|
2040
|
+
"The first response is complete server-rendered HTML.",
|
|
2041
|
+
"Function components handle stateless snippets; decorated classes own state and actions.",
|
|
2042
|
+
"There is no React dependency, virtual DOM, or hydration pass."
|
|
2043
|
+
],
|
|
2044
|
+
"recipe": {
|
|
2045
|
+
"template": "realtime",
|
|
2046
|
+
"file": "src/app.tsx"
|
|
2047
|
+
},
|
|
2048
|
+
"code": "import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';\nimport { runApp } from './run-app';\n\n@page('/', { css: 'app.css', shared: true })\nexport class CounterPage {\n @state() count = 0;\n\n @action()\n increment() { this.count += 1; }\n\n render() {\n return (\n <main class=\"home\">\n <h1>A counter owned by the server</h1>\n <p>Open this page in two tabs. Either button updates both.</p>\n <button rw-click=\"increment\">\n Count <output>{this.count}</output>\n </button>\n </main>\n );\n }\n}\n\nexport function createApp(options: LiveHtmlStartOptions = {}) {\n return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });\n}\n\nif (require.main === module) runApp(createApp);\n"
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
"id": "static-site",
|
|
2052
|
+
"label": "Static HTML",
|
|
2053
|
+
"language": "tsx",
|
|
2054
|
+
"title": "A complete site with shared defaults",
|
|
2055
|
+
"summary": "Define metadata, layout, CSS, caching, and asset export once, then keep each page focused on its content.",
|
|
2056
|
+
"notes": [
|
|
2057
|
+
"Canonical URLs are derived from the route and origin.",
|
|
2058
|
+
"Public assets and rendered pages are staged before output changes.",
|
|
2059
|
+
"The generated site contains no Redweb browser runtime or WebSocket."
|
|
2060
|
+
],
|
|
2061
|
+
"code": "import { defineSite } from 'redweb'\n\nconst site = defineSite({\n origin: 'https://example.com',\n css: 'site.css',\n head: { description: 'Product documentation', image: '/og.png' },\n cache: { maxAge: 300 },\n layout: content => <body><nav>Product</nav><main>{content}</main></body>,\n})\n\n@site.page('/docs', { head: { title: 'Documentation' } })\nclass DocsPage {\n render() { return <h1>Documentation</h1> }\n}\n\nawait site.export(DocsPage, {\n outDir: 'dist',\n publicDir: 'public',\n})"
|
|
2062
|
+
},
|
|
2063
|
+
{
|
|
2064
|
+
"id": "handlers",
|
|
2065
|
+
"label": "Messages",
|
|
2066
|
+
"title": "JSON routing, broadcast, and binary frames",
|
|
2067
|
+
"summary": "Text messages select a handler by type. Binary frames stay as Buffer values and can be accepted by the handler that understands them.",
|
|
2068
|
+
"notes": [
|
|
2069
|
+
"sendJson and broadcast share the same outbound policy.",
|
|
2070
|
+
"acceptsBinary can select among multiple binary handlers.",
|
|
2071
|
+
"Async handler failures become safe client errors."
|
|
2072
|
+
],
|
|
2073
|
+
"code": "const { BaseHandler, SocketRoute } = require('redweb')\n\nclass ChatHandler extends BaseHandler {\n constructor() { super('chat') }\n\n onMessage(socket, message) {\n socket.broadcast({ type: 'chat', text: message.text })\n }\n}\n\nclass SnapshotHandler extends BaseHandler {\n constructor() { super('snapshot') }\n onMessage() {}\n acceptsBinary(_socket, buffer) { return buffer.length > 0 }\n onBinaryMessage(socket, buffer) {\n socket.sendJson({ type: 'snapshot:received', bytes: buffer.length })\n }\n}\n\nclass RealtimeRoute extends SocketRoute {\n constructor() {\n super({\n path: '/realtime',\n handlers: [ChatHandler, SnapshotHandler],\n websocketOptions: { maxPayload: 64 * 1024 },\n })\n }\n}"
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
"id": "protected-route",
|
|
2077
|
+
"label": "Production",
|
|
2078
|
+
"title": "Bound admission, work, and slow peers",
|
|
2079
|
+
"summary": "Production controls are opt-in and route-local. Authenticate before upgrade, cap every queue, and use one heartbeat scheduler for the whole route.",
|
|
2080
|
+
"notes": [
|
|
2081
|
+
"Authentication completes before onInitialContact.",
|
|
2082
|
+
"Ordered overflow closes pending work synchronously.",
|
|
2083
|
+
"Disabled controls add no per-connection queue or timer."
|
|
2084
|
+
],
|
|
2085
|
+
"code": "class MatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [InputHandler],\n admission: {\n origins: ['https://game.example'],\n timeoutMs: 3000,\n authenticate: (request, { signal }) =>\n verifyPlayer(request, signal),\n },\n maxPendingUpgrades: 64,\n limits: {\n maxConnections: 5000,\n maxBufferedBytes: 1024 * 1024,\n maxPendingMessages: 64,\n messageRate: { capacity: 60, refillPerSecond: 30 },\n },\n orderedMessages: true,\n heartbeat: { intervalMs: 30000, timeoutMs: 10000 },\n websocketOptions: { maxPayload: 64 * 1024 },\n })\n }\n}"
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
"id": "rooms-sessions",
|
|
2089
|
+
"label": "Players",
|
|
2090
|
+
"language": "ts",
|
|
2091
|
+
"title": "Match handlers and resumable ownership",
|
|
2092
|
+
"summary": "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.",
|
|
2093
|
+
"notes": [
|
|
2094
|
+
"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.",
|
|
2095
|
+
"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.",
|
|
2096
|
+
"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.",
|
|
2097
|
+
"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."
|
|
2098
|
+
],
|
|
2099
|
+
"recipe": {
|
|
2100
|
+
"template": "socket",
|
|
2101
|
+
"file": "src/handlers.ts"
|
|
2102
|
+
},
|
|
2103
|
+
"code": "import { randomUUID } from 'node:crypto';\nimport type { RedWebSocket } from 'redweb';\nimport { match } from './contract';\n\nclass Player {\n readonly session = randomUUID();\n x = 0;\n y = 0;\n constructor(readonly name: string) {}\n}\n\nfunction requireUnjoined(socket: RedWebSocket) {\n if (socket.context?.session) throw new Error('Already joined.');\n}\n\nfunction currentPlayer(socket: RedWebSocket) {\n const session = socket.context?.session as { data?: unknown } | null | undefined;\n if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');\n return session.data;\n}\n\nexport const Join = match.handler('join', (socket, { name }, message) => {\n requireUnjoined(socket);\n const player = new Player(name);\n if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Move = match.handler('move', (socket, { x, y }, message) => {\n const player = currentPlayer(socket);\n player.x = x;\n player.y = y;\n return match.send(socket, 'state', player, { requestId: message.requestId });\n});\n\nexport const Resume = match.handler('resume', (socket, { session }, message) => {\n requireUnjoined(socket);\n if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');\n return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });\n});\n"
|
|
2104
|
+
},
|
|
2105
|
+
{
|
|
2106
|
+
"id": "fixed-step",
|
|
2107
|
+
"label": "Simulation",
|
|
2108
|
+
"title": "Fixed-step work without overlapping ticks",
|
|
2109
|
+
"summary": "FixedStepService compensates for scheduler drift, bounds catch-up, contains async failures, and reports lag that was deliberately dropped.",
|
|
2110
|
+
"notes": [
|
|
2111
|
+
"The active async tick must finish before another begins.",
|
|
2112
|
+
"maxCatchUpTicks prevents a spiral of death.",
|
|
2113
|
+
"maxRetainedLagMs bounds remembered delay."
|
|
2114
|
+
],
|
|
2115
|
+
"code": "const { FixedStepService, SocketRoute } = require('redweb')\n\nclass Simulation extends FixedStepService {\n constructor() {\n super('simulation', 50, 3, 250)\n }\n\n async onTick(stepMs, tick) {\n await authoritativeGame.update(stepMs, tick)\n }\n\n onLagDropped(milliseconds) {\n console.warn('Simulation lag discarded', { milliseconds })\n }\n}\n\nclass SimulationRoute extends SocketRoute {\n constructor() {\n super({\n path: '/simulation',\n handlers: [InputHandler],\n services: [Simulation],\n })\n }\n}"
|
|
2116
|
+
},
|
|
2117
|
+
{
|
|
2118
|
+
"id": "protocol",
|
|
2119
|
+
"label": "Clients",
|
|
2120
|
+
"title": "Versioned envelopes and the dependency-free client",
|
|
2121
|
+
"summary": "Negotiate a finite protocol version before upgrade, then share stable envelopes and error codes between server and client.",
|
|
2122
|
+
"notes": [
|
|
2123
|
+
"Browsers negotiate with redwebVersion in the query.",
|
|
2124
|
+
"requestId correlates; sequence expresses application ordering.",
|
|
2125
|
+
"Neither field promises durability or exactly-once delivery."
|
|
2126
|
+
],
|
|
2127
|
+
"code": "const { SocketRoute } = require('redweb')\n\nclass ProtocolRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [MoveHandler],\n protocol: {\n versions: ['2', '1'],\n binary: {\n maxBytes: 64 * 1024,\n encode: (state) => codec.encode(state),\n decode: (bytes) => codec.decode(bytes),\n },\n },\n })\n }\n}\n\n// Browser client\nconst { ProtocolClient, ERROR_CODES } = require('redweb/client')\nconst socket = new WebSocket(\n 'wss://game.example/match?redwebVersion=2'\n)\nconst client = new ProtocolClient(socket, '2')\n\nsocket.addEventListener('message', (event) => {\n const message = client.parse(event)\n if (message.error?.code === ERROR_CODES.RATE_LIMITED) backOff()\n})\n\nclient.send('move', { x: 4, y: 2 }, { sequence: 17 })"
|
|
2128
|
+
},
|
|
2129
|
+
{
|
|
2130
|
+
"id": "distribution",
|
|
2131
|
+
"label": "Multiple nodes",
|
|
2132
|
+
"title": "Bring your own broker adapter",
|
|
2133
|
+
"summary": "Redweb supplies a bounded composition seam rather than choosing infrastructure. Events are finite, deduplicated briefly, and explicitly best-effort.",
|
|
2134
|
+
"notes": [
|
|
2135
|
+
"Required adapters affect readiness; best-effort adapters do not.",
|
|
2136
|
+
"Source-node events are ignored to prevent reflection loops.",
|
|
2137
|
+
"Authoritative state and partition reconciliation remain application work."
|
|
2138
|
+
],
|
|
2139
|
+
"code": "const { SocketRoute } = require('redweb')\n\nclass DistributedMatchRoute extends SocketRoute {\n constructor() {\n super({\n path: '/match',\n handlers: [JoinMatchHandler, MoveMatchHandler, ResumeMatchHandler],\n rooms: true,\n distribution: {\n adapter: brokerAdapter,\n channel: 'matches',\n nodeId: process.env.INSTANCE_ID,\n required: true,\n maxEventBytes: 64 * 1024,\n maxConcurrentPublishes: 32,\n onEvent(event, route) {\n route.rooms.broadcast(event.payload.roomId, {\n type: event.type,\n payload: event.payload,\n })\n },\n },\n })\n }\n}\n\n// From a connected socket:\nawait socket.publishEvent('match:update', update)"
|
|
2140
|
+
},
|
|
2141
|
+
{
|
|
2142
|
+
"id": "draining",
|
|
2143
|
+
"label": "Operations",
|
|
2144
|
+
"title": "Readiness first, then bounded shutdown",
|
|
2145
|
+
"summary": "Stop placement to the node, flip readiness, let cooperative handlers observe cancellation, and await deterministic cleanup.",
|
|
2146
|
+
"notes": [
|
|
2147
|
+
"New upgrades receive 503 once draining starts.",
|
|
2148
|
+
"The route signal is shared through socket.context.signal.",
|
|
2149
|
+
"A hard deadline terminates non-cooperating peers."
|
|
2150
|
+
],
|
|
2151
|
+
"code": "const { HttpServer, SocketServer } = require('redweb')\n\nconst http = new HttpServer({ listen: false })\nconst socketServer = new SocketServer({\n server: http.server,\n routes: [MatchRoute],\n})\n\nhttp.app.get('/ready', (_request, response) => {\n response.sendStatus(socketServer.isReady() ? 200 : 503)\n})\n\nhttp.server.listen(3000)\n\nprocess.once('SIGTERM', async () => {\n socketServer.beginDrain()\n await stopExternalPlacement()\n await socketServer.shutdown()\n await http.shutdown()\n})\n\n// In a handler with drainHandlers: true\nawait saveCheckpoint({ signal: socket.context.signal })"
|
|
2152
|
+
}
|
|
2153
|
+
]
|
|
2154
|
+
}
|