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.
Files changed (72) hide show
  1. package/CHANGELOG.md +20 -3
  2. package/README.md +289 -288
  3. package/contract.d.ts +11 -3
  4. package/docs/APPLICATION.md +98 -0
  5. package/docs/CLI.md +1 -1
  6. package/docs/CLIENT_DEVELOPMENT.md +9 -5
  7. package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
  8. package/docs/DEFINE_APP_VERIFICATION.md +101 -0
  9. package/docs/DEVELOPMENT.md +1 -1
  10. package/docs/GETTING_STARTED.md +1 -1
  11. package/docs/LIVE_HTML.md +2 -2
  12. package/docs/MIGRATION.md +1 -1
  13. package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
  14. package/docs/RELEASE_TRUST.md +29 -6
  15. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  16. package/docs/SOCKET_CONTRACTS.md +6 -3
  17. package/docs/SOCKET_PAGES.md +99 -0
  18. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
  19. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
  20. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
  21. package/docs/generated.json +2217 -2154
  22. package/docs/guides/http-websocket.md +4 -4
  23. package/docs/guides/jsx-without-react.md +1 -1
  24. package/docs/reference.json +40 -1
  25. package/docs/releases/0.14.0.json +2208 -0
  26. package/docs/releases/0.15.0.json +2217 -0
  27. package/docs/topics.json +3 -1
  28. package/examples/live-html/cards.js +87 -86
  29. package/examples/live-html/cards.ts +3 -2
  30. package/examples/live-html/chatroom.js +210 -207
  31. package/examples/live-html/chatroom.tsx +6 -2
  32. package/examples/live-html/components.js +103 -102
  33. package/examples/live-html/components.ts +3 -2
  34. package/examples/live-html/counter.js +74 -73
  35. package/examples/live-html/counter.ts +3 -2
  36. package/examples/live-html/jsx-page.js +2 -1
  37. package/examples/live-html/jsx-page.tsx +3 -2
  38. package/index.d.ts +59 -7
  39. package/index.js +3 -0
  40. package/package.json +8 -5
  41. package/recipes/chat/app.test.cjs +3 -1
  42. package/recipes/chat/app.tsx +4 -7
  43. package/recipes/dashboard/app.test.cjs +10 -10
  44. package/recipes/dashboard/app.tsx +29 -29
  45. package/recipes/http-ws/README.md +1 -1
  46. package/recipes/http-ws/app.test.cjs +8 -10
  47. package/recipes/http-ws/app.tsx +9 -20
  48. package/recipes/realtime/app.tsx +3 -6
  49. package/recipes/shared/README.md +10 -1
  50. package/recipes/shared/lifecycle.test.cjs +81 -0
  51. package/recipes/shared/network.cjs +10 -5
  52. package/recipes/site/app.tsx +3 -6
  53. package/recipes/socket/app.tsx +3 -10
  54. package/src/Application.js +239 -0
  55. package/src/StartupCleanup.js +24 -0
  56. package/src/cli/SourceInspector.js +5 -0
  57. package/src/cli/templates.js +6 -6
  58. package/src/htmx/Jsx.js +2 -2
  59. package/src/htmx/LiveHtmlServer.js +14 -5
  60. package/src/htmx/PageManager.js +13 -8
  61. package/src/htmx/PageSocketRoute.js +132 -0
  62. package/src/htmx/ReactiveRenderer.js +8 -2
  63. package/src/htmx/SocketAction.js +19 -0
  64. package/src/htmx/metadata.js +6 -2
  65. package/src/ws/BaseHandler.js +6 -4
  66. package/src/ws/BaseSocketServer.js +13 -13
  67. package/src/ws/HandlerGuard.js +4 -0
  68. package/src/ws/SocketAction.js +16 -0
  69. package/src/ws/SocketContract.js +3 -2
  70. package/src/ws/SocketRoute.js +9 -4
  71. package/recipes/shared/run-app.test.cjs +0 -158
  72. 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);
@@ -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/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}`,
24
24
  ...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
25
25
  ...(template === 'dashboard' ? { express: dependencies.express } : {}),
26
26
  },
27
- devDependencies: {
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/run-app.test.cjs', content: read('shared/run-app.test.cjs') },
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: [Route],
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.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
 
@@ -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 => LivePage.withRenderContext(context, callback);
267
- const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
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
- socket.sendEvent('redweb:patch', { patches, states: explicit });
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) };