redweb 0.13.5 → 0.15.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 +20 -3
- package/README.md +289 -288
- package/contract.d.ts +11 -3
- package/docs/APPLICATION.md +98 -0
- package/docs/CLI.md +1 -1
- package/docs/CLIENT_DEVELOPMENT.md +9 -5
- package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
- package/docs/DEFINE_APP_VERIFICATION.md +101 -0
- 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/MULTIPLAYER_OPERATIONS.md +6 -0
- package/docs/RELEASE_TRUST.md +29 -6
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +6 -3
- package/docs/SOCKET_PAGES.md +99 -0
- package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
- package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
- package/docs/generated.json +2217 -2154
- package/docs/guides/http-websocket.md +4 -4
- package/docs/guides/jsx-without-react.md +1 -1
- package/docs/reference.json +40 -1
- package/docs/releases/0.14.0.json +2208 -0
- package/docs/releases/0.15.0.json +2217 -0
- package/docs/topics.json +3 -1
- package/examples/live-html/cards.js +87 -86
- package/examples/live-html/cards.ts +3 -2
- package/examples/live-html/chatroom.js +210 -207
- package/examples/live-html/chatroom.tsx +6 -2
- package/examples/live-html/components.js +103 -102
- package/examples/live-html/components.ts +3 -2
- package/examples/live-html/counter.js +74 -73
- package/examples/live-html/counter.ts +3 -2
- package/examples/live-html/jsx-page.js +2 -1
- package/examples/live-html/jsx-page.tsx +3 -2
- package/index.d.ts +59 -7
- package/index.js +3 -0
- package/package.json +8 -5
- package/recipes/chat/app.test.cjs +3 -1
- package/recipes/chat/app.tsx +4 -7
- package/recipes/dashboard/app.test.cjs +10 -10
- package/recipes/dashboard/app.tsx +29 -29
- package/recipes/http-ws/README.md +1 -1
- package/recipes/http-ws/app.test.cjs +8 -10
- package/recipes/http-ws/app.tsx +9 -20
- package/recipes/realtime/app.tsx +3 -6
- package/recipes/shared/README.md +10 -1
- package/recipes/shared/lifecycle.test.cjs +81 -0
- package/recipes/shared/network.cjs +10 -5
- package/recipes/site/app.tsx +3 -6
- package/recipes/socket/app.tsx +3 -10
- package/src/Application.js +239 -0
- package/src/StartupCleanup.js +24 -0
- package/src/cli/SourceInspector.js +5 -0
- package/src/cli/templates.js +6 -6
- package/src/htmx/Jsx.js +2 -2
- package/src/htmx/LiveHtmlServer.js +14 -5
- package/src/htmx/PageManager.js +13 -8
- package/src/htmx/PageSocketRoute.js +132 -0
- package/src/htmx/ReactiveRenderer.js +8 -2
- package/src/htmx/SocketAction.js +19 -0
- package/src/htmx/metadata.js +6 -2
- package/src/ws/BaseHandler.js +6 -4
- package/src/ws/BaseSocketServer.js +13 -13
- package/src/ws/HandlerGuard.js +4 -0
- package/src/ws/SocketAction.js +16 -0
- package/src/ws/SocketContract.js +3 -2
- package/src/ws/SocketRoute.js +9 -4
- package/recipes/shared/run-app.test.cjs +0 -158
- package/recipes/shared/run-app.ts +0 -50
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LiveHtmlServer = require('./htmx/LiveHtmlServer');
|
|
4
|
+
const HttpServer = require('./http/HttpServer');
|
|
5
|
+
const HttpsServer = require('./http/HttpsServer');
|
|
6
|
+
const SocketServer = require('./ws/SocketServer');
|
|
7
|
+
const SocketService = require('./ws/SocketService');
|
|
8
|
+
const OwnedServerLifecycle = require('./OwnedServerLifecycle');
|
|
9
|
+
const { validateListenerOptions } = require('./serverLifecycle');
|
|
10
|
+
const { awaitStartupCleanup } = require('./StartupCleanup');
|
|
11
|
+
const { performance } = require('node:perf_hooks');
|
|
12
|
+
|
|
13
|
+
function classes(value, name) {
|
|
14
|
+
if (!Array.isArray(value) || value.some(Type => typeof Type !== 'function')) {
|
|
15
|
+
throw new TypeError(`\`${name}\` must be an array of classes.`);
|
|
16
|
+
}
|
|
17
|
+
return [...value];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bounded(operation, milliseconds, label, signal) {
|
|
21
|
+
let timer, abort;
|
|
22
|
+
return Promise.race([
|
|
23
|
+
Promise.resolve().then(() => {
|
|
24
|
+
if (signal?.aborted) throw new Error('Application startup was cancelled.');
|
|
25
|
+
return operation();
|
|
26
|
+
}),
|
|
27
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} exceeded its deadline.`)), milliseconds); }),
|
|
28
|
+
new Promise((_, reject) => {
|
|
29
|
+
abort = () => reject(new Error('Application startup was cancelled.'));
|
|
30
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
31
|
+
if (signal?.aborted) abort();
|
|
32
|
+
}),
|
|
33
|
+
]).finally(() => { clearTimeout(timer); signal?.removeEventListener('abort', abort); });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One deferred application definition, one listener and one cleanup owner. */
|
|
37
|
+
class Application {
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Application options must be an object.');
|
|
40
|
+
const { pages = [], sockets = [], services = [], port = 8181, bind = '0.0.0.0',
|
|
41
|
+
startupTimeoutMs = 5000, shutdownTimeoutMs = 5000, signals = true, ...rest } = options;
|
|
42
|
+
if ('static' in rest) throw new TypeError('Use exportStatic() for static output; defineApp() owns a live HTTP listener.');
|
|
43
|
+
for (const name of ['listen', 'routes', 'socketRoutes', 'closeServerOnShutdown']) {
|
|
44
|
+
if (name in rest) throw new TypeError(`defineApp owns \`${name}\`; use pages/sockets and run().`);
|
|
45
|
+
}
|
|
46
|
+
validateListenerOptions({ port, bind, listen: false, listenCallback: rest.listenCallback });
|
|
47
|
+
for (const value of [startupTimeoutMs, shutdownTimeoutMs]) {
|
|
48
|
+
if (!Number.isInteger(value) || value < 1 || value > 2147483647) throw new RangeError('Application deadlines must be positive timer-safe integers.');
|
|
49
|
+
}
|
|
50
|
+
if (typeof signals !== 'boolean') throw new TypeError('`signals` must be a boolean.');
|
|
51
|
+
this.options = { ...rest, pages: classes(pages, 'pages'), sockets: classes(sockets, 'sockets'),
|
|
52
|
+
services: classes(services, 'services'), port, bind, startupTimeoutMs, shutdownTimeoutMs, signals };
|
|
53
|
+
if (this.options.services.some(Type => Type === SocketService || Type.prototype instanceof SocketService)) {
|
|
54
|
+
throw new TypeError('SocketService belongs to a socket route, not application services.');
|
|
55
|
+
}
|
|
56
|
+
this.services = [];
|
|
57
|
+
this.server = null;
|
|
58
|
+
this.app = null;
|
|
59
|
+
this.http = null;
|
|
60
|
+
this.sockets = null;
|
|
61
|
+
this._live = null;
|
|
62
|
+
this._owner = null;
|
|
63
|
+
this._runPromise = null;
|
|
64
|
+
this._shutdownPromise = null;
|
|
65
|
+
this._cleanupPromise = null;
|
|
66
|
+
this._stopping = false;
|
|
67
|
+
this._abort = new AbortController();
|
|
68
|
+
this._onSignal = () => this._stopProcess();
|
|
69
|
+
this._onClose = () => {
|
|
70
|
+
if (this._stopping) return;
|
|
71
|
+
if (this.options.signals) this._stopProcess();
|
|
72
|
+
else void this.shutdown().catch(() => {});
|
|
73
|
+
};
|
|
74
|
+
this._onError = () => {
|
|
75
|
+
if (this.options.signals) process.exitCode = 1;
|
|
76
|
+
this._onClose();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
run() {
|
|
81
|
+
if (this._stopping) return Promise.reject(new Error('An application cannot run after shutdown. Define a new application.'));
|
|
82
|
+
if (this._runPromise) return this._runPromise;
|
|
83
|
+
this._startupDeadline = performance.now() + this.options.startupTimeoutMs;
|
|
84
|
+
this._runPromise = this._start().catch(async error => {
|
|
85
|
+
this._stopping = true;
|
|
86
|
+
this._abort.abort();
|
|
87
|
+
try {
|
|
88
|
+
const results = await Promise.allSettled([
|
|
89
|
+
this._withinShutdown(() => awaitStartupCleanup(error), 'Construction rollback'), this._cleanup(),
|
|
90
|
+
]);
|
|
91
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
92
|
+
if (errors.length) throw new AggregateError(errors, 'Application rollback failed.');
|
|
93
|
+
}
|
|
94
|
+
catch (cleanup) { throw new AggregateError([error, cleanup], 'Application startup and cleanup failed.', { cause: error }); }
|
|
95
|
+
throw error;
|
|
96
|
+
});
|
|
97
|
+
return this._runPromise;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async _start() {
|
|
101
|
+
const { pages, sockets, services, startupTimeoutMs, shutdownTimeoutMs, signals, httpServices = [], ...options } = this.options;
|
|
102
|
+
if (signals) {
|
|
103
|
+
process.on('SIGINT', this._onSignal);
|
|
104
|
+
process.on('SIGTERM', this._onSignal);
|
|
105
|
+
}
|
|
106
|
+
const httpOptions = { ...options, services: httpServices, listen: false, shutdownTimeoutMs };
|
|
107
|
+
if (pages.length) {
|
|
108
|
+
this._live = new LiveHtmlServer({ ...httpOptions, pages, socketRoutes: sockets });
|
|
109
|
+
this.http = this._live.http;
|
|
110
|
+
this.sockets = this._live.sockets;
|
|
111
|
+
this._owner = this._live._ownedServer;
|
|
112
|
+
} else {
|
|
113
|
+
const Server = options.ssl ? HttpsServer : HttpServer;
|
|
114
|
+
this.http = new Server(httpOptions);
|
|
115
|
+
this._owner = new OwnedServerLifecycle(this.http.server);
|
|
116
|
+
}
|
|
117
|
+
this.server = this.http.server;
|
|
118
|
+
this.app = this.http.app;
|
|
119
|
+
if (!pages.length && sockets.length) {
|
|
120
|
+
this.sockets = new SocketServer({ server: this.server, routes: sockets, listen: false,
|
|
121
|
+
closeServerOnShutdown: false, logger: options.logger });
|
|
122
|
+
}
|
|
123
|
+
for (const Service of services) {
|
|
124
|
+
this._checkStarting();
|
|
125
|
+
const service = new Service();
|
|
126
|
+
this.services.push(service);
|
|
127
|
+
if (typeof service.onInit !== 'function' || typeof service.onShutdown !== 'function') {
|
|
128
|
+
throw new TypeError('Application services require onInit(app, signal) and onShutdown().');
|
|
129
|
+
}
|
|
130
|
+
await this._withinStartup(() => service.onInit(this, this._abort.signal), 'Application service initialization');
|
|
131
|
+
}
|
|
132
|
+
this._checkStarting();
|
|
133
|
+
await this._listen();
|
|
134
|
+
this._checkStarting();
|
|
135
|
+
this.server.on('error', this._onError);
|
|
136
|
+
this.server.once('close', this._onClose);
|
|
137
|
+
if (options.listenCallback) await this._withinStartup(() => options.listenCallback(), 'Application listening callback');
|
|
138
|
+
else this.http.logger?.log?.(`Redweb application listening on ${options.bind}:${this.server.address().port}`);
|
|
139
|
+
this._checkStarting();
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_checkStarting() {
|
|
144
|
+
if (this._stopping) throw new Error('Application startup was cancelled.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_listen() {
|
|
148
|
+
const { server } = this;
|
|
149
|
+
return this._withinStartup(() => new Promise((resolve, reject) => {
|
|
150
|
+
// A cancelled or timed-out native lookup can still complete on
|
|
151
|
+
// Node 18. Only native settlement detaches these one-shot guards.
|
|
152
|
+
const detach = () => { server.off('listening', ready); server.off('error', failed); };
|
|
153
|
+
const failed = error => { detach(); reject(error); };
|
|
154
|
+
const ready = () => {
|
|
155
|
+
try {
|
|
156
|
+
if (this._abort.signal.aborted) server.close();
|
|
157
|
+
detach();
|
|
158
|
+
resolve();
|
|
159
|
+
} catch (error) { failed(error); }
|
|
160
|
+
};
|
|
161
|
+
server.once('listening', ready);
|
|
162
|
+
server.once('error', failed);
|
|
163
|
+
try { server.listen({ port: this.options.port, host: this.options.bind, signal: this._abort.signal }); }
|
|
164
|
+
catch (error) { failed(error); }
|
|
165
|
+
}), 'Application listener startup');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
shutdown() {
|
|
169
|
+
if (!this._shutdownPromise) {
|
|
170
|
+
this._stopping = true;
|
|
171
|
+
this._deadline ??= performance.now() + this.options.shutdownTimeoutMs;
|
|
172
|
+
this._abort.abort();
|
|
173
|
+
this._shutdownPromise = Promise.resolve(this._runPromise).catch(() => {}).then(() => this._cleanup());
|
|
174
|
+
}
|
|
175
|
+
return this._shutdownPromise;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
revoke(principal) { return this._live ? this._live.revoke(principal) : Promise.resolve(0); }
|
|
179
|
+
|
|
180
|
+
inspect() { return this._live ? this._live.inspect() : null; }
|
|
181
|
+
|
|
182
|
+
_stopProcess() {
|
|
183
|
+
if (this._processStopping) return;
|
|
184
|
+
this._processStopping = true;
|
|
185
|
+
const timer = setTimeout(() => { process.exitCode = 1; process.exit(); }, this.options.shutdownTimeoutMs);
|
|
186
|
+
void this.shutdown().then(() => clearTimeout(timer), () => { process.exitCode = 1; timer.unref(); });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_withinShutdown(operation, label) {
|
|
190
|
+
this._deadline ??= performance.now() + this.options.shutdownTimeoutMs;
|
|
191
|
+
return bounded(operation, Math.max(0, this._deadline - performance.now()), label);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_withinStartup(operation, label) {
|
|
195
|
+
const remaining = this._startupDeadline - performance.now();
|
|
196
|
+
if (remaining <= 0) return Promise.reject(new Error(`${label} exceeded its deadline.`));
|
|
197
|
+
return bounded(operation, remaining, label, this._abort.signal);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_cleanup() {
|
|
201
|
+
if (!this._cleanupPromise) this._cleanupPromise = this._dispose();
|
|
202
|
+
return this._cleanupPromise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async _dispose() {
|
|
206
|
+
const errors = [];
|
|
207
|
+
const close = async (operation, label) => {
|
|
208
|
+
try { await this._withinShutdown(operation, label); } catch (error) { errors.push(error); }
|
|
209
|
+
};
|
|
210
|
+
// Drain handlers and close listeners before releasing their dependencies.
|
|
211
|
+
if (this.server?.listening) {
|
|
212
|
+
try { this.server.close(); } catch (error) { errors.push(error); }
|
|
213
|
+
}
|
|
214
|
+
if (this.sockets) await close(() => this.sockets.shutdown(), 'Socket shutdown');
|
|
215
|
+
if (this._live) await close(() => this._live.manager.shutdown(), 'Page shutdown');
|
|
216
|
+
if (this._owner) {
|
|
217
|
+
// OwnedServerLifecycle applies this same remaining deadline and
|
|
218
|
+
// force-closes peers when it expires. A second racing timer could
|
|
219
|
+
// report failure just before that successful force-close.
|
|
220
|
+
try { await this._owner.close(Math.max(0, this._deadline - performance.now()), () => this.http.shutdown()); }
|
|
221
|
+
catch (error) { errors.push(error); }
|
|
222
|
+
errors.push(...this._owner.forceClose());
|
|
223
|
+
}
|
|
224
|
+
for (const service of [...this.services].reverse()) {
|
|
225
|
+
await close(() => service.onShutdown(), 'Application service shutdown');
|
|
226
|
+
}
|
|
227
|
+
this.server?.off('error', this._onError);
|
|
228
|
+
this.server?.off('close', this._onClose);
|
|
229
|
+
if (!errors.length || !this._processStopping) {
|
|
230
|
+
process.off('SIGINT', this._onSignal);
|
|
231
|
+
process.off('SIGTERM', this._onSignal);
|
|
232
|
+
}
|
|
233
|
+
if (errors.length) throw new AggregateError(errors, 'Application shutdown failed.');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function defineApp(options) { return new Application(options); }
|
|
238
|
+
|
|
239
|
+
module.exports = { Application, defineApp };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const pending = new WeakMap();
|
|
4
|
+
const { isNativeError } = require('node:util').types;
|
|
5
|
+
|
|
6
|
+
/** Preserve synchronous constructor errors while letting async owners await rollback. */
|
|
7
|
+
function scheduleStartupCleanup(error, cleanup) {
|
|
8
|
+
const failure = error instanceof Error || isNativeError(error) ? error : new Error('Application construction failed.', { cause: error });
|
|
9
|
+
const previous = pending.get(failure);
|
|
10
|
+
// Each partially constructed owner must start releasing its resources even
|
|
11
|
+
// when another owner's cleanup stalls. Retain every failure for the caller.
|
|
12
|
+
const rollback = Promise.allSettled([previous, Promise.resolve().then(cleanup)]).then(results => {
|
|
13
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
14
|
+
if (errors.length) throw new AggregateError(errors, 'Construction rollback failed.');
|
|
15
|
+
});
|
|
16
|
+
pending.set(failure, rollback);
|
|
17
|
+
// Synchronous callers cannot await a constructor; async owners inspect the original task.
|
|
18
|
+
rollback.catch(() => {});
|
|
19
|
+
return failure;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function awaitStartupCleanup(error) { return pending.get(error); }
|
|
23
|
+
|
|
24
|
+
module.exports = { scheduleStartupCleanup, awaitStartupCleanup };
|
|
@@ -108,6 +108,11 @@ class SourceInspector {
|
|
|
108
108
|
this.group(args[0], 'page', node, args[1]);
|
|
109
109
|
} else if (api === 'LiveHtmlServer') {
|
|
110
110
|
this.group(read.property(args[0], 'pages'), 'page', node, args[0]);
|
|
111
|
+
} else if (['defineApp', 'Application'].includes(api)) {
|
|
112
|
+
for (const [field, kind] of [['pages', 'page'], ['sockets', 'route']]) {
|
|
113
|
+
const registrations = read.property(args[0], field);
|
|
114
|
+
if (registrations !== undefined) this.group(registrations, kind, node, args[0]);
|
|
115
|
+
}
|
|
111
116
|
} else if (['SocketServer', 'SecureSocketServer'].includes(api)) {
|
|
112
117
|
const routes = read.property(args[0], 'routes');
|
|
113
118
|
if (routes !== undefined) this.group(routes, 'route', node);
|
package/src/cli/templates.js
CHANGED
|
@@ -8,7 +8,7 @@ const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboar
|
|
|
8
8
|
|
|
9
9
|
function projectFiles(version, template = 'realtime', root = path.resolve(__dirname, '../..')) {
|
|
10
10
|
if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
|
|
11
|
-
const { devDependencies, dependencies } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
11
|
+
const { devDependencies, dependencies, overrides } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
12
12
|
const read = relative => fs.readFileSync(path.join(root, 'recipes', relative), 'utf8');
|
|
13
13
|
const manifest = {
|
|
14
14
|
name: 'redweb-app', private: true, version: '0.0.0',
|
|
@@ -16,15 +16,16 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
|
|
|
16
16
|
build: 'tsc && node scripts/copy-assets.cjs',
|
|
17
17
|
start: 'node dist/app.js',
|
|
18
18
|
dev: 'nodemon',
|
|
19
|
-
test: 'npm run build && node --test test/app.test.cjs test/
|
|
20
|
-
'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/
|
|
19
|
+
test: 'npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs',
|
|
20
|
+
'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs',
|
|
21
21
|
},
|
|
22
22
|
dependencies: {
|
|
23
23
|
redweb: `^${version}`,
|
|
24
24
|
...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
|
|
25
25
|
...(template === 'dashboard' ? { express: dependencies.express } : {}),
|
|
26
26
|
},
|
|
27
|
-
|
|
27
|
+
overrides,
|
|
28
|
+
devDependencies: {
|
|
28
29
|
typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws, c8: devDependencies.c8,
|
|
29
30
|
...(template === 'dashboard' ? {
|
|
30
31
|
'@types/node': devDependencies['redweb-dashboard-types'].replace('npm:@types/node@', ''),
|
|
@@ -52,12 +53,11 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
|
|
|
52
53
|
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
53
54
|
}) },
|
|
54
55
|
{ path: 'src/app.tsx', content: read(`${template}/app.tsx`) },
|
|
55
|
-
{ path: 'src/run-app.ts', content: read('shared/run-app.ts') },
|
|
56
56
|
{ path: 'src/app.css', content: read(`${template === 'dashboard' ? template : 'shared'}/app.css`) },
|
|
57
57
|
{ path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
|
|
58
58
|
{ path: 'test/network.cjs', content: read('shared/network.cjs') },
|
|
59
59
|
{ path: 'test/app.test.cjs', content: read(`${template}/app.test.cjs`) },
|
|
60
|
-
{ path: 'test/
|
|
60
|
+
{ path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
|
|
61
61
|
{ path: 'README.md', content: `${read('shared/README.md')}\n${read(`${template}/README.md`)}` },
|
|
62
62
|
{ path: '.gitignore', content: 'node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n' },
|
|
63
63
|
];
|
package/src/htmx/Jsx.js
CHANGED
|
@@ -83,13 +83,13 @@ function renderComponent(Component, properties) {
|
|
|
83
83
|
|
|
84
84
|
function createElement(type, properties, key) {
|
|
85
85
|
const reactive = ReactiveRenderer.jsx();
|
|
86
|
-
const props = properties == null ? {} : properties;
|
|
86
|
+
const props = properties == null ? {} : properties;
|
|
87
87
|
if (!props || typeof props !== 'object' || Array.isArray(props)) {
|
|
88
88
|
throw new TypeError('JSX properties must be an object.');
|
|
89
89
|
}
|
|
90
90
|
let result;
|
|
91
91
|
if (type === Fragment) result = trustedHtml(renderChild(props.children));
|
|
92
|
-
else if (typeof type === 'string') result = renderIntrinsic(type, props);
|
|
92
|
+
else if (typeof type === 'string') result = renderIntrinsic(type, require('./SocketAction').attributes(props));
|
|
93
93
|
else if (typeof type === 'function') result = renderComponent(type, props);
|
|
94
94
|
else throw new TypeError('JSX element types must be intrinsic names or function components.');
|
|
95
95
|
const elementKey = key ?? props.key;
|
|
@@ -7,14 +7,21 @@ const { createInspection } = require('../development/Inspection');
|
|
|
7
7
|
const developmentSettings = require('../development/settings');
|
|
8
8
|
const OwnedServerLifecycle = require('../OwnedServerLifecycle');
|
|
9
9
|
const { listenServer, validateListenerOptions } = require('../serverLifecycle');
|
|
10
|
+
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
10
11
|
|
|
11
12
|
class LiveHtmlServer {
|
|
12
13
|
constructor(options = {}) {
|
|
14
|
+
try { this.initialize(options); }
|
|
15
|
+
catch (error) { throw scheduleStartupCleanup(error, () => this.shutdown()); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
initialize(options) {
|
|
13
19
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
14
20
|
throw new TypeError('Live HTML server options must be an object.');
|
|
15
21
|
}
|
|
16
22
|
const {
|
|
17
23
|
pages,
|
|
24
|
+
socketRoutes = [],
|
|
18
25
|
templateRoot,
|
|
19
26
|
livePaths,
|
|
20
27
|
sessionTtlMs,
|
|
@@ -29,6 +36,9 @@ class LiveHtmlServer {
|
|
|
29
36
|
server: suppliedApp,
|
|
30
37
|
...httpOptions
|
|
31
38
|
} = options;
|
|
39
|
+
if (!Array.isArray(socketRoutes) || socketRoutes.some(Route => typeof Route !== 'function')) {
|
|
40
|
+
throw new TypeError('`socketRoutes` must be an array of route classes.');
|
|
41
|
+
}
|
|
32
42
|
const settings = developmentSettings(development, ['inspect', 'refresh'], { refresh: process.env.REDWEB_DEV_REFRESH === '1' });
|
|
33
43
|
this._inspection = createInspection({ inspect: settings.inspect });
|
|
34
44
|
const app = suppliedApp === undefined ? express() : suppliedApp;
|
|
@@ -58,11 +68,10 @@ class LiveHtmlServer {
|
|
|
58
68
|
validateListenerOptions({ ...this.http, listen });
|
|
59
69
|
this._ownedServer = new OwnedServerLifecycle(this.http.server);
|
|
60
70
|
try {
|
|
61
|
-
if (this.manager.hasLivePages) {
|
|
62
|
-
const Route = this.manager.route();
|
|
71
|
+
if (this.manager.hasLivePages || socketRoutes.length) {
|
|
63
72
|
this.sockets = new SocketServer({
|
|
64
73
|
server: this.http.server,
|
|
65
|
-
routes: [
|
|
74
|
+
routes: [...(this.manager.hasLivePages ? [this.manager.route()] : []), ...require('./PageSocketRoute').bindRoutes(this.manager, socketRoutes)],
|
|
66
75
|
listen,
|
|
67
76
|
port: this.http.port,
|
|
68
77
|
bind: this.http.bind,
|
|
@@ -100,11 +109,11 @@ class LiveHtmlServer {
|
|
|
100
109
|
try { await this.sockets.shutdown(); }
|
|
101
110
|
catch (error) { errors.push(error); }
|
|
102
111
|
}
|
|
103
|
-
try { await this.manager
|
|
112
|
+
try { await this.manager?.shutdown(); }
|
|
104
113
|
catch (error) {
|
|
105
114
|
errors.push(error);
|
|
106
115
|
}
|
|
107
|
-
try { await this._ownedServer
|
|
116
|
+
try { await this._ownedServer?.close(this.manager.shutdownTimeoutMs, () => this.http.shutdown()); }
|
|
108
117
|
catch (error) { errors.push(error); }
|
|
109
118
|
if (errors.length) throw new AggregateError(errors, 'Live HTML shutdown failed.');
|
|
110
119
|
}
|
package/src/htmx/PageManager.js
CHANGED
|
@@ -16,6 +16,7 @@ const { AccessDenied } = require('../access/AccessPolicy');
|
|
|
16
16
|
const { PageIdentity, AuthenticationFailure, isPrincipal } = require('./PageIdentity');
|
|
17
17
|
const PageLifetime = require('./PageLifetime');
|
|
18
18
|
const requestSnapshot = require('../context/RequestSnapshot');
|
|
19
|
+
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
19
20
|
|
|
20
21
|
const PROTOCOL_VERSION = '1';
|
|
21
22
|
const DEFAULT_HEARTBEAT = Object.freeze({ intervalMs: 15_000, timeoutMs: 10_000 });
|
|
@@ -122,7 +123,8 @@ class PageManager {
|
|
|
122
123
|
this.renderAbortController = new AbortController();
|
|
123
124
|
setMaxListeners(maxSessions + maxConcurrentRenders + 1, this.renderAbortController.signal);
|
|
124
125
|
this.closing = false;
|
|
125
|
-
pages.forEach(PageClass => this.register(PageClass));
|
|
126
|
+
try { pages.forEach(PageClass => this.register(PageClass)); }
|
|
127
|
+
catch (error) { throw scheduleStartupCleanup(error, () => this.shutdown()); }
|
|
126
128
|
this.hasLivePages = [...this.records.values()].some(record => record.metadata.live !== false);
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -263,8 +265,10 @@ class PageManager {
|
|
|
263
265
|
if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
|
|
264
266
|
return renderValue(result);
|
|
265
267
|
};
|
|
266
|
-
const withContext = callback =>
|
|
267
|
-
|
|
268
|
+
const withContext = callback => require('./SocketAction').withRoute(record.socketHandlers,
|
|
269
|
+
() => LivePage.withRenderContext(context, callback));
|
|
270
|
+
const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
|
|
271
|
+
if (record.metadata.socket && !renderer.enabled) throw new TypeError('Socket-bound pages must render TSX.');
|
|
268
272
|
const document = this.createDocument(record, request);
|
|
269
273
|
if (record.metadata.live === false) {
|
|
270
274
|
const result = document(markup, null);
|
|
@@ -275,7 +279,7 @@ class PageManager {
|
|
|
275
279
|
session.renderLifetime = renderer;
|
|
276
280
|
const config = {
|
|
277
281
|
pageId: session.id,
|
|
278
|
-
socketPath: this.paths.socket,
|
|
282
|
+
socketPath: record.socketPath || this.paths.socket,
|
|
279
283
|
runtimePath: this.paths.runtime,
|
|
280
284
|
version: PROTOCOL_VERSION,
|
|
281
285
|
};
|
|
@@ -320,7 +324,7 @@ class PageManager {
|
|
|
320
324
|
session.timer.unref?.();
|
|
321
325
|
}
|
|
322
326
|
|
|
323
|
-
async authenticate(request) {
|
|
327
|
+
async authenticate(request, RouteClass) {
|
|
324
328
|
let id;
|
|
325
329
|
try {
|
|
326
330
|
id = new URL(request.url, `http://${request.headers.host || 'localhost'}`).searchParams.get('pageId');
|
|
@@ -328,8 +332,9 @@ class PageManager {
|
|
|
328
332
|
return false;
|
|
329
333
|
}
|
|
330
334
|
if (typeof id !== 'string' || id.length > 128) return false;
|
|
331
|
-
const session = this.pending.get(id) || this.active.get(id);
|
|
332
|
-
if (!session || session.socket || session.detaching) return false;
|
|
335
|
+
const session = this.pending.get(id) || this.active.get(id);
|
|
336
|
+
if (!session || session.socket || session.detaching) return false;
|
|
337
|
+
if (session.record?.metadata.socket !== RouteClass) return false;
|
|
333
338
|
try {
|
|
334
339
|
const principal = await this.identity.resolve(request, session.lifetime.signal);
|
|
335
340
|
if (!Object.is(principal, session.principal)) return false;
|
|
@@ -383,7 +388,7 @@ class PageManager {
|
|
|
383
388
|
connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
|
|
384
389
|
|
|
385
390
|
checkConnected(session, socket) {
|
|
386
|
-
if (!this.available(session) || session.socket !== socket) throw new AccessDenied('ACCESS_CANCELLED');
|
|
391
|
+
if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
|
|
387
392
|
session.connection.check();
|
|
388
393
|
}
|
|
389
394
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdmissionPolicy, ADMISSION_CONTEXT } = require('../ws/AdmissionPolicy');
|
|
4
|
+
const { AccessDenied } = require('../access/AccessPolicy');
|
|
5
|
+
const TransportPolicy = require('../ws/TransportPolicy');
|
|
6
|
+
const { guards } = require('../ws/HandlerGuard');
|
|
7
|
+
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
8
|
+
|
|
9
|
+
/** Adds page ownership to a route without replacing its admission, handlers or transport. */
|
|
10
|
+
function bindRoute(manager, RouteClass, records) {
|
|
11
|
+
const sessions = new WeakMap();
|
|
12
|
+
const ready = new WeakMap();
|
|
13
|
+
return class PageSocketRoute extends RouteClass {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
try {
|
|
17
|
+
if (!this.protocolPolicy?.versions.includes('1') || this.protocolPolicy.queryParameter !== 'redwebVersion') {
|
|
18
|
+
throw new TypeError('Page socket routes must support protocol version 1 and the default version query parameter.');
|
|
19
|
+
}
|
|
20
|
+
if (this.handlers.some(handler => handler.name.startsWith('redweb:'))) throw new TypeError('Page socket routes reserve redweb:* message types.');
|
|
21
|
+
if (!this.allowDuplicateConnections) throw new TypeError('Page socket routes require allowDuplicateConnections: true for independent browser tabs.');
|
|
22
|
+
this.transportPolicy = new TransportPolicy(this.transportPolicy || {}, true);
|
|
23
|
+
this.runtime.inFlight ||= new Set();
|
|
24
|
+
this.inFlight = this.runtime.inFlight;
|
|
25
|
+
for (const handler of this.handlers) {
|
|
26
|
+
const initial = handler.onInitialContact;
|
|
27
|
+
handler.onInitialContact = async (socket, request) => {
|
|
28
|
+
await ready.get(socket).promise;
|
|
29
|
+
if (socket.context.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
|
|
30
|
+
return initial?.call(handler, socket, request);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
this.pageAdmission = new AdmissionPolicy({
|
|
34
|
+
origins: (origin, request) => manager.acceptsOrigin(origin, request),
|
|
35
|
+
authenticate: async request => {
|
|
36
|
+
const session = await manager.authenticate(request, RouteClass);
|
|
37
|
+
if (!session) return false;
|
|
38
|
+
sessions.set(request, session);
|
|
39
|
+
return request[ADMISSION_CONTEXT]?.principal ?? true;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
record.socketPath = this.path;
|
|
44
|
+
record.socketHandlers = new Set(this.handlers.map(handler => handler.constructor));
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw scheduleStartupCleanup(error, () => this.shutdown());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
handleConnection(socket, request) {
|
|
52
|
+
let resolve, reject;
|
|
53
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
54
|
+
promise.catch(() => {});
|
|
55
|
+
ready.set(socket, { promise, resolve, reject });
|
|
56
|
+
return super.handleConnection(socket, request);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async authorizeUpgrade(request, rawSocket, signal) {
|
|
60
|
+
if (!await super.authorizeUpgrade(request, rawSocket, signal)) return false;
|
|
61
|
+
if (!new URL(request.url, 'http://localhost').searchParams.has('pageId')) return true;
|
|
62
|
+
return this.pageAdmission.authorize(request, rawSocket, this, signal);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async connectionOpenCallback(socket, request) {
|
|
66
|
+
try {
|
|
67
|
+
const session = sessions.get(request);
|
|
68
|
+
if (session) {
|
|
69
|
+
Object.defineProperty(socket, 'page', { value: PageClass => {
|
|
70
|
+
manager.checkConnected(session, socket);
|
|
71
|
+
if (!(session.page instanceof PageClass)) throw new TypeError('This connection does not own the requested page.');
|
|
72
|
+
return session.page;
|
|
73
|
+
} });
|
|
74
|
+
guards.set(socket, () => manager.authorize(session, socket));
|
|
75
|
+
session.renderer.authorize = () => manager.authorize(session, socket);
|
|
76
|
+
await manager.connect(session, socket);
|
|
77
|
+
}
|
|
78
|
+
await super.connectionOpenCallback(socket, request);
|
|
79
|
+
ready.get(socket).resolve();
|
|
80
|
+
} catch (error) { ready.get(socket).reject(error); throw error; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async handleMessage(socket, message) {
|
|
84
|
+
try {
|
|
85
|
+
await ready.get(socket).promise;
|
|
86
|
+
const session = socket.__redwebPageSession;
|
|
87
|
+
if (!session) return super.handleMessage(socket, message);
|
|
88
|
+
await manager.authorize(session, socket);
|
|
89
|
+
// Only the bound route's registered commands are accepted. Live action/state
|
|
90
|
+
// envelopes cannot bypass its handlers or mutate page fields.
|
|
91
|
+
const accepted = await super.handleMessage(socket, message);
|
|
92
|
+
if (accepted && message.requestId !== undefined) {
|
|
93
|
+
manager.checkConnected(session, socket);
|
|
94
|
+
socket.sendEvent('redweb:result', null, { requestId: message.requestId });
|
|
95
|
+
}
|
|
96
|
+
return accepted;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!(error instanceof AccessDenied)) throw error;
|
|
99
|
+
this.sendAccessFailure(socket, error, { requestId: message?.requestId });
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async connectionCloseCallback(socket) {
|
|
105
|
+
// Keep the weak guard: validators already in flight must still fail after close.
|
|
106
|
+
try { await manager.disconnect(socket); }
|
|
107
|
+
finally { await super.connectionCloseCallback?.(socket); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async handleBinaryMessage(socket, buffer) {
|
|
111
|
+
await ready.get(socket).promise;
|
|
112
|
+
if (socket.__redwebPageSession) {
|
|
113
|
+
socket.close(1008, 'Page sockets accept JSON commands only');
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return super.handleBinaryMessage(socket, buffer);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function bindRoutes(manager, RouteClasses) {
|
|
122
|
+
const attached = [...manager.records.values()].filter(record => record.metadata.socket);
|
|
123
|
+
for (const record of attached) {
|
|
124
|
+
if (!RouteClasses.includes(record.metadata.socket)) throw new TypeError('Every page socket route must be registered in the application.');
|
|
125
|
+
}
|
|
126
|
+
return RouteClasses.map(RouteClass => {
|
|
127
|
+
const records = attached.filter(record => record.metadata.socket === RouteClass);
|
|
128
|
+
return records.length ? bindRoute(manager, RouteClass, records) : RouteClass;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { bindRoutes };
|
|
@@ -222,8 +222,14 @@ class ReactiveRenderer {
|
|
|
222
222
|
const node = this.nodes.get(ownerId(payload.component));
|
|
223
223
|
return node && [...node.html.matchAll(/\b(?:data-rw-state|rw-bind)\s*=\s*["']([^"']+)["']/g)].some(match => match[1] === payload.name);
|
|
224
224
|
});
|
|
225
|
-
if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
|
|
226
|
-
|
|
225
|
+
if (!this.disposed && generation === this.generation && socket && (patches.length || explicit.length)) {
|
|
226
|
+
try { if (this.authorize) await this.authorize(); }
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (this.disposed || generation !== this.generation) return;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (this.disposed || generation !== this.generation) return;
|
|
232
|
+
socket.sendEvent('redweb:patch', { patches, states: explicit });
|
|
227
233
|
}
|
|
228
234
|
}
|
|
229
235
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
4
|
+
const { bindings } = require('../ws/SocketAction');
|
|
5
|
+
const routes = new AsyncLocalStorage();
|
|
6
|
+
|
|
7
|
+
function attributes(properties) {
|
|
8
|
+
let result = properties;
|
|
9
|
+
for (const name of ['rw-click', 'rw-submit']) {
|
|
10
|
+
const binding = bindings.get(properties[name]);
|
|
11
|
+
if (!binding) continue;
|
|
12
|
+
if (!routes.getStore()?.has(binding.Handler)) throw new TypeError('Socket action handler must belong to this page socket route.');
|
|
13
|
+
if (Object.keys(result).some(key => key.toLowerCase() === 'data-rw-command')) throw new TypeError('Only one socket action may bind a control.');
|
|
14
|
+
result = { ...result, [name]: binding.type, 'data-rw-command': JSON.stringify({ type: binding.type, payload: binding.payload }) };
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { attributes, withRoute: (handlers, render) => routes.run(handlers, render) };
|