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.
Files changed (57) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +20 -32
  3. package/docs/APPLICATION.md +98 -0
  4. package/docs/CLI.md +1 -1
  5. package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
  6. package/docs/DEFINE_APP_VERIFICATION.md +101 -0
  7. package/docs/DEVELOPMENT.md +1 -1
  8. package/docs/GETTING_STARTED.md +1 -1
  9. package/docs/LIVE_HTML.md +2 -2
  10. package/docs/MIGRATION.md +1 -1
  11. package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
  12. package/docs/RELEASE_TRUST.md +4 -4
  13. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  14. package/docs/SOCKET_CONTRACTS.md +2 -2
  15. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
  16. package/docs/generated.json +325 -271
  17. package/docs/guides/http-websocket.md +4 -4
  18. package/docs/guides/jsx-without-react.md +1 -1
  19. package/docs/reference.json +40 -1
  20. package/docs/releases/0.14.0.json +2208 -0
  21. package/docs/topics.json +1 -0
  22. package/examples/live-html/cards.js +87 -86
  23. package/examples/live-html/cards.ts +3 -2
  24. package/examples/live-html/chatroom.js +210 -207
  25. package/examples/live-html/chatroom.tsx +6 -2
  26. package/examples/live-html/components.js +103 -102
  27. package/examples/live-html/components.ts +3 -2
  28. package/examples/live-html/counter.js +74 -73
  29. package/examples/live-html/counter.ts +3 -2
  30. package/examples/live-html/jsx-page.js +2 -1
  31. package/examples/live-html/jsx-page.tsx +3 -2
  32. package/index.d.ts +53 -5
  33. package/index.js +3 -0
  34. package/package.json +3 -3
  35. package/recipes/chat/app.test.cjs +3 -1
  36. package/recipes/chat/app.tsx +4 -7
  37. package/recipes/dashboard/app.test.cjs +10 -10
  38. package/recipes/dashboard/app.tsx +29 -29
  39. package/recipes/http-ws/README.md +1 -1
  40. package/recipes/http-ws/app.test.cjs +8 -10
  41. package/recipes/http-ws/app.tsx +9 -20
  42. package/recipes/realtime/app.tsx +3 -6
  43. package/recipes/shared/README.md +3 -1
  44. package/recipes/shared/lifecycle.test.cjs +81 -0
  45. package/recipes/shared/network.cjs +10 -5
  46. package/recipes/site/app.tsx +3 -6
  47. package/recipes/socket/app.tsx +3 -10
  48. package/src/Application.js +239 -0
  49. package/src/StartupCleanup.js +24 -0
  50. package/src/cli/SourceInspector.js +5 -0
  51. package/src/cli/templates.js +3 -4
  52. package/src/htmx/LiveHtmlServer.js +14 -5
  53. package/src/htmx/PageManager.js +3 -1
  54. package/src/ws/BaseSocketServer.js +13 -13
  55. package/src/ws/SocketRoute.js +6 -2
  56. package/recipes/shared/run-app.test.cjs +0 -158
  57. 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);
@@ -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/run-app.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/run-app.test.cjs',
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/run-app.test.cjs', content: read('shared/run-app.test.cjs') },
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: [Route],
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.shutdown(); }
112
+ try { await this.manager?.shutdown(); }
104
113
  catch (error) {
105
114
  errors.push(error);
106
115
  }
107
- try { await this._ownedServer.close(this.manager.shutdownTimeoutMs, () => this.http.shutdown()); }
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
  }
@@ -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.disposeRoutes([route]);
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.forEach(route => {
100
- Promise.resolve()
101
- .then(() => route.shutdown?.())
102
- .catch(error => this.logger?.error?.('Error shutting down route:', error));
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;
@@ -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
- }