redweb 0.13.5 → 0.14.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 +8 -1
- package/README.md +20 -32
- package/docs/APPLICATION.md +98 -0
- package/docs/CLI.md +1 -1
- 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 +4 -4
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +2 -2
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
- package/docs/generated.json +325 -271
- 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/topics.json +1 -0
- 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 +53 -5
- package/index.js +3 -0
- package/package.json +3 -3
- 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 +3 -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 +3 -4
- package/src/htmx/LiveHtmlServer.js +14 -5
- package/src/htmx/PageManager.js +3 -1
- package/src/ws/BaseSocketServer.js +13 -13
- package/src/ws/SocketRoute.js +6 -2
- 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
|
@@ -16,8 +16,8 @@ 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}`,
|
|
@@ -52,12 +52,11 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
|
|
|
52
52
|
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
53
53
|
}) },
|
|
54
54
|
{ path: 'src/app.tsx', content: read(`${template}/app.tsx`) },
|
|
55
|
-
{ path: 'src/run-app.ts', content: read('shared/run-app.ts') },
|
|
56
55
|
{ path: 'src/app.css', content: read(`${template === 'dashboard' ? template : 'shared'}/app.css`) },
|
|
57
56
|
{ path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
|
|
58
57
|
{ path: 'test/network.cjs', content: read('shared/network.cjs') },
|
|
59
58
|
{ path: 'test/app.test.cjs', content: read(`${template}/app.test.cjs`) },
|
|
60
|
-
{ path: 'test/
|
|
59
|
+
{ path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
|
|
61
60
|
{ path: 'README.md', content: `${read('shared/README.md')}\n${read(`${template}/README.md`)}` },
|
|
62
61
|
{ path: '.gitignore', content: 'node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n' },
|
|
63
62
|
];
|
|
@@ -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()] : []), ...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
|
|
|
@@ -12,6 +12,7 @@ const { PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT } = require('./AdmissionPolicy'
|
|
|
12
12
|
const { PROTOCOL_REJECTION } = require('./ProtocolPolicy');
|
|
13
13
|
const { RequestFailure, UPGRADE_REJECTION } = require('../access/RequestFailure');
|
|
14
14
|
const OwnedServerLifecycle = require('../OwnedServerLifecycle');
|
|
15
|
+
const { scheduleStartupCleanup } = require('../StartupCleanup');
|
|
15
16
|
const {
|
|
16
17
|
listenServer,
|
|
17
18
|
closeServer,
|
|
@@ -62,15 +63,14 @@ class BaseSocketServer {
|
|
|
62
63
|
for (const RouteClass of RouteClasses) {
|
|
63
64
|
const route = new RouteClass(server, { logger: this.logger });
|
|
64
65
|
if (this.routes.some(existing => existing.path === route.path)) {
|
|
65
|
-
this.
|
|
66
|
+
this.routes.push(route);
|
|
66
67
|
throw new Error('WebSocket route paths must be unique.');
|
|
67
68
|
}
|
|
68
69
|
this.routes.push(route);
|
|
69
70
|
}
|
|
70
71
|
} catch (error) {
|
|
71
72
|
this._ownedServer?.dispose();
|
|
72
|
-
this.disposeRoutes(this.routes);
|
|
73
|
-
throw error;
|
|
73
|
+
throw scheduleStartupCleanup(error, () => this.disposeRoutes(this.routes));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
this._upgradeHandler = this.handleUpgrade.bind(this);
|
|
@@ -90,17 +90,18 @@ class BaseSocketServer {
|
|
|
90
90
|
} catch (error) {
|
|
91
91
|
this.server.off('upgrade', this._upgradeHandler);
|
|
92
92
|
this._ownedServer?.dispose();
|
|
93
|
-
this.disposeRoutes(this.routes);
|
|
94
|
-
throw error;
|
|
93
|
+
throw scheduleStartupCleanup(error, () => this.disposeRoutes(this.routes));
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
disposeRoutes(routes) {
|
|
99
|
-
routes.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
async disposeRoutes(routes) {
|
|
98
|
+
const errors = await settleTasks(routes.map(route => () => route.shutdown?.()));
|
|
99
|
+
if (errors.length) {
|
|
100
|
+
for (const error of errors) {
|
|
101
|
+
try { this.logger?.error?.('Error shutting down route:', error); } catch { /* Preserve the cleanup failure even if logging fails. */ }
|
|
102
|
+
}
|
|
103
|
+
throw new AggregateError(errors, 'Route construction rollback failed.');
|
|
104
|
+
}
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
handleUpgrade(req, sock, head) {
|
|
@@ -208,8 +209,7 @@ class BaseSocketServer {
|
|
|
208
209
|
addRoute(RouteClass) {
|
|
209
210
|
const route = new RouteClass(this.server, { logger: this.logger });
|
|
210
211
|
if (this.routes.some(existing => existing.path === route.path)) {
|
|
211
|
-
this.disposeRoutes([route]);
|
|
212
|
-
throw new Error(`A WebSocket route already exists at ${route.path}.`);
|
|
212
|
+
throw scheduleStartupCleanup(new Error(`A WebSocket route already exists at ${route.path}.`), () => this.disposeRoutes([route]));
|
|
213
213
|
}
|
|
214
214
|
this.routes.push(route);
|
|
215
215
|
return route;
|
package/src/ws/SocketRoute.js
CHANGED
|
@@ -117,6 +117,10 @@ class SocketRoute {
|
|
|
117
117
|
const reservedOption = ['noServer', 'path', 'server', 'port']
|
|
118
118
|
.find(option => Object.prototype.hasOwnProperty.call(websocketOptions, option));
|
|
119
119
|
if (reservedOption) throw new TypeError(`Redweb controls websocketOptions.${reservedOption}.`);
|
|
120
|
+
const { closeTimeout = 5000 } = websocketOptions;
|
|
121
|
+
if (!Number.isInteger(closeTimeout) || closeTimeout < 1 || closeTimeout > 2147483647) {
|
|
122
|
+
throw new TypeError('`websocketOptions.closeTimeout` must be an integer between 1 and 2147483647 milliseconds.');
|
|
123
|
+
}
|
|
120
124
|
if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {
|
|
121
125
|
throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
|
|
122
126
|
}
|
|
@@ -135,7 +139,7 @@ class SocketRoute {
|
|
|
135
139
|
* @type {string}
|
|
136
140
|
*/
|
|
137
141
|
this.path = path;
|
|
138
|
-
this.websocketOptions = { ...websocketOptions };
|
|
142
|
+
this.websocketOptions = { ...websocketOptions, closeTimeout };
|
|
139
143
|
this.logger = logger || { log() {}, warn() {}, error() {} };
|
|
140
144
|
this.trustProxy = trustProxy;
|
|
141
145
|
this.getClientKey = getClientKey;
|
|
@@ -165,7 +169,7 @@ class SocketRoute {
|
|
|
165
169
|
throw new Error('Handler names must be unique within a route.');
|
|
166
170
|
}
|
|
167
171
|
this.clients = new Map();
|
|
168
|
-
this.server = new WebSocketServer({ ...websocketOptions, noServer: true });
|
|
172
|
+
this.server = new WebSocketServer({ ...this.websocketOptions, noServer: true });
|
|
169
173
|
this.server.on('connection', this.handleConnection.bind(this));
|
|
170
174
|
this.allowDuplicateConnections = Boolean(allowDuplicateConnections);
|
|
171
175
|
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
const assert = require('node:assert/strict');
|
|
2
|
-
const { test } = require('node:test');
|
|
3
|
-
const { spawn } = require('node:child_process');
|
|
4
|
-
|
|
5
|
-
// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.
|
|
6
|
-
// Windows cannot deliver POSIX signals through child.kill, so only that platform
|
|
7
|
-
// explicitly emits the signal event inside the child. Linux uses real OS signals.
|
|
8
|
-
const fixture = String.raw`
|
|
9
|
-
const assert = require('node:assert/strict');
|
|
10
|
-
const http = require('node:http');
|
|
11
|
-
const net = require('node:net');
|
|
12
|
-
const { once } = require('node:events');
|
|
13
|
-
const WebSocket = require('ws');
|
|
14
|
-
const mode = process.argv[1];
|
|
15
|
-
const signals = ['SIGINT', 'SIGTERM'];
|
|
16
|
-
const initial = signals.map(signal => process.listenerCount(signal));
|
|
17
|
-
const { runApp } = require('./dist/run-app.js');
|
|
18
|
-
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
|
|
19
|
-
require('./dist/app.js');
|
|
20
|
-
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
|
|
21
|
-
let cleanups = 0;
|
|
22
|
-
process.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));
|
|
23
|
-
const signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);
|
|
24
|
-
if (mode === 'invalid') {
|
|
25
|
-
for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);
|
|
26
|
-
} else if (mode === 'factory') {
|
|
27
|
-
assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);
|
|
28
|
-
} else {
|
|
29
|
-
if (mode === 'preserve') process.exitCode = '7';
|
|
30
|
-
const server = http.createServer((_request, response) => response.end('ready'));
|
|
31
|
-
const wss = new WebSocket.Server({ server });
|
|
32
|
-
wss.on('error', () => {}); // The HTTP listener error is owned by runApp.
|
|
33
|
-
const peers = new Set();
|
|
34
|
-
server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });
|
|
35
|
-
const close = async () => {
|
|
36
|
-
for (const peer of peers) peer.destroy();
|
|
37
|
-
for (const peer of wss.clients) peer.terminate();
|
|
38
|
-
await new Promise(resolve => wss.close(resolve));
|
|
39
|
-
await new Promise(resolve => server.close(resolve));
|
|
40
|
-
};
|
|
41
|
-
const app = runApp(() => ({ server, shutdown() {
|
|
42
|
-
cleanups++;
|
|
43
|
-
console.log('cleanup-started');
|
|
44
|
-
if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }
|
|
45
|
-
if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));
|
|
46
|
-
return close().then(async () => {
|
|
47
|
-
if (mode === 'hung') return new Promise(() => {});
|
|
48
|
-
if (mode === 'reject') throw Error('private cleanup detail');
|
|
49
|
-
if (mode === 'repeat') {
|
|
50
|
-
signal('SIGINT'); signal('SIGTERM');
|
|
51
|
-
server.emit('error', Error('private listener detail'));
|
|
52
|
-
}
|
|
53
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
54
|
-
});
|
|
55
|
-
} }), 200);
|
|
56
|
-
assert.equal(app.server, server);
|
|
57
|
-
(async () => {
|
|
58
|
-
if (mode === 'occupied') {
|
|
59
|
-
const other = http.createServer();
|
|
60
|
-
await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));
|
|
61
|
-
server.once('error', () => other.close());
|
|
62
|
-
server.listen(other.address().port, '127.0.0.1');
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
66
|
-
const port = server.address().port;
|
|
67
|
-
const response = await fetch('http://127.0.0.1:' + port);
|
|
68
|
-
assert.equal(await response.text(), 'ready');
|
|
69
|
-
const peer = net.connect(port, '127.0.0.1');
|
|
70
|
-
peer.on('error', () => {});
|
|
71
|
-
await once(peer, 'connect');
|
|
72
|
-
peer.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
|
|
73
|
-
const socket = new WebSocket('ws://127.0.0.1:' + port);
|
|
74
|
-
socket.on('error', () => {});
|
|
75
|
-
await once(socket, 'open');
|
|
76
|
-
if (mode === 'native-close') {
|
|
77
|
-
for (const connection of peers) connection.destroy();
|
|
78
|
-
server.close();
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
// A partial HTTP peer otherwise prevents native close; application cleanup
|
|
82
|
-
// begins via the signal and the later native close must not end its timer.
|
|
83
|
-
signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');
|
|
84
|
-
})().catch(error => { console.error(error); process.exit(99); });
|
|
85
|
-
}
|
|
86
|
-
`;
|
|
87
|
-
|
|
88
|
-
function execute(mode, t, args = ['-e', fixture, mode], env = process.env) {
|
|
89
|
-
return new Promise((resolve, reject) => {
|
|
90
|
-
const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });
|
|
91
|
-
let stdout = '', stderr = '';
|
|
92
|
-
let timedOut = false, finished = false;
|
|
93
|
-
const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
|
|
94
|
-
const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);
|
|
95
|
-
t.after(async () => {
|
|
96
|
-
clearTimeout(deadline);
|
|
97
|
-
if (!finished) { child.kill('SIGKILL'); await closed; }
|
|
98
|
-
});
|
|
99
|
-
child.stdout.on('data', data => { stdout += data; });
|
|
100
|
-
child.stderr.on('data', data => { stderr += data; });
|
|
101
|
-
child.once('error', reject);
|
|
102
|
-
child.once('close', (code, signal) => {
|
|
103
|
-
clearTimeout(deadline);
|
|
104
|
-
if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\n${stdout}\n${stderr}`));
|
|
105
|
-
else resolve({ code, signal, stdout, stderr });
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
test('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {
|
|
111
|
-
const net = require('node:net');
|
|
112
|
-
const { once } = require('node:events');
|
|
113
|
-
const fs = require('node:fs');
|
|
114
|
-
const path = require('node:path');
|
|
115
|
-
const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));
|
|
116
|
-
const occupied = net.createServer(socket => socket.destroy());
|
|
117
|
-
const loopback = net.createServer(socket => socket.destroy());
|
|
118
|
-
let failure;
|
|
119
|
-
try {
|
|
120
|
-
occupied.listen(0, '0.0.0.0');
|
|
121
|
-
await once(occupied, 'listening');
|
|
122
|
-
// Windows permits distinct wildcard/loopback binds on the same port.
|
|
123
|
-
// Hold both addresses; Unix may already reject the second bind.
|
|
124
|
-
loopback.listen(occupied.address().port, '127.0.0.1');
|
|
125
|
-
try { await once(loopback, 'listening'); }
|
|
126
|
-
catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
|
|
127
|
-
const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };
|
|
128
|
-
delete env.DASHBOARD_ORIGIN;
|
|
129
|
-
const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);
|
|
130
|
-
assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
|
|
131
|
-
assert.equal(result.signal, null);
|
|
132
|
-
assert.match(result.stderr, /Application listener failed/);
|
|
133
|
-
} catch (error) { failure = error; }
|
|
134
|
-
const cleanup = await Promise.allSettled([
|
|
135
|
-
...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>
|
|
136
|
-
error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),
|
|
137
|
-
fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
|
138
|
-
]);
|
|
139
|
-
const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];
|
|
140
|
-
if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
for (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {
|
|
144
|
-
test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {
|
|
145
|
-
const result = await execute(mode, t);
|
|
146
|
-
const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;
|
|
147
|
-
assert.equal(result.code, expected, `${result.stdout}\n${result.stderr}`);
|
|
148
|
-
assert.equal(result.signal, null);
|
|
149
|
-
assert.doesNotMatch(result.stderr, /private .* detail/);
|
|
150
|
-
const noApp = ['invalid', 'factory'].includes(mode);
|
|
151
|
-
assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);
|
|
152
|
-
if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);
|
|
153
|
-
if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {
|
|
154
|
-
const snapshot = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1));
|
|
155
|
-
assert.deepEqual(snapshot.signals, snapshot.initial);
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
}
|