redweb 0.12.0 → 0.13.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 (167) hide show
  1. package/CHANGELOG.md +166 -9
  2. package/README.md +177 -523
  3. package/bin/redweb.js +11 -20
  4. package/client.d.ts +7 -2
  5. package/config/tsconfig.json +14 -14
  6. package/contract.d.ts +45 -0
  7. package/contract.js +5 -0
  8. package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
  9. package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
  10. package/docs/AGENT_ACCESS.md +35 -0
  11. package/docs/AGENT_EVALUATION.md +58 -0
  12. package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
  13. package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
  14. package/docs/BENCHMARK_VERIFICATION.md +307 -0
  15. package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
  16. package/docs/CLI.md +116 -0
  17. package/docs/CLIENT_DEVELOPMENT.md +152 -0
  18. package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
  19. package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
  20. package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
  21. package/docs/DEVELOPMENT.md +79 -0
  22. package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
  23. package/docs/DOCUMENTATION.md +37 -0
  24. package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
  25. package/docs/GETTING_STARTED.md +58 -0
  26. package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
  27. package/docs/LIVE_HTML.md +169 -21
  28. package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
  29. package/docs/MIGRATION.md +28 -0
  30. package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
  31. package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
  32. package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
  33. package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
  34. package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
  35. package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
  36. package/docs/PRODUCTION_READINESS.md +11 -2
  37. package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
  38. package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
  39. package/docs/RECOVERY_CODE_CENSUS.md +158 -0
  40. package/docs/RECOVERY_COMPARISON.md +103 -0
  41. package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
  42. package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
  43. package/docs/RECOVERY_INVESTIGATION.md +229 -0
  44. package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
  45. package/docs/RELEASE_TRUST.md +58 -0
  46. package/docs/ROOM_AUTHORIZATION.md +49 -0
  47. package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
  48. package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
  49. package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
  50. package/docs/SOAK_VERIFICATION.md +154 -0
  51. package/docs/SOCKET_CONTRACTS.md +39 -0
  52. package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
  53. package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
  54. package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
  55. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
  56. package/docs/STARTER_REPORT_RETENTION.md +73 -0
  57. package/docs/VERIFICATION_EVIDENCE.md +2 -0
  58. package/docs/generated.json +2154 -0
  59. package/docs/guides/chatroom.md +27 -0
  60. package/docs/guides/http-websocket.md +28 -0
  61. package/docs/guides/jsx-without-react.md +26 -0
  62. package/docs/guides/realtime-dashboard.md +29 -0
  63. package/docs/guides/typed-websockets.md +26 -0
  64. package/docs/reference.json +1207 -0
  65. package/docs/snippets/room-access.tsx +51 -0
  66. package/docs/topics.json +21 -0
  67. package/examples/live-html/chatroom.js +207 -268
  68. package/examples/live-html/chatroom.tsx +167 -0
  69. package/examples/live-html/jsx-page.js +1 -1
  70. package/examples/live-html/jsx-page.tsx +1 -1
  71. package/examples/live-html/tsconfig.json +8 -7
  72. package/index.d.ts +170 -45
  73. package/index.js +2 -0
  74. package/jsx-dev-runtime.js +2 -2
  75. package/jsx-runtime.d.ts +7 -2
  76. package/package.json +88 -7
  77. package/recipes/add/artifact.test.cjs +57 -0
  78. package/recipes/add/live.tsx +18 -0
  79. package/recipes/add/socket-route.ts +24 -0
  80. package/recipes/chat/README.md +22 -0
  81. package/recipes/chat/app.test.cjs +105 -0
  82. package/recipes/chat/app.tsx +9 -0
  83. package/recipes/dashboard/README.md +43 -0
  84. package/recipes/dashboard/admin.ts +21 -0
  85. package/recipes/dashboard/app.css +16 -0
  86. package/recipes/dashboard/app.test.cjs +450 -0
  87. package/recipes/dashboard/app.tsx +86 -0
  88. package/recipes/dashboard/auth.ts +80 -0
  89. package/recipes/dashboard/cards.tsx +102 -0
  90. package/recipes/dashboard/rate-window.test.cjs +17 -0
  91. package/recipes/dashboard/store.ts +120 -0
  92. package/recipes/http-ws/README.md +11 -0
  93. package/recipes/http-ws/app.test.cjs +92 -0
  94. package/recipes/http-ws/app.tsx +36 -0
  95. package/recipes/realtime/README.md +8 -0
  96. package/recipes/realtime/app.test.cjs +15 -0
  97. package/recipes/realtime/app.tsx +28 -0
  98. package/recipes/shared/README.md +40 -0
  99. package/recipes/shared/app.css +8 -0
  100. package/recipes/shared/copy-assets.cjs +8 -0
  101. package/recipes/shared/network.cjs +59 -0
  102. package/recipes/shared/run-app.test.cjs +158 -0
  103. package/recipes/shared/run-app.ts +50 -0
  104. package/recipes/site/README.md +4 -0
  105. package/recipes/site/app.test.cjs +19 -0
  106. package/recipes/site/app.tsx +25 -0
  107. package/recipes/socket/README.md +39 -0
  108. package/recipes/socket/app.test.cjs +85 -0
  109. package/recipes/socket/app.tsx +30 -0
  110. package/recipes/socket/contract.ts +12 -0
  111. package/recipes/socket/handlers.ts +40 -0
  112. package/src/OwnedServerLifecycle.js +66 -0
  113. package/src/access/AccessPolicy.js +37 -0
  114. package/src/access/AuthenticationFailure.js +13 -0
  115. package/src/access/RequestFailure.js +33 -0
  116. package/src/access/failure-codes.json +25 -0
  117. package/src/async/BoundedOperation.js +62 -0
  118. package/src/cli/ActionReferences.js +193 -0
  119. package/src/cli/AdditionLayout.js +140 -0
  120. package/src/cli/FilePlan.js +94 -0
  121. package/src/cli/ProjectAddition.js +60 -0
  122. package/src/cli/ProjectConfig.js +26 -0
  123. package/src/cli/ProjectDoctor.js +112 -0
  124. package/src/cli/ProjectInitializer.js +20 -30
  125. package/src/cli/SourceInspector.js +207 -0
  126. package/src/cli/StaticSource.js +192 -0
  127. package/src/cli/arguments.js +62 -0
  128. package/src/cli/formatCommand.js +10 -0
  129. package/src/cli/run.js +57 -0
  130. package/src/cli/templates.js +86 -87
  131. package/src/context/RequestSnapshot.js +41 -0
  132. package/src/dataProperty.js +11 -0
  133. package/src/development/DevelopmentPageManager.js +48 -0
  134. package/src/development/Inspection.js +104 -0
  135. package/src/development/ObservedRenderer.js +42 -0
  136. package/src/development/description.js +35 -0
  137. package/src/development/loopbackRequest.js +27 -0
  138. package/src/development/refreshBrowser.js +96 -0
  139. package/src/development/refreshStyles.js +9 -0
  140. package/src/development/settings.js +17 -0
  141. package/src/docs/Documentation.js +182 -0
  142. package/src/htmx/ActionDefinition.js +44 -0
  143. package/src/htmx/Jsx.js +24 -8
  144. package/src/htmx/LiveHtmlServer.js +41 -19
  145. package/src/htmx/LivePage.js +63 -13
  146. package/src/htmx/PageIdentity.js +32 -0
  147. package/src/htmx/PageLifetime.js +37 -0
  148. package/src/htmx/PageManager.js +203 -74
  149. package/src/htmx/ReactiveRenderer.js +241 -0
  150. package/src/htmx/StaticExporter.js +1 -1
  151. package/src/htmx/TemplateRenderer.js +13 -7
  152. package/src/htmx/browserRuntime.js +2 -93
  153. package/src/htmx/metadata.js +19 -7
  154. package/src/validation/ActionInputError.js +12 -0
  155. package/src/validation/SchemaValidator.js +38 -0
  156. package/src/ws/AdmissionPolicy.js +24 -23
  157. package/src/ws/BaseSocketServer.js +53 -38
  158. package/src/ws/ContractValidationError.js +12 -0
  159. package/src/ws/HeartbeatMonitor.js +19 -7
  160. package/src/ws/ProtocolPolicy.js +1 -1
  161. package/src/ws/RoomAccess.js +82 -0
  162. package/src/ws/RoomRegistry.js +56 -6
  163. package/src/ws/RouteRuntime.js +56 -10
  164. package/src/ws/SocketContract.js +112 -0
  165. package/src/ws/SocketRoute.js +18 -0
  166. package/src/ws/protocol-schema.json +6 -1
  167. package/examples/live-html/chatroom.ts +0 -217
@@ -1,14 +1,21 @@
1
1
  const { createHash, randomUUID } = require('crypto');
2
+ const { RequestFailure } = require('../access/RequestFailure');
2
3
  const path = require('path');
4
+ const { setMaxListeners } = require('events');
3
5
  const { BaseHandler } = require('../ws/BaseHandler');
4
6
  const { SocketRoute } = require('../ws');
5
7
  const HtmlRenderer = require('./HtmlRenderer');
6
8
  const PageAssetLoader = require('./PageAssetLoader');
7
9
  const LivePage = require('./LivePage');
10
+ const ReactiveRenderer = require('./ReactiveRenderer');
8
11
  const browserRuntime = require('./browserRuntime');
9
12
  const { isHtml, renderValue, trustedHtml } = require('./Html');
10
13
  const { getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot } = require('./metadata');
11
14
  const synchronous = require('./synchronous');
15
+ const { AccessDenied } = require('../access/AccessPolicy');
16
+ const { PageIdentity, AuthenticationFailure, isPrincipal } = require('./PageIdentity');
17
+ const PageLifetime = require('./PageLifetime');
18
+ const requestSnapshot = require('../context/RequestSnapshot');
12
19
 
13
20
  const PROTOCOL_VERSION = '1';
14
21
  const DEFAULT_HEARTBEAT = Object.freeze({ intervalMs: 15_000, timeoutMs: 10_000 });
@@ -62,7 +69,7 @@ function matchesIfNoneMatch(header, etag) {
62
69
  }
63
70
 
64
71
  class PageManager {
65
- constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, origins, logger = console }) {
72
+ constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, logger = console }, reservedPaths = {}) {
66
73
  if (!Array.isArray(pages) || pages.length === 0) throw new TypeError('`pages` must be a non-empty array.');
67
74
  if (templateRoot !== undefined && (typeof templateRoot !== 'string' || !templateRoot)) throw new TypeError('`templateRoot` must be a non-empty string.');
68
75
  if (!Number.isInteger(sessionTtlMs) || sessionTtlMs < 0) throw new TypeError('`sessionTtlMs` must be a non-negative integer.');
@@ -72,12 +79,11 @@ class PageManager {
72
79
  }
73
80
  if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
74
81
  if (!paths || typeof paths !== 'object' || Array.isArray(paths)) throw new TypeError('`paths` must be an object.');
75
- if (authenticate !== undefined && typeof authenticate !== 'function') throw new TypeError('`authenticate` must be a function.');
76
82
  if (origins !== undefined && typeof origins !== 'function' &&
77
83
  (!Array.isArray(origins) || origins.some(origin => typeof origin !== 'string' || !origin))) {
78
84
  throw new TypeError('`origins` must be a function or an array of non-empty origins.');
79
85
  }
80
- this.paths = { ...DEFAULT_PATHS, ...paths };
86
+ this.paths = { ...DEFAULT_PATHS, ...paths, ...reservedPaths };
81
87
  Object.entries(this.paths).forEach(([name, value]) => {
82
88
  internalPath(value, name);
83
89
  });
@@ -99,6 +105,8 @@ class PageManager {
99
105
  this.heartbeat = heartbeat;
100
106
  this.logger = logger || { log() {}, warn() {}, error() {} };
101
107
  this.authenticateRequest = authenticate;
108
+ this.identity = new PageIdentity(authenticate, authenticationTimeoutMs);
109
+ this.lifetimes = new Set();
102
110
  this.origins = origins;
103
111
  this.pending = new Map();
104
112
  this.active = new Map();
@@ -112,6 +120,7 @@ class PageManager {
112
120
  this.renderWaiters = [];
113
121
  this.renderPages = new Set();
114
122
  this.renderAbortController = new AbortController();
123
+ setMaxListeners(maxSessions + maxConcurrentRenders + 1, this.renderAbortController.signal);
115
124
  this.closing = false;
116
125
  pages.forEach(PageClass => this.register(PageClass));
117
126
  this.hasLivePages = [...this.records.values()].some(record => record.metadata.live !== false);
@@ -163,92 +172,130 @@ class PageManager {
163
172
 
164
173
  mount(app) {
165
174
  if (this.hasLivePages) {
166
- const clientFile = path.join(path.dirname(require.resolve('redweb-client')), 'index.js');
175
+ const clientFile = path.join(path.dirname(require.resolve('redweb-client/live-html')), 'live-html.js');
167
176
  app.get(this.paths.client, (_request, response) => response.sendFile(clientFile));
168
177
  app.get(this.paths.runtime, (_request, response) => response.type('text/javascript').send(browserRuntime(this.paths.client)));
169
178
  }
170
179
  this.stylesheets.forEach((content, url) => app.get(url, (_request, response) => {
171
180
  response.set('Cache-Control', 'public, max-age=31536000, immutable').type('text/css').send(content);
172
181
  }));
173
- this.records.forEach(record => app.get(record.metadata.path, (request, response, next) => {
174
- Promise.resolve(this.render(record, request)).then(markup => {
175
- if (record.metadata.live !== false) {
176
- response.set('Cache-Control', 'private, no-store').type('html').send(markup);
177
- return;
178
- }
179
- if (this.authenticateRequest) {
180
- response.set('Cache-Control', 'private, no-store').type('html').send(markup);
181
- return;
182
- }
183
- const etag = `"${createHash('sha256').update(markup).digest('base64url')}"`;
184
- response.set('Cache-Control', cacheControl(record.metadata.cache)).set('ETag', etag);
185
- if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) {
186
- response.status(304).end();
187
- return;
188
- }
189
- response.type('html').send(markup);
190
- }, next);
182
+ this.records.forEach(record => app.get(record.metadata.path, (request, response) => {
183
+ const controller = new AbortController();
184
+ const closed = () => controller.abort();
185
+ const finished = () => { response.off('close', closed); response.off('finish', finished); };
186
+ response.once('close', closed);
187
+ response.once('finish', finished);
188
+ this.render(record, request, controller.signal, markup => this.respond(record, request, response, markup)).catch(error => {
189
+ if (response.destroyed || response.writableEnded) return;
190
+ if (response.headersSent) { response.destroy(); return; }
191
+ if (this.closing) response.set('Connection', 'close');
192
+ const failure = RequestFailure.from(error, 'PAGE_FAILED');
193
+ response.set('Cache-Control', 'private, no-store').status(failure.status).json({ error: {
194
+ code: failure.code, message: failure.message,
195
+ } });
196
+ }).finally(finished);
191
197
  }));
192
198
  }
193
199
 
194
- async render(record, request) {
200
+ respond(record, request, response, markup) {
201
+ if (record.metadata.live !== false || this.authenticateRequest || record.metadata.policy) {
202
+ // end() deliberately bypasses Express's automatic conditional-GET/ETag handling.
203
+ response.set('Cache-Control', 'private, no-store').type('html').end(markup);
204
+ return;
205
+ }
206
+ const etag = `"${createHash('sha256').update(markup).digest('base64url')}"`;
207
+ response.set('Cache-Control', cacheControl(record.metadata.cache)).set('ETag', etag);
208
+ if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) {
209
+ response.status(304).end();
210
+ return;
211
+ }
212
+ response.type('html').send(markup);
213
+ }
214
+
215
+ createDocument(record) {
216
+ return (markup, config) => HtmlRenderer.document(markup, config, record.stylesheets, record.metadata.head);
217
+ }
218
+
219
+ createLifetime(principal) {
220
+ const lifetime = new PageLifetime(this.renderAbortController.signal);
221
+ lifetime.principal = principal;
222
+ this.lifetimes.add(lifetime);
223
+ return lifetime;
224
+ }
225
+
226
+ async render(record, request, signal, onReady) {
195
227
  const live = record.metadata.live !== false;
196
228
  const sessionsFull = live && this.pending.size + this.active.size + this.liveRendering >= this.maxSessions;
197
229
  if (this.closing || this.rendering >= this.maxConcurrentRenders || sessionsFull) {
198
- const error = new Error('Live HTML session capacity reached.');
199
- error.status = 503;
200
- throw error;
230
+ throw new RequestFailure('PAGE_CAPACITY');
201
231
  }
202
232
  this.rendering += 1;
203
233
  if (live) this.liveRendering += 1;
204
234
  const ownsPage = record.metadata.scope === 'connection';
205
235
  let page;
236
+ let renderer;
237
+ let session;
238
+ const lifetime = this.createLifetime();
239
+ const deliver = markup => { lifetime.check(); onReady?.(markup); return markup; };
240
+ signal?.addEventListener('abort', lifetime.abort, { once: true });
241
+ if (signal?.aborted) lifetime.abort();
206
242
  try {
243
+ const snapshot = requestSnapshot(request);
244
+ const principal = await this.identity.resolve(request, lifetime.signal);
245
+ lifetime.principal = principal;
246
+ lifetime.check();
247
+ const context = Object.freeze({ request: snapshot, params: snapshot.params, query: snapshot.query, body: snapshot.body, principal, signal: lifetime.signal });
248
+ await record.metadata.policy?.check(context);
249
+ lifetime.check();
207
250
  page = ownsPage ? this.instantiate(record) : record.shared;
251
+ lifetime.page = ownsPage ? page : null;
208
252
  this.renderPages.add(page);
209
- const principal = this.authenticateRequest ? await this.authenticateRequest(request) : undefined;
210
- if (this.authenticateRequest && (principal === false || principal === null || principal === undefined || typeof principal === 'object')) {
211
- const error = new Error('Live HTML authentication failed.');
212
- error.status = 401;
213
- throw error;
214
- }
215
- const context = Object.freeze({
216
- request,
217
- params: request.params,
218
- query: request.query,
219
- body: request.body,
220
- principal,
221
- signal: this.renderAbortController.signal,
222
- });
223
- await page.loading?.(context);
224
- await page._loadComponents(context);
225
- if (this.closing) throw new Error('Live HTML server is shutting down.');
226
- const markup = await LivePage.withRenderContext(context, async () => {
253
+ if (live) renderer = new this.Renderer(page, lifetime.signal);
254
+ await lifetime.wait(() => page.loading?.(context));
255
+ await lifetime.wait(() => page._loadComponents(context));
256
+ const render = async () => {
227
257
  const source = record.template ?? await page.render?.(context);
228
- if (this.closing) throw new Error('Live HTML server is shutting down.');
258
+ lifetime.check();
229
259
  if (source === undefined) throw new Error(`${record.PageClass.name} must provide a template or render().`);
230
260
  const content = isHtml(source) ? renderValue(source) : HtmlRenderer.render(source.toString(), page, { live });
231
261
  if (!record.metadata.layout) return content;
232
262
  const result = synchronous(record.metadata.layout(trustedHtml(content), context), 'Page layouts must render synchronously.');
233
263
  if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
234
264
  return renderValue(result);
235
- });
265
+ };
266
+ const withContext = callback => LivePage.withRenderContext(context, callback);
267
+ const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
268
+ const document = this.createDocument(record, request);
236
269
  if (record.metadata.live === false) {
237
- const document = HtmlRenderer.document(markup, null, record.stylesheets, record.metadata.head);
238
- if (ownsPage) await page.dispose();
239
- return document;
270
+ const result = document(markup, null);
271
+ if (ownsPage) await lifetime.wait(() => page.dispose());
272
+ return deliver(result);
240
273
  }
241
- const session = this.createSession(page, ownsPage, principal);
242
- return HtmlRenderer.document(markup, {
274
+ session = this.createSession(page, ownsPage, principal, context, record, lifetime);
275
+ session.renderLifetime = renderer;
276
+ const config = {
243
277
  pageId: session.id,
244
278
  socketPath: this.paths.socket,
245
279
  runtimePath: this.paths.runtime,
246
280
  version: PROTOCOL_VERSION,
247
- }, record.stylesheets, record.metadata.head);
281
+ };
282
+ if (renderer.enabled) {
283
+ session.renderer = renderer;
284
+ renderer.document = value => document(value, config);
285
+ renderer.onError = error => this.logger.error?.('Live HTML reactive render failed.', error);
286
+ } else renderer.nodes.clear();
287
+ return deliver(document(markup, config));
248
288
  } catch (error) {
249
- if (ownsPage && page) await page.dispose();
289
+ renderer?.dispose();
290
+ if (ownsPage && page) {
291
+ if (lifetime.signal.aborted) await withinDeadline(Promise.allSettled([page.dispose()]), Date.now() + this.shutdownTimeoutMs);
292
+ else await page.dispose();
293
+ }
294
+ if (this.closing) throw new Error('Live HTML server is shutting down.');
250
295
  throw error;
251
296
  } finally {
297
+ signal?.removeEventListener('abort', lifetime.abort);
298
+ if (!session) { lifetime.abort(); this.lifetimes.delete(lifetime); }
252
299
  if (page) this.renderPages.delete(page);
253
300
  this.rendering -= 1;
254
301
  if (live) this.liveRendering -= 1;
@@ -256,9 +303,10 @@ class PageManager {
256
303
  }
257
304
  }
258
305
 
259
- createSession(page, ownsPage, principal) {
306
+ createSession(page, ownsPage, principal, context = {}, record, lifetime = this.createLifetime(principal)) {
260
307
  const id = randomUUID();
261
- const session = { id, page, ownsPage, principal, socket: null, timer: null, detaching: null };
308
+ const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null };
309
+ lifetime.session = session;
262
310
  this.pending.set(id, session);
263
311
  this.expire(session);
264
312
  return session;
@@ -282,28 +330,33 @@ class PageManager {
282
330
  if (typeof id !== 'string' || id.length > 128) return false;
283
331
  const session = this.pending.get(id) || this.active.get(id);
284
332
  if (!session || session.socket || session.detaching) return false;
285
- if (this.authenticateRequest) {
286
- const principal = await this.authenticateRequest(request);
333
+ try {
334
+ const principal = await this.identity.resolve(request, session.lifetime.signal);
287
335
  if (!Object.is(principal, session.principal)) return false;
336
+ await session.record?.metadata.policy?.check(session.context);
337
+ return this.available(session) && !session.socket && !session.detaching ? session : false;
338
+ } catch (error) {
339
+ if (error instanceof AuthenticationFailure || error instanceof AccessDenied) throw error;
340
+ throw new Error('Page upgrade failed.');
288
341
  }
289
- return !session.socket && !session.detaching && !LivePage.isDisposed(session.page) ? session : false;
290
342
  }
291
343
 
292
344
  acceptsOrigin(origin, request) {
293
345
  if (typeof origin !== 'string') return false;
346
+ let parsed;
294
347
  try {
295
- const parsed = new URL(origin);
296
- if (typeof this.origins === 'function') return this.origins(origin, request);
297
- if (this.origins) return this.origins.includes(parsed.origin);
298
- const protocol = request.socket?.encrypted ? 'https:' : 'http:';
299
- return parsed.protocol === protocol && parsed.host === request.headers.host;
348
+ parsed = new URL(origin);
300
349
  } catch {
301
350
  return false;
302
351
  }
352
+ if (typeof this.origins === 'function') return this.origins(origin, request);
353
+ if (this.origins) return this.origins.includes(parsed.origin);
354
+ const protocol = request.socket?.encrypted ? 'https:' : 'http:';
355
+ return parsed.protocol === protocol && parsed.host === request.headers.host;
303
356
  }
304
357
 
305
358
  connect(session, socket) {
306
- if (!session || session.socket || session.detaching || LivePage.isDisposed(session.page)) {
359
+ if (!session || !this.available(session) || session.socket || session.detaching) {
307
360
  throw new Error('Page session is unavailable.');
308
361
  }
309
362
  clearTimeout(session.timer);
@@ -311,32 +364,100 @@ class PageManager {
311
364
  this.active.set(session.id, session);
312
365
  session.socket = socket;
313
366
  socket.__redwebPageSession = session;
314
- return session.page._attach(socket, Object.freeze({ socket, signal: socket.context?.signal, principal: session.principal }));
367
+ session.connection = new PageLifetime(session.lifetime.signal);
368
+ socket.once?.('close', session.connection.abort);
369
+ socket.context?.signal?.addEventListener('abort', session.connection.abort, { once: true });
370
+ if (socket.context?.signal?.aborted || (socket.readyState !== undefined && socket.readyState !== 1)) session.connection.abort();
371
+ if (socket.context?.signal) setMaxListeners(this.maxSessions + 1, socket.context.signal);
372
+ return session.connection.wait(() => session.page._attach(socket, this.connectionContext(session, socket)))
373
+ .then(result => {
374
+ this.checkConnected(session, socket);
375
+ return session.renderer ? session.renderer.attach(socket, LivePage.snapshots(session.page, true)) : result;
376
+ });
377
+ }
378
+
379
+ available(session) {
380
+ return !session.lifetime.revoked && !LivePage.isDisposed(session.page) && (this.pending.get(session.id) === session || this.active.get(session.id) === session);
381
+ }
382
+
383
+ connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
384
+
385
+ checkConnected(session, socket) {
386
+ if (!this.available(session) || session.socket !== socket) throw new AccessDenied('ACCESS_CANCELLED');
387
+ session.connection.check();
388
+ }
389
+
390
+ async authorize(session, socket) {
391
+ this.checkConnected(session, socket);
392
+ await session.record?.metadata.policy?.check(this.connectionContext(session, socket));
393
+ this.checkConnected(session, socket);
315
394
  }
316
395
 
317
396
  async disconnect(socket) {
318
397
  const session = socket.__redwebPageSession;
319
398
  if (!session || session.socket !== socket) return false;
320
- const detaching = Promise.resolve(session.page._detach(socket, Object.freeze({ socket })))
399
+ session.connection.abort();
400
+ socket.off?.('close', session.connection.abort);
401
+ socket.context?.signal?.removeEventListener('abort', session.connection.abort);
402
+ session.renderer?.detach();
403
+ const detaching = Promise.resolve(session.page._detach(socket, this.connectionContext(session, socket)))
321
404
  .finally(() => { session.detaching = null; });
322
405
  session.detaching = detaching;
323
406
  session.socket = null;
324
- this.expire(session);
407
+ if (this.available(session)) this.expire(session);
325
408
  await detaching;
326
409
  return true;
327
410
  }
328
411
 
329
412
  async release(session) {
413
+ session.lifetime.revoked = true;
414
+ const cleanup = [];
415
+ if (session.socket) {
416
+ session.socket.terminate?.();
417
+ cleanup.push(this.disconnect(session.socket));
418
+ } else if (session.detaching) cleanup.push(session.detaching);
419
+ session.lifetime.abort();
420
+ this.lifetimes.delete(session.lifetime);
421
+ session.renderLifetime?.dispose();
330
422
  clearTimeout(session.timer);
331
423
  this.pending.delete(session.id);
332
424
  this.active.delete(session.id);
333
- if (session.ownsPage) await session.page.dispose();
425
+ if (session.ownsPage) cleanup.push(session.page.dispose());
426
+ LivePage._throwLifecycleFailures(await Promise.allSettled(cleanup), 'Page session cleanup failed.');
334
427
  return true;
335
428
  }
336
429
 
430
+ async revoke(principal) {
431
+ if (!isPrincipal(principal)) throw new TypeError('revoke() requires an authenticated primitive identity.');
432
+ const affected = [...this.lifetimes].filter(lifetime => Object.is(lifetime.principal, principal) || (this.authenticateRequest && lifetime.principal === undefined));
433
+ // Invalidate every publication target before dispatching any application abort listener.
434
+ affected.forEach(lifetime => { lifetime.revoked = true; });
435
+ affected.forEach(lifetime => lifetime.session?.socket?.terminate?.());
436
+ const cleanup = [];
437
+ for (const lifetime of affected) {
438
+ const session = lifetime.session;
439
+ if (session) {
440
+ cleanup.push(this.release(session));
441
+ } else {
442
+ lifetime.abort();
443
+ this.lifetimes.delete(lifetime);
444
+ if (lifetime.page) cleanup.push(lifetime.page.dispose());
445
+ }
446
+ }
447
+ if (!cleanup.length) return affected.length;
448
+ const result = await withinDeadline(Promise.allSettled(cleanup), Date.now() + this.shutdownTimeoutMs);
449
+ if (!result.completed || result.value.some(entry => entry.status === 'rejected')) {
450
+ const error = new Error('Page access revoked; application cleanup did not complete successfully.');
451
+ error.code = 'REVOCATION_CLEANUP_FAILED';
452
+ throw error;
453
+ }
454
+ return affected.length;
455
+ }
456
+
337
457
  async receive(socket, message) {
338
458
  const session = socket.__redwebPageSession;
339
459
  if (!session) throw new Error('Page session is not connected.');
460
+ this.checkConnected(session, socket);
340
461
  const payload = message.payload;
341
462
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('Live HTML payload must be an object.');
342
463
  const name = boundedName(payload.name, 'Live HTML member name');
@@ -345,17 +466,16 @@ class PageManager {
345
466
  : session.page._component(boundedName(payload.component, 'Live HTML component name'));
346
467
  if (!target) throw new Error('Unknown Live HTML component.');
347
468
  if (payload.kind === 'action') {
348
- const result = await LivePage.invoke(target, name, payload.args, Object.freeze({
349
- socket,
350
- signal: socket.context?.signal,
351
- principal: session.principal,
352
- }));
469
+ const result = await LivePage.invoke(target, name, payload.args, this.connectionContext(session, socket), () => this.authorize(session, socket));
470
+ this.checkConnected(session, socket);
353
471
  if (message.requestId !== undefined) {
354
472
  socket.sendEvent('redweb:result', result ?? null, { requestId: message.requestId });
355
473
  }
356
474
  return;
357
475
  }
358
476
  if (payload.kind === 'state') {
477
+ await this.authorize(session, socket);
478
+ this.checkConnected(session, socket);
359
479
  LivePage.setFromClient(target, name, payload.value);
360
480
  return;
361
481
  }
@@ -367,7 +487,14 @@ class PageManager {
367
487
  class LiveHtmlHandler extends BaseHandler {
368
488
  constructor() { super('redweb:html'); }
369
489
  onInitialContact(socket) { return manager.connect(socket.context.principal, socket); }
370
- onMessage(socket, message) { return manager.receive(socket, message); }
490
+ async onMessage(socket, message) {
491
+ try { return await manager.receive(socket, message); }
492
+ catch (error) {
493
+ const failure = RequestFailure.from(error);
494
+ if (!failure.code.startsWith('ACTION_') && !failure.code.startsWith('ACCESS_')) throw error;
495
+ socket.sendProtocolError(failure.code, failure.message, { requestId: message.requestId });
496
+ }
497
+ }
371
498
  }
372
499
  return class LiveHtmlRoute extends SocketRoute {
373
500
  constructor() {
@@ -432,4 +559,6 @@ class PageManager {
432
559
  }
433
560
  }
434
561
 
562
+ PageManager.prototype.Renderer = ReactiveRenderer;
563
+
435
564
  module.exports = { DEFAULT_PATHS, PROTOCOL_VERSION, PageManager };