redweb 0.8.0 → 0.10.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +573 -307
  3. package/client.d.ts +42 -0
  4. package/client.js +55 -0
  5. package/docs/LIVE_HTML.md +313 -0
  6. package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
  7. package/docs/PRODUCTION_READINESS.md +68 -0
  8. package/docs/VERIFICATION_EVIDENCE.md +20 -0
  9. package/examples/live-html/cards.css +36 -0
  10. package/examples/live-html/cards.html +11 -0
  11. package/examples/live-html/cards.js +91 -0
  12. package/examples/live-html/cards.ts +35 -0
  13. package/examples/live-html/chatroom.css +156 -0
  14. package/examples/live-html/chatroom.js +268 -0
  15. package/examples/live-html/chatroom.ts +217 -0
  16. package/examples/live-html/components.css +7 -0
  17. package/examples/live-html/components.js +113 -0
  18. package/examples/live-html/components.ts +41 -0
  19. package/examples/live-html/counter.css +24 -0
  20. package/examples/live-html/counter.html +10 -0
  21. package/examples/live-html/counter.js +73 -0
  22. package/examples/live-html/counter.ts +21 -0
  23. package/examples/live-html/tsconfig.json +16 -0
  24. package/index.d.ts +538 -114
  25. package/index.js +44 -12
  26. package/package.json +39 -15
  27. package/src/htmx/Html.js +133 -0
  28. package/src/htmx/HtmlRenderer.js +88 -0
  29. package/src/htmx/HtmlSyntax.js +168 -0
  30. package/src/htmx/LiveHtmlServer.js +91 -0
  31. package/src/htmx/LivePage.js +232 -0
  32. package/src/htmx/PageAssetLoader.js +34 -0
  33. package/src/htmx/PageManager.js +435 -0
  34. package/src/htmx/StaticExporter.js +78 -0
  35. package/src/htmx/StaticSite.js +182 -0
  36. package/src/htmx/TemplateRenderer.js +231 -0
  37. package/src/htmx/browserRuntime.js +97 -0
  38. package/src/htmx/index.js +10 -0
  39. package/src/htmx/metadata.js +349 -0
  40. package/src/htmx/sourceRoot.js +28 -0
  41. package/src/htmx/start.js +17 -0
  42. package/src/htmx/synchronous.js +9 -0
  43. package/src/http/BaseHttpServer.js +82 -117
  44. package/src/http/HttpServer.js +18 -18
  45. package/src/http/HttpsServer.js +20 -20
  46. package/src/serverLifecycle.js +46 -46
  47. package/src/ws/AdmissionPolicy.js +145 -0
  48. package/src/ws/BaseHandler.js +40 -40
  49. package/src/ws/BaseSocketServer.js +199 -100
  50. package/src/ws/DefaultHandler.js +5 -5
  51. package/src/ws/DefaultRoute.js +8 -8
  52. package/src/ws/DistributionBridge.js +271 -0
  53. package/src/ws/FixedStepService.js +74 -0
  54. package/src/ws/HeartbeatMonitor.js +75 -0
  55. package/src/ws/Metrics.js +34 -0
  56. package/src/ws/ProtocolPolicy.js +130 -0
  57. package/src/ws/RoomRegistry.js +117 -0
  58. package/src/ws/RouteRuntime.js +146 -0
  59. package/src/ws/SecureSocketServer.js +9 -9
  60. package/src/ws/SessionRegistry.js +135 -0
  61. package/src/ws/SocketRoute.js +523 -254
  62. package/src/ws/SocketServer.js +8 -8
  63. package/src/ws/TaskQueue.js +64 -0
  64. package/src/ws/TokenBucket.js +31 -0
  65. package/src/ws/TransportPolicy.js +68 -0
  66. package/src/ws/index.js +7 -2
  67. package/src/ws/protocol-schema.json +13 -0
  68. package/src/ws/protocol-validation.js +21 -0
  69. package/src/ws/shutdown.js +33 -33
  70. package/src/ws/util.js +38 -30
  71. package/src/htmx/HtmxRenderer.js +0 -73
  72. package/src/htmx/RedWebHtmxComponent.js +0 -11
package/README.md CHANGED
@@ -1,307 +1,573 @@
1
- # RedWeb
2
-
3
- RedWeb is a small Node.js helper that wires together Express HTTP/HTTPS servers and `ws` WebSocket servers with simple defaults. Use it to serve static files plus JSON APIs and to route WebSocket traffic to handler classes.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install redweb
9
- ```
10
-
11
- ## Exports
12
-
13
- ```js
14
- const {
15
- HttpServer, // HTTP over Express
16
- HttpsServer, // HTTP with TLS (key/cert required)
17
- SocketServer, // WebSocket over HTTP
18
- SecureSocketServer, // WebSocket over HTTPS
19
- SocketRoute, // Per-path WebSocket routing
20
- SocketService, // Route-scoped background/tick logic
21
- SocketRegistry, // Evented in-memory store
22
- BaseHttpServer, // Express app builder for advanced composition
23
- BaseHandler, // WebSocket message handler base
24
- sendJson, // Utility to stringify+send
25
- HTTP_OPTIONS, // Defaults for HTTP servers
26
- ENCODINGS, // json/urlencoded encoding names
27
- SOCKET_OPTIONS, // Defaults for socket servers
28
- METHODS // Express method helpers
29
- } = require('redweb');
30
- ```
31
-
32
- ## HTTP servers (Express)
33
-
34
- `new HttpServer(options)` creates a Node HTTP server and starts listening immediately by default (default port `80`). `new HttpsServer({ ssl: { key, cert }, ... })` does the same over TLS.
35
-
36
- Options:
37
-
38
- - `port` (number): defaults to `80`.
39
- - `bind` (string): defaults to `0.0.0.0`.
40
- - `publicPaths` (string[]): folders served as static assets.
41
- - `services` (array): `{ serviceName, method, function }` for REST endpoints.
42
- - `listen` (boolean): defaults to `true`; set `false` to build `.app` and `.server` without binding a port.
43
- - `listenCallback` (function): invoked after `.listen`.
44
- - `encoding` (`'json' | 'urlencoded'`): body parser selection.
45
- - `corsOptions`: passed to `cors`.
46
- - `corsOptions: false`: disables the CORS middleware entirely.
47
- - `enableHtmxRendering` (boolean): render `.htmx` files with the built-in renderer.
48
- - `exposeErrors` (boolean): include HTMX rendering details in responses; defaults to `false`.
49
- - `logger`: an object with optional `log`, `warn`, and `error` methods. Pass `null` to disable library logging.
50
-
51
- Example:
52
-
53
- ```js
54
- const { HttpServer, METHODS } = require('redweb');
55
-
56
- new HttpServer({
57
- port: 3000,
58
- publicPaths: ['./public'],
59
- services: [
60
- {
61
- serviceName: '/api/hello',
62
- method: METHODS.GET,
63
- function: (req, res) => res.json({ hello: 'world' })
64
- }
65
- ]
66
- });
67
- ```
68
-
69
- HTMX rendering example (`enableHtmxRendering: true`):
70
-
71
- ```js
72
- new HttpServer({ publicPaths: ['./public'], enableHtmxRendering: true });
73
- ```
74
-
75
- `public/example.htmx`:
76
-
77
- ```js
78
- const name = 'RedWeb';
79
-
80
- <@>
81
- <h1>Hello, {{name}}!</h1>
82
- <@/>
83
- ```
84
-
85
- Requesting `/example.htmx` returns rendered HTML.
86
-
87
- Templates are trusted server-side code. They may load relative modules within their configured public directory, execute for at most one second by default, and interpolate raw HTML. Never render user-supplied template files.
88
-
89
- CORS remains permissive by default for backward compatibility. CORS is not authorization; configure `corsOptions`, add authentication middleware to `server.app`, or disable the middleware as appropriate.
90
-
91
- ## WebSocket servers
92
-
93
- `SocketServer` uses `ws` and routes connections to `SocketRoute` instances. Clients must send JSON containing a `type` that matches a handler name.
94
-
95
- Handler:
96
-
97
- ```js
98
- const { BaseHandler } = require('redweb');
99
-
100
- class ChatHandler extends BaseHandler {
101
- constructor() { super('chat'); }
102
-
103
- onMessage(socket, message) {
104
- socket.broadcast({ type: 'chat', text: message.text });
105
- }
106
- }
107
- ```
108
-
109
- Route:
110
-
111
- ```js
112
- const { SocketRoute } = require('redweb');
113
-
114
- class ChatRoute extends SocketRoute {
115
- constructor() {
116
- super({
117
- path: '/chat',
118
- handlers: [ChatHandler],
119
- allowDuplicateConnections: true // otherwise one connection per IP
120
- });
121
- }
122
- }
123
- ```
124
-
125
- Server:
126
-
127
- ```js
128
- const { SocketServer } = require('redweb');
129
-
130
- new SocketServer({
131
- port: 3000, // default
132
- routes: [ChatRoute], // defaults to a route at "/" with DefaultHandler if omitted
133
- });
134
- ```
135
-
136
- Each connected socket gets:
137
-
138
- - `socket.sendJson(data)` to send JSON.
139
- - `socket.broadcast(data)` to send JSON to all other clients on the same route.
140
-
141
- Invalid JSON triggers an error response and closes the socket.
142
-
143
- ### Binary WebSocket messages
144
-
145
- Text frames are still parsed as JSON and routed by `message.type`. Binary frames are dispatched separately, so handlers can receive raw `Buffer` payloads without triggering JSON parse errors.
146
-
147
- ```js
148
- const { BaseHandler, SocketRoute } = require('redweb');
149
-
150
- class UploadHandler extends BaseHandler {
151
- constructor() { super('upload'); }
152
-
153
- onMessage(socket, message) {
154
- socket.sendJson({ type: 'upload:control', action: message.action });
155
- }
156
-
157
- onBinaryMessage(socket, buffer) {
158
- socket.sendJson({ type: 'upload:chunk', bytes: buffer.length });
159
- }
160
- }
161
-
162
- class UploadRoute extends SocketRoute {
163
- constructor() {
164
- super({
165
- path: '/upload',
166
- handlers: [UploadHandler],
167
- allowDuplicateConnections: true,
168
- websocketOptions: {
169
- maxPayload: 2 * 1024 * 1024
170
- }
171
- });
172
- }
173
- }
174
- ```
175
-
176
- `BaseHandler` provides `handleBinaryMessage(socket, buffer)` and `onBinaryMessage(socket, buffer)`. Override `onBinaryMessage` for normal use. If a handler does not override it, RedWeb sends:
177
-
178
- ```json
179
- { "error": "Binary messages are not supported by this handler" }
180
- ```
181
-
182
- Routes may also select a binary-capable handler with `acceptsBinary(socket, buffer)`:
183
-
184
- ```js
185
- class ImageHandler extends BaseHandler {
186
- constructor() { super('image'); }
187
-
188
- acceptsBinary(socket, buffer) {
189
- return buffer.length > 0;
190
- }
191
-
192
- onMessage(socket, message) {}
193
- onBinaryMessage(socket, buffer) {}
194
- }
195
- ```
196
-
197
- ### WebSocket route options
198
-
199
- `SocketRoute` accepts `websocketOptions`, which are passed to `new WebSocketServer(...)`. Use this for `ws` server settings such as `maxPayload` or `perMessageDeflate`.
200
- Redweb controls `noServer`, `path`, `server`, and `port`; do not include them in `websocketOptions`. Route selection is performed once by Redweb so strict matching and optional root fallback behave consistently. Handshake authentication can use the `ws` `verifyClient` option, although authenticating in the surrounding HTTP upgrade flow is preferable for complex applications.
201
-
202
- ```js
203
- class ClipboardRoute extends SocketRoute {
204
- constructor() {
205
- super({
206
- path: '/clipboard',
207
- handlers: [ClipboardHandler],
208
- websocketOptions: {
209
- maxPayload: 1024 * 1024,
210
- perMessageDeflate: false
211
- }
212
- });
213
- }
214
- }
215
- ```
216
-
217
- Other route options:
218
-
219
- - `trustProxy`: use the first `X-Forwarded-For` value as the connection identity. Enable this only behind a trusted proxy.
220
- - `getClientKey(req)`: provide application-specific connection identity logic instead of IP-based identity.
221
- - `exposeErrors`: return handler exception messages to clients; defaults to `false`.
222
- - `logger`: route logger with optional `log`, `warn`, and `error` methods; pass `null` to disable it.
223
- - `shutdownTimeoutMs`: grace period before non-cooperating peers are terminated during shutdown; defaults to `1000`.
224
-
225
- `BaseHandler.validateMessage(message, socket)` may return `false` or a promise resolving to `false` to reject a message. Text and binary handlers may be asynchronous; rejected promises are caught and converted to safe error responses.
226
-
227
- ### Sharing an HTTP/HTTPS server
228
-
229
- Use `listen: false` on `HttpServer` to build the Express app and Node server without binding a port. Then pass `httpServer.server` to `SocketServer`. When `SocketServer` receives a prebuilt `server`, it attaches upgrade handling but does not call `.listen()` unless you explicitly set `listen: true`.
230
-
231
- ```js
232
- const { HttpServer, METHODS, SocketServer } = require('redweb');
233
-
234
- const httpServer = new HttpServer({
235
- port: 3030,
236
- listen: false,
237
- publicPaths: ['./public'],
238
- services: [
239
- { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },
240
- { serviceName: '/session', method: METHODS.POST, function: createSession }
241
- ]
242
- });
243
-
244
- new SocketServer({
245
- server: httpServer.server,
246
- routes: [ClipboardRoute]
247
- });
248
-
249
- httpServer.server.listen(3030, () => console.log('HTTP and WebSocket server listening on 3030'));
250
- ```
251
-
252
- ### Socket services
253
-
254
- Route-scoped background logic:
255
-
256
- ```js
257
- const { SocketService } = require('redweb');
258
-
259
- class ClockService extends SocketService {
260
- constructor() { super('clock', 1000); } // tick every 1s
261
- onTick() {
262
- this.route.clients.forEach((socket) => socket.sendJson({ type: 'time', now: Date.now() }));
263
- }
264
- }
265
- ```
266
-
267
- Add with `services: [ClockService]` when constructing a `SocketRoute`.
268
-
269
- ### Socket registries
270
-
271
- `SocketRegistry` is a small evented list for socket-bound objects.
272
-
273
- ```js
274
- const { SocketRegistry } = require('redweb');
275
-
276
- class PlayerRegistry extends SocketRegistry {
277
- addPlayer(player) {
278
- this.add(player);
279
- this.emit('playerJoined', player);
280
- }
281
- }
282
- ```
283
-
284
- Helpers: `add`, `remove(itemOrId, byKey = 'id')`, `all()`, `count()`.
285
-
286
- ## Defaults and lifecycle
287
-
288
- - HTTP defaults: port `80`, bind `0.0.0.0`, `listen: true`.
289
- - WebSocket defaults: port `3000`, single connection per IP unless `allowDuplicateConnections` is set.
290
- - `SocketServer` owns and listens on its own server by default; if you pass `server`, you own calling `.listen()` unless you also pass `listen: true`.
291
- - Upgrade paths are matched strictly by default. Set `fallbackToRoot: true` for legacy behavior that sends unmatched paths to `/`.
292
- - If you do not supply `routes`, `SocketServer` registers a default route at `/` with `DefaultHandler` (it expects messages with `type: 'DefaultHandler'`).
293
- - `shutdown()` closes routes and services. It closes an owned listener, but leaves a supplied listener running unless `closeServerOnShutdown: true` is set.
294
- - Shutdown is best-effort: all hooks, clients, routes, and owned listeners are processed before collected cleanup errors are reported.
295
- - `HttpServer` and `HttpsServer` expose an idempotent async `shutdown()` helper.
296
-
297
- ## 0.8 migration notes
298
-
299
- - Unmatched WebSocket paths are rejected unless `fallbackToRoot: true` is configured.
300
- - Handler exception details are hidden unless `exposeErrors: true` is configured.
301
- - Shutting down a WebSocket server no longer closes a caller-supplied HTTP/HTTPS server by default.
302
- - `bind` is now honored by HTTP, HTTPS, WebSocket, and secure WebSocket listeners.
303
- - `shutdown()` is asynchronous; await it when deterministic cleanup matters.
304
-
305
- ## Developing
306
-
307
- - Run tests with `npm test` (Jest). The suite includes mock-free HTTP, HTTPS, WebSocket, and secure WebSocket integration tests plus unit tests, with 100% coverage enforced for statements, branches, functions, and lines.
1
+ # RedWeb
2
+
3
+ RedWeb is a small Node.js transport foundation that wires together Express HTTP/HTTPS servers and `ws` WebSocket servers with simple defaults. Use it for ordinary web apps or opt into bounded multiplayer controls without adopting a broker, identity system, or game-state framework.
4
+
5
+ Version 0.9 adds production-minded multiplayer building blocks while preserving the 0.8 API and wire behavior when they are disabled. Redweb owns transport boundaries and lifecycle; your game remains responsible for authoritative state, rules, matchmaking, persistence, and identity.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install redweb
11
+ ```
12
+
13
+ ## Exports
14
+
15
+ ```js
16
+ const {
17
+ HttpServer, // HTTP over Express
18
+ HttpsServer, // HTTP with TLS (key/cert required)
19
+ SocketServer, // WebSocket over HTTP
20
+ SecureSocketServer, // WebSocket over HTTPS
21
+ SocketRoute, // Per-path WebSocket routing
22
+ SocketService, // Route-scoped background/tick logic
23
+ FixedStepService, // Drift-aware, non-overlapping simulation ticks
24
+ SocketRegistry, // Evented in-memory store
25
+ RoomRegistry, // Bounded route-local connection groups
26
+ SessionRegistry, // Bounded, expiring application-issued sessions
27
+ BaseHttpServer, // Express app builder for advanced composition
28
+ BaseHandler, // WebSocket message handler base
29
+ sendJson, // Utility to stringify+send
30
+ HTTP_OPTIONS, // Defaults for HTTP servers
31
+ ENCODINGS, // json/urlencoded encoding names
32
+ SOCKET_OPTIONS, // Defaults for socket servers
33
+ METHODS, // Express method helpers
34
+ LiveHtmlServer, // SSR plus lifecycle-safe realtime HTML
35
+ HtmlRenderer, // Safe HTML templates, collections, and state payloads
36
+ LivePage, // Optional base for advanced page internals
37
+ page, state, action, view, // Live HTML decorators
38
+ html, start // Safe HTML plus one-call page startup
39
+ } = require('redweb');
40
+ ```
41
+
42
+ ## Live HTML
43
+
44
+ `start(PageClass)` combines server-rendered `.html` templates and Redweb WebSockets on one listener. Decorated plain classes hold the behavior; templates remain declarative HTML. Redweb injects a small browser runtime backed by [`redweb-client`](https://www.npmjs.com/package/redweb-client), binds the HTTP render to an expiring page token, and disposes connection-owned state after disconnect.
45
+
46
+ ```ts
47
+ import { page, start, state } from 'redweb';
48
+
49
+ @page('/', { template: 'counter.html', css: 'counter.css' })
50
+ class CounterPage {
51
+ @state()
52
+ count = 0;
53
+
54
+ private ticker?: NodeJS.Timeout;
55
+
56
+ connected() {
57
+ this.ticker = setInterval(() => this.count++, 1000);
58
+ }
59
+
60
+ disconnected() {
61
+ clearInterval(this.ticker);
62
+ }
63
+ }
64
+
65
+ start(CounterPage, { port: 8080 });
66
+ ```
67
+
68
+ `counter.html` contains no executable server code:
69
+
70
+ ```html
71
+ <h1>Server-side counter</h1>
72
+ <output aria-live="polite" data-rw-state="count"></output>
73
+ ```
74
+
75
+ Changing a `@state()` property sends only that binding's new value. State updates are shallow and assignment-driven; Redweb does not install deep proxies or rerender the document for scalar changes.
76
+
77
+ CSS is colocated with the page and needs no static-server setup. Pass one file with `css: 'counter.css'` or compose several with `css: ['base.css', 'counter.css']`. Redweb resolves the files beside the decorated class, injects `<link>` elements during SSR, and serves content-addressed stylesheets with immutable browser caching.
78
+
79
+ Browser events can call only explicitly exposed actions:
80
+
81
+ ```ts
82
+ @component()
83
+ class Chatroom {
84
+ @state()
85
+ screen = html`<form rw-submit="join"><input name="name"><button>Join</button></form>`;
86
+
87
+ @action()
88
+ join({ name }: { name: string }) {
89
+ this.screen = html`<p>Connected as ${name}</p><form rw-submit="send"><input name="message"><button>Send</button></form>`;
90
+ }
91
+ }
92
+ ```
93
+
94
+ ```ts
95
+ @page('/chat', { css: 'chatroom.css' })
96
+ class ChatroomPage {
97
+ chat = new Chatroom();
98
+ render() { return html`<main>${this.chat}</main>`; }
99
+ }
100
+ ```
101
+
102
+ Interpolations created with `html` are escaped by default and are restricted to element text—not attributes, URLs, scripts, or styles. Only `HtmlFragment` values may produce HTML patches; ordinary state uses `textContent`. Use `@state({ writable: true })` to opt a property into `rw-bind="property"` browser updates. A page is connection-scoped by default; `shared: true` deliberately shares one instance across its connected visitors. The older `scope: 'shared'` spelling remains supported.
103
+
104
+ Collections use the same model without manual concatenation. Keep the array in `@state()`, render one item with `@view('cards')`, and place it with `<section rw-each="cards"></section>`. Item views must return `html` fragments, so values remain escaped. The current protocol replaces the collection contents atomically; keyed incremental patches can be added later without changing the page API.
105
+
106
+ Documentation and content-heavy pages can compose nested fragments without a client framework:
107
+
108
+ ```ts
109
+ import { attribute, codeBlock, each, html, url } from 'redweb';
110
+
111
+ const sections = each(apiSections, section => html`
112
+ <article id="${attribute(section.id)}">
113
+ <h2>${section.name}</h2>
114
+ <a href="${url(`#${section.id}`)}">Permalink</a>
115
+ ${each(section.methods, method => html`<section><h3>${method.name}</h3></section>`)}
116
+ ${codeBlock(section.usage, { language: 'ts', label: 'TypeScript' })}
117
+ </article>
118
+ `);
119
+ ```
120
+
121
+ Primitive values may be interpolated directly into quoted attributes and safe URL attributes. Redweb escapes attributes and rejects unsafe or protocol-relative URL schemes; `attribute()` and `url()` remain available when explicit intent helps readability. Event handlers, inline styles, `srcdoc`, and `srcset` remain prohibited. `codeBlock()` escapes ordinary code and can call a server-side `highlight` function that returns an `HtmlFragment`.
122
+
123
+ For React-free documentation or marketing pages, set `live: false`. Redweb omits page tokens, browser JavaScript, and WebSockets; adds document metadata; and serves the result with an ETag:
124
+
125
+ ```ts
126
+ @page('/docs', {
127
+ template: 'docs.html',
128
+ css: 'docs.css',
129
+ live: false,
130
+ head: {
131
+ title: 'Redweb API',
132
+ description: 'Complete Redweb API reference.',
133
+ canonical: 'https://example.com/docs',
134
+ image: 'https://example.com/og.png',
135
+ },
136
+ cache: { maxAge: 300, staleWhileRevalidate: 3600 },
137
+ })
138
+ class DocsPage {}
139
+ ```
140
+
141
+ Export the same decorated page to CDN-ready files with `await exportStatic(DocsPage, { outDir: 'dist' })`. Route paths become `index.html` files, colocated stylesheets are emitted under their content-addressed URLs, and no Live HTML runtime is included. Static export requires `live: false`.
142
+
143
+ For a multi-page site, `defineSite()` removes repeated static-page configuration. It shares CSS, metadata, caching, and a safe layout; generates canonical URLs; and can copy a public asset directory during export:
144
+
145
+ ```ts
146
+ const docs = defineSite({
147
+ origin: 'https://redweb.example',
148
+ css: 'site.css',
149
+ head: { description: 'Redweb documentation' },
150
+ layout: content => html`<body><nav>Redweb</nav><main>${content}</main></body>`,
151
+ });
152
+
153
+ @docs.page('/docs', { head: { title: 'Documentation' } })
154
+ class DocsPage {
155
+ render() { return html`<h1>Documentation</h1>`; }
156
+ }
157
+
158
+ await docs.export(DocsPage, { outDir: 'dist', publicDir: 'public' });
159
+ ```
160
+
161
+ An `html` fragment returned by `render()` is final safe markup, so documentation examples containing literal `{{ bindings }}` are never parsed a second time. Return a string or use a template file when Redweb should resolve template bindings and directives.
162
+
163
+ The same API serves HTTPS/WSS when `ssl` is provided. For private pages, an optional `authenticate(request)` callback binds the page token to the same stable user identity across the HTTP render and WebSocket upgrade. Initial connections and reconnects always receive a complete authoritative state snapshot.
164
+
165
+ See the [Live HTML guide](docs/LIVE_HTML.md), runnable TypeScript [server counter](examples/live-html/counter.ts), component-based [chatroom](examples/live-html/chatroom.ts), and [persistent card collection](examples/live-html/cards.ts). The chatroom separates joining from its stable message composer, tracks online members, preserves bounded history, restores identity and missed messages after reconnect, and creates an isolated room for every server. The cards page uses `shared: true`, so additions survive reloads, reconnects, and new visitors while its server is running. Run the examples with `npm run example:counter`, `npm run example:chatroom`, and `npm run example:cards`. The decorated sources are compiled and exercised unchanged by mock-free HTTP/WebSocket integration tests and a real-Chromium DOM gate.
166
+
167
+ Reusable snippets can own server behavior without page-level forwarding methods. Decorate a class with `@component()`, put instances in page fields, and interpolate them directly: `` html`<main>${this.primary}${this.secondary}</main>` ``. Each instance gets isolated `@state()`, scoped `@action()` methods, nested-component support, and page-owned lifecycle cleanup. See the runnable [component counters](examples/live-html/components.ts) or run `npm run example:components`.
168
+
169
+ ## Multiplayer in 0.9
170
+
171
+ Redweb keeps each production feature independent and opt-in:
172
+
173
+ | Need | Redweb primitive |
174
+ | --- | --- |
175
+ | Authenticate and place players before upgrade | Bounded `admission` hooks with origin and redirect policy |
176
+ | Contain abusive or slow peers | Connection, rate, queue, payload, and outbound-buffer limits |
177
+ | Detect dead connections cheaply | One heartbeat scheduler per route |
178
+ | Group players and resume ownership | Bounded rooms and expiring application-issued sessions |
179
+ | Run simulation work predictably | Drift-aware, non-overlapping `FixedStepService` ticks |
180
+ | Scale across nodes | Optional broker adapter with bounded fan-out and explicit best-effort semantics |
181
+ | Roll deployments safely | Readiness, draining, cooperative cancellation, and bounded shutdown |
182
+ | Evolve clients | Opt-in version negotiation, stable envelopes/error codes, generated types, and codec hooks |
183
+
184
+ The framework does not claim exactly-once delivery or durable state. See the [production-readiness contract](docs/PRODUCTION_READINESS.md), [multiplayer operations guide](docs/MULTIPLAYER_OPERATIONS.md), and [release evidence](docs/VERIFICATION_EVIDENCE.md) before running authoritative sessions.
185
+
186
+ ## HTTP servers (Express)
187
+
188
+ `new HttpServer(options)` creates a Node HTTP server and starts listening immediately by default (default port `80`). `new HttpsServer({ ssl: { key, cert }, ... })` does the same over TLS.
189
+
190
+ Options:
191
+
192
+ - `port` (number): defaults to `80`.
193
+ - `bind` (string): defaults to `0.0.0.0`.
194
+ - `publicPaths` (string[]): folders served as static assets.
195
+ - `services` (array): `{ serviceName, method, function }` for REST endpoints.
196
+ - `listen` (boolean): defaults to `true`; set `false` to build `.app` and `.server` without binding a port.
197
+ - `listenCallback` (function): invoked after `.listen`.
198
+ - `encoding` (`'json' | 'urlencoded'`): body parser selection.
199
+ - `corsOptions`: passed to `cors`.
200
+ - `corsOptions: false`: disables the CORS middleware entirely.
201
+ - `exposeErrors` (boolean): include WebSocket handler details in responses; defaults to `false`.
202
+ - `logger`: an object with optional `log`, `warn`, and `error` methods. Pass `null` to disable library logging.
203
+
204
+ Example:
205
+
206
+ ```js
207
+ const { HttpServer, METHODS } = require('redweb');
208
+
209
+ new HttpServer({
210
+ port: 3000,
211
+ publicPaths: ['./public'],
212
+ services: [
213
+ {
214
+ serviceName: '/api/hello',
215
+ method: METHODS.GET,
216
+ function: (req, res) => res.json({ hello: 'world' })
217
+ }
218
+ ]
219
+ });
220
+ ```
221
+
222
+ CORS remains permissive by default for backward compatibility. CORS is not authorization; configure `corsOptions`, add authentication middleware to `server.app`, or disable the middleware as appropriate.
223
+
224
+ ## WebSocket servers
225
+
226
+ `SocketServer` uses `ws` and routes connections to `SocketRoute` instances. Clients must send JSON containing a `type` that matches a handler name.
227
+
228
+ Handler:
229
+
230
+ ```js
231
+ const { BaseHandler } = require('redweb');
232
+
233
+ class ChatHandler extends BaseHandler {
234
+ constructor() { super('chat'); }
235
+
236
+ onMessage(socket, message) {
237
+ socket.broadcast({ type: 'chat', text: message.text });
238
+ }
239
+ }
240
+ ```
241
+
242
+ Route:
243
+
244
+ ```js
245
+ const { SocketRoute } = require('redweb');
246
+
247
+ class ChatRoute extends SocketRoute {
248
+ constructor() {
249
+ super({
250
+ path: '/chat',
251
+ handlers: [ChatHandler],
252
+ allowDuplicateConnections: true // otherwise one connection per IP
253
+ });
254
+ }
255
+ }
256
+ ```
257
+
258
+ Server:
259
+
260
+ ```js
261
+ const { SocketServer } = require('redweb');
262
+
263
+ new SocketServer({
264
+ port: 3000, // default
265
+ routes: [ChatRoute], // defaults to a route at "/" with DefaultHandler if omitted
266
+ });
267
+ ```
268
+
269
+ Each connected socket gets:
270
+
271
+ - `socket.sendJson(data)` to send JSON.
272
+ - `socket.broadcast(data)` to send JSON to all other clients on the same route.
273
+
274
+ Invalid JSON triggers an error response and closes the socket.
275
+
276
+ ### Binary WebSocket messages
277
+
278
+ Text frames are still parsed as JSON and routed by `message.type`. Binary frames are dispatched separately, so handlers can receive raw `Buffer` payloads without triggering JSON parse errors.
279
+
280
+ ```js
281
+ const { BaseHandler, SocketRoute } = require('redweb');
282
+
283
+ class UploadHandler extends BaseHandler {
284
+ constructor() { super('upload'); }
285
+
286
+ onMessage(socket, message) {
287
+ socket.sendJson({ type: 'upload:control', action: message.action });
288
+ }
289
+
290
+ onBinaryMessage(socket, buffer) {
291
+ socket.sendJson({ type: 'upload:chunk', bytes: buffer.length });
292
+ }
293
+ }
294
+
295
+ class UploadRoute extends SocketRoute {
296
+ constructor() {
297
+ super({
298
+ path: '/upload',
299
+ handlers: [UploadHandler],
300
+ allowDuplicateConnections: true,
301
+ websocketOptions: {
302
+ maxPayload: 2 * 1024 * 1024
303
+ }
304
+ });
305
+ }
306
+ }
307
+ ```
308
+
309
+ `BaseHandler` provides `handleBinaryMessage(socket, buffer)` and `onBinaryMessage(socket, buffer)`. Override `onBinaryMessage` for normal use. If a handler does not override it, RedWeb sends:
310
+
311
+ ```json
312
+ { "error": "Binary messages are not supported by this handler" }
313
+ ```
314
+
315
+ Routes may also select a binary-capable handler with `acceptsBinary(socket, buffer)`:
316
+
317
+ ```js
318
+ class ImageHandler extends BaseHandler {
319
+ constructor() { super('image'); }
320
+
321
+ acceptsBinary(socket, buffer) {
322
+ return buffer.length > 0;
323
+ }
324
+
325
+ onMessage(socket, message) {}
326
+ onBinaryMessage(socket, buffer) {}
327
+ }
328
+ ```
329
+
330
+ ### WebSocket route options
331
+
332
+ `SocketRoute` accepts `websocketOptions`, which are passed to `new WebSocketServer(...)`. Use this for `ws` server settings such as `maxPayload` or `perMessageDeflate`.
333
+ Redweb controls `noServer`, `path`, `server`, and `port`; do not include them in `websocketOptions`. Route selection is performed once by Redweb so strict matching and optional root fallback behave consistently. Handshake authentication can use the `ws` `verifyClient` option, although authenticating in the surrounding HTTP upgrade flow is preferable for complex applications.
334
+
335
+ ```js
336
+ class ClipboardRoute extends SocketRoute {
337
+ constructor() {
338
+ super({
339
+ path: '/clipboard',
340
+ handlers: [ClipboardHandler],
341
+ websocketOptions: {
342
+ maxPayload: 1024 * 1024,
343
+ perMessageDeflate: false
344
+ }
345
+ });
346
+ }
347
+ }
348
+ ```
349
+
350
+ Other route options:
351
+
352
+ - `trustProxy`: use the first `X-Forwarded-For` value as the connection identity. Enable this only behind a trusted proxy.
353
+ - `getClientKey(req)`: provide application-specific connection identity logic instead of IP-based identity.
354
+ - `exposeErrors`: return handler exception messages to clients; defaults to `false`.
355
+ - `logger`: route logger with optional `log`, `warn`, and `error` methods; pass `null` to disable it.
356
+ - `shutdownTimeoutMs`: grace period before non-cooperating peers are terminated during shutdown; defaults to `1000`.
357
+ - `admission`: optional pre-upgrade authentication/origin/placement policy. It may be a function or `{ authenticate, origins, place, allowedPlacementOrigins, allowInsecurePlacement, timeoutMs }`. Secure `wss` placement is the default; returned destinations can be origin-allowlisted.
358
+ - `maxPendingUpgrades`: maximum concurrent pre-upgrade authorization/negotiation operations; defaults to `64`.
359
+ - `limits`: opt-in connection, message-rate, pending-message, and outbound-buffer limits.
360
+ - `orderedMessages`: process each connection's messages serially through a bounded queue; defaults to `false` for compatibility.
361
+ - `heartbeat`: optional `{ intervalMs, timeoutMs }` half-open detection using one scheduler per route.
362
+ - `rooms` and `sessions`: optional bounded route-local grouping and resumable session registries. Session payload shape and byte size remain the application's responsibility.
363
+ - `distribution`: optional bounded fan-out adapter. Mark it `required` to fail readiness and reject new upgrades after startup or publish failure; adapter operations receive cancellation signals.
364
+ - `drainHandlers`: expose a route shutdown signal to handlers and track their work within `shutdownTimeoutMs`.
365
+ - `protocol`: optional version negotiation, stable envelopes, and binary codec hooks.
366
+
367
+ Production protections are deliberately opt-in, so existing applications retain their behavior and disabled features add no timers or per-connection queues. A protected route can stay compact:
368
+
369
+ ```js
370
+ class GameRoute extends SocketRoute {
371
+ constructor() {
372
+ super({
373
+ path: '/game',
374
+ handlers: [InputHandler],
375
+ admission: {
376
+ origins: ['https://game.example'],
377
+ timeoutMs: 3000,
378
+ authenticate: (request, { signal }) => verifySession(request, signal)
379
+ },
380
+ limits: {
381
+ maxConnections: 5000,
382
+ maxBufferedBytes: 1024 * 1024,
383
+ maxPendingMessages: 64,
384
+ messageRate: { capacity: 60, refillPerSecond: 30 }
385
+ },
386
+ orderedMessages: true,
387
+ heartbeat: { intervalMs: 30000, timeoutMs: 10000 },
388
+ websocketOptions: { maxPayload: 64 * 1024 }
389
+ });
390
+ }
391
+ }
392
+ ```
393
+
394
+ Admission completes before the WebSocket upgrade and before any handler hook runs. Its return value becomes `socket.context.principal`; the random `connectionId`, authenticated principal, future resumable session, and legacy IP-based `clientKey` remain separate concepts. Authentication errors are never returned to clients.
395
+
396
+ Rate and backpressure actions are `"drop"` or `"disconnect"`. Slow-consumer checks apply equally to `sendJson` and `broadcast`, and broadcasts still serialize a message once. Ordered processing never keeps more than `maxPendingMessages` waiting behind the active task.
397
+
398
+ ### Rooms, resumable sessions, and metrics
399
+
400
+ Set `rooms: true` to add bounded route-local rooms, or pass limits such as `{ maxRooms, maxMembersPerRoom, maxRoomsPerConnection, maxRoomIdLength }`. Connected sockets receive `joinRoom`, `leaveRoom`, and `roomBroadcast`. Joins and leaves are idempotent, disconnect removes every membership, and empty rooms are reclaimed.
401
+
402
+ Set `sessions: true` or provide `{ ttlMs, maxSessions, maxSessionIdLength, sweepIntervalMs }`. Applications supply opaque session IDs; Redweb does not create credentials. Sockets receive `createSession` and `resumeSession`. A successful takeover closes the former owner, and a stale close cannot release the replacement. Disconnected sessions expire through one route scheduler.
403
+
404
+ The optional `metrics` sink is vendor-neutral and supports `increment`, `gauge`, and `observe`. Framework attributes contain only the static route path—never player IDs, room IDs, tokens, payloads, or exception text.
405
+
406
+ ```js
407
+ class MatchRoute extends SocketRoute {
408
+ constructor() {
409
+ super({
410
+ path: '/match',
411
+ handlers: [MatchHandler],
412
+ rooms: { maxRooms: 1000, maxMembersPerRoom: 32 },
413
+ sessions: { ttlMs: 30000, maxSessions: 10000 },
414
+ metrics: myMetricsSink
415
+ });
416
+ }
417
+ }
418
+ ```
419
+
420
+ ### Horizontal composition and draining
421
+
422
+ Distribution is an opt-in adapter seam, not a bundled broker. Provide `distribution: { adapter, channel, nodeId, onEvent }`; the adapter only needs `publish(channel, serializedEvent)` and `subscribe(channel, listener)`. Optional `start`, `unsubscribe`, and `close` hooks have bounded lifecycles. Redweb validates event size, ignores events published by the same node, and retains a bounded, expiring deduplication window. Delivery remains at-most-effort: partitions can lose events and reconnects can duplicate them, so authoritative games should include their own tick or sequence in payloads.
423
+
424
+ Sockets on distributed routes receive `publishEvent(type, payload)`. The application decides how a received event affects rooms or state:
425
+
426
+ ```js
427
+ super({
428
+ path: '/match',
429
+ handlers: [MatchHandler],
430
+ rooms: true,
431
+ distribution: {
432
+ adapter: brokerAdapter,
433
+ channel: 'matches',
434
+ nodeId: process.env.INSTANCE_ID,
435
+ onEvent(event, route) {
436
+ route.rooms.broadcast('match-42', event.payload)
437
+ }
438
+ }
439
+ })
440
+ ```
441
+
442
+ `server.beginDrain()` flips readiness before rejecting new upgrades with `503`; `server.isReady()` exposes the state. Set `drainHandlers: true` to give connection contexts an `AbortSignal` and make shutdown wait for active handlers. Handlers must cooperate with that signal—JavaScript cannot forcibly cancel arbitrary application promises. This option is off by default, adding no per-message tracking to existing routes.
443
+
444
+ ### Versioned game protocol
445
+
446
+ Set `protocol: { versions: ['1'] }` to require version negotiation before upgrade. Browser clients use `?redwebVersion=1`; non-browser clients may send `x-redweb-version: 1`. Missing or unsupported versions receive `426 Upgrade Required` with a `Redweb-Versions` response header. The selected value is available as `socket.context.protocol.version`.
447
+
448
+ Protocol messages use `{ v, type, payload, requestId?, sequence? }`. Protocol routes add `socket.sendEvent(...)` and `socket.sendProtocolError(...)`; framework failures use stable codes exported as `ERROR_CODES`. This affects only opted-in routes. Existing routes retain their existing message and error shapes.
449
+
450
+ ```js
451
+ super({
452
+ path: '/match',
453
+ handlers: [MoveHandler],
454
+ protocol: {
455
+ versions: ['2', '1'],
456
+ binary: {
457
+ maxBytes: 64 * 1024,
458
+ encode: state => myCodec.encode(state),
459
+ decode: bytes => myCodec.decode(bytes)
460
+ }
461
+ }
462
+ })
463
+ ```
464
+
465
+ The optional binary hooks add no codec dependency. Decoded values pass through the same version/envelope validation and handler dispatch as JSON; `socket.sendBinaryEvent(value)` applies the same slow-consumer policy as other outbound traffic. Without binary hooks, binary frames on a protocol route receive `BINARY_UNSUPPORTED`.
466
+
467
+ For clients, `require('redweb/client')` exports the dependency-free `ProtocolClient` and the same error codes. Its TypeScript declarations are generated from Redweb's checked-in protocol schema and checked for drift before every test run.
468
+
469
+ `BaseHandler.validateMessage(message, socket)` may return `false` or a promise resolving to `false` to reject a message. Text and binary handlers may be asynchronous; rejected promises are caught and converted to safe error responses.
470
+
471
+ ### Sharing an HTTP/HTTPS server
472
+
473
+ Use `listen: false` on `HttpServer` to build the Express app and Node server without binding a port. Then pass `httpServer.server` to `SocketServer`. When `SocketServer` receives a prebuilt `server`, it attaches upgrade handling but does not call `.listen()` unless you explicitly set `listen: true`.
474
+
475
+ ```js
476
+ const { HttpServer, METHODS, SocketServer } = require('redweb');
477
+
478
+ const httpServer = new HttpServer({
479
+ port: 3030,
480
+ listen: false,
481
+ publicPaths: ['./public'],
482
+ services: [
483
+ { serviceName: '/health', method: METHODS.GET, function: (req, res) => res.json({ ok: true }) },
484
+ { serviceName: '/session', method: METHODS.POST, function: createSession }
485
+ ]
486
+ });
487
+
488
+ new SocketServer({
489
+ server: httpServer.server,
490
+ routes: [ClipboardRoute]
491
+ });
492
+
493
+ httpServer.server.listen(3030, () => console.log('HTTP and WebSocket server listening on 3030'));
494
+ ```
495
+
496
+ ### Socket services
497
+
498
+ Route-scoped background logic:
499
+
500
+ ```js
501
+ const { SocketService } = require('redweb');
502
+
503
+ class ClockService extends SocketService {
504
+ constructor() { super('clock', 1000); } // tick every 1s
505
+ onTick() {
506
+ this.route.clients.forEach((socket) => socket.sendJson({ type: 'time', now: Date.now() }));
507
+ }
508
+ }
509
+ ```
510
+
511
+ Add with `services: [ClockService]` when constructing a `SocketRoute`.
512
+
513
+ For authoritative simulation timing, extend `FixedStepService`. It compensates for timer drift, caps catch-up work, contains tick failures, and never overlaps an asynchronous tick with itself:
514
+
515
+ ```js
516
+ class Simulation extends FixedStepService {
517
+ constructor() { super('simulation', 50, 3); }
518
+ async onTick(stepMs, tick) {
519
+ await game.update(stepMs, tick);
520
+ }
521
+ }
522
+ ```
523
+
524
+ ### Socket registries
525
+
526
+ `SocketRegistry` is a small evented list for socket-bound objects.
527
+
528
+ ```js
529
+ const { SocketRegistry } = require('redweb');
530
+
531
+ class PlayerRegistry extends SocketRegistry {
532
+ addPlayer(player) {
533
+ this.add(player);
534
+ this.emit('playerJoined', player);
535
+ }
536
+ }
537
+ ```
538
+
539
+ Helpers: `add`, `remove(itemOrId, byKey = 'id')`, `all()`, `count()`.
540
+
541
+ ## Defaults and lifecycle
542
+
543
+ - HTTP defaults: port `80`, bind `0.0.0.0`, `listen: true`.
544
+ - WebSocket defaults: port `3000`, single connection per IP unless `allowDuplicateConnections` is set.
545
+ - `SocketServer` owns and listens on its own server by default; if you pass `server`, you own calling `.listen()` unless you also pass `listen: true`.
546
+ - Upgrade paths are matched strictly by default. Set `fallbackToRoot: true` for legacy behavior that sends unmatched paths to `/`.
547
+ - If you do not supply `routes`, `SocketServer` registers a default route at `/` with `DefaultHandler` (it expects messages with `type: 'DefaultHandler'`).
548
+ - `shutdown()` closes routes and services. It closes an owned listener, but leaves a supplied listener running unless `closeServerOnShutdown: true` is set.
549
+ - Shutdown is best-effort: all hooks, clients, routes, and owned listeners are processed before collected cleanup errors are reported.
550
+ - `HttpServer` and `HttpsServer` expose an idempotent async `shutdown()` helper.
551
+
552
+ ## 0.8 migration notes
553
+
554
+ - Unmatched WebSocket paths are rejected unless `fallbackToRoot: true` is configured.
555
+ - Handler exception details are hidden unless `exposeErrors: true` is configured.
556
+ - Shutting down a WebSocket server no longer closes a caller-supplied HTTP/HTTPS server by default.
557
+ - `bind` is now honored by HTTP, HTTPS, WebSocket, and secure WebSocket listeners.
558
+ - `shutdown()` is asynchronous; await it when deterministic cleanup matters.
559
+
560
+ ## 0.9 migration notes
561
+
562
+ - No migration is required when the new multiplayer options are disabled.
563
+ - Production controls are route-local and opt-in; enable and size them from measured capacity rather than copying example limits.
564
+ - `ProtocolClient` is available from `redweb/client` for negotiated protocol routes without adding runtime dependencies.
565
+ - The minimum supported Node.js version is 18.
566
+
567
+ ## Live HTML migration
568
+
569
+ The earlier executable `.htmx` sandbox and `enableHtmxRendering` option have been replaced. Templates are now ordinary `.html` files registered through decorated plain classes. Move template calculations and imports into the page class, mark reactive fields with `@state()`, expose browser-callable methods with `@action()`, and launch the page with `start(PageClass)`.
570
+
571
+ ## Developing
572
+
573
+ - Run tests with `npm test` (Jest). The suite includes mock-free HTTP, HTTPS, WebSocket, and secure WebSocket integration tests plus unit tests, with 100% coverage enforced for statements, branches, functions, and lines.