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.
- package/CHANGELOG.md +28 -0
- package/README.md +573 -307
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/LIVE_HTML.md +313 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/examples/live-html/cards.css +36 -0
- package/examples/live-html/cards.html +11 -0
- package/examples/live-html/cards.js +91 -0
- package/examples/live-html/cards.ts +35 -0
- package/examples/live-html/chatroom.css +156 -0
- package/examples/live-html/chatroom.js +268 -0
- package/examples/live-html/chatroom.ts +217 -0
- package/examples/live-html/components.css +7 -0
- package/examples/live-html/components.js +113 -0
- package/examples/live-html/components.ts +41 -0
- package/examples/live-html/counter.css +24 -0
- package/examples/live-html/counter.html +10 -0
- package/examples/live-html/counter.js +73 -0
- package/examples/live-html/counter.ts +21 -0
- package/examples/live-html/tsconfig.json +16 -0
- package/index.d.ts +538 -114
- package/index.js +44 -12
- package/package.json +39 -15
- package/src/htmx/Html.js +133 -0
- package/src/htmx/HtmlRenderer.js +88 -0
- package/src/htmx/HtmlSyntax.js +168 -0
- package/src/htmx/LiveHtmlServer.js +91 -0
- package/src/htmx/LivePage.js +232 -0
- package/src/htmx/PageAssetLoader.js +34 -0
- package/src/htmx/PageManager.js +435 -0
- package/src/htmx/StaticExporter.js +78 -0
- package/src/htmx/StaticSite.js +182 -0
- package/src/htmx/TemplateRenderer.js +231 -0
- package/src/htmx/browserRuntime.js +97 -0
- package/src/htmx/index.js +10 -0
- package/src/htmx/metadata.js +349 -0
- package/src/htmx/sourceRoot.js +28 -0
- package/src/htmx/start.js +17 -0
- package/src/htmx/synchronous.js +9 -0
- package/src/http/BaseHttpServer.js +82 -117
- package/src/http/HttpServer.js +18 -18
- package/src/http/HttpsServer.js +20 -20
- package/src/serverLifecycle.js +46 -46
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -40
- package/src/ws/BaseSocketServer.js +199 -100
- package/src/ws/DefaultHandler.js +5 -5
- package/src/ws/DefaultRoute.js +8 -8
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -9
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +523 -254
- package/src/ws/SocketServer.js +8 -8
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -33
- package/src/ws/util.js +38 -30
- package/src/htmx/HtmxRenderer.js +0 -73
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
`
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
`
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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.
|