redweb 0.16.2 → 0.16.4

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 (71) hide show
  1. package/CHANGELOG.md +40 -29
  2. package/README.md +293 -291
  3. package/contract.d.ts +11 -11
  4. package/docs/API_EXAMPLES_VERIFICATION.md +22 -22
  5. package/docs/APPLICATION.md +96 -94
  6. package/docs/CLI.md +122 -122
  7. package/docs/CLIENT_DEVELOPMENT.md +9 -9
  8. package/docs/CONNECTED_CLIENTS_VERIFICATION.md +65 -65
  9. package/docs/DEVELOPMENT.md +81 -81
  10. package/docs/GETTING_STARTED.md +78 -78
  11. package/docs/LIVE_HTML.md +555 -478
  12. package/docs/MIGRATION.md +28 -28
  13. package/docs/RELEASE_TRUST.md +88 -88
  14. package/docs/RUNTIME_DIAGNOSTICS.md +78 -78
  15. package/docs/SOCKET_CONTRACTS.md +42 -42
  16. package/docs/SOCKET_PAGES.md +172 -172
  17. package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -120
  18. package/docs/SOCKET_PAGE_VERIFICATION.md +85 -85
  19. package/docs/generated.json +2290 -2286
  20. package/docs/guides/chatroom.md +1 -1
  21. package/docs/guides/jsx-without-react.md +14 -14
  22. package/docs/reference.json +1333 -1329
  23. package/docs/releases/0.15.0.json +2217 -2217
  24. package/docs/releases/0.16.0.json +2217 -2217
  25. package/docs/releases/0.16.1.json +2286 -2286
  26. package/docs/releases/0.16.2.json +2286 -2286
  27. package/docs/releases/0.16.3.json +2286 -0
  28. package/docs/releases/0.16.4.json +2290 -0
  29. package/docs/snippets/components.tsx +24 -24
  30. package/docs/snippets/counter.tsx +16 -16
  31. package/docs/snippets/room-access.tsx +11 -11
  32. package/docs/snippets/site.css +2 -2
  33. package/docs/snippets/site.tsx +22 -22
  34. package/docs/topics.json +3 -3
  35. package/index.d.ts +96 -58
  36. package/index.js +14 -8
  37. package/package.json +12 -8
  38. package/recipes/foundation/README.md +7 -7
  39. package/recipes/foundation/app.test.cjs +15 -15
  40. package/recipes/foundation/app.tsx +12 -12
  41. package/recipes/shared/README.md +7 -7
  42. package/src/Application.js +4 -4
  43. package/src/access/failure-codes.json +4 -0
  44. package/src/cli/ProjectInitializer.js +1 -1
  45. package/src/cli/arguments.js +21 -21
  46. package/src/cli/run.js +12 -12
  47. package/src/cli/templates.js +40 -40
  48. package/src/docs/Documentation.js +29 -29
  49. package/src/htmx/CodeHighlight.js +98 -0
  50. package/src/htmx/Html.js +4 -3
  51. package/src/htmx/Jsx.js +2 -2
  52. package/src/htmx/LiveHtmlServer.js +5 -1
  53. package/src/htmx/LivePage.js +5 -1
  54. package/src/htmx/LiveResource.js +96 -0
  55. package/src/htmx/PageManager.js +126 -13
  56. package/src/htmx/PageSocketRoute.js +132 -132
  57. package/src/htmx/PageTaskLane.js +39 -0
  58. package/src/htmx/ReactiveRenderer.js +8 -8
  59. package/src/htmx/SocketAction.js +19 -19
  60. package/src/htmx/TemplateRenderer.js +1 -1
  61. package/src/htmx/index.js +4 -2
  62. package/src/htmx/metadata.js +117 -6
  63. package/src/ws/BaseHandler.js +6 -6
  64. package/src/ws/ConnectedClients.js +207 -207
  65. package/src/ws/HandlerGuard.js +4 -4
  66. package/src/ws/RoomRegistry.js +4 -4
  67. package/src/ws/RouteRuntime.js +11 -11
  68. package/src/ws/SocketAction.js +16 -16
  69. package/src/ws/SocketContract.js +3 -3
  70. package/src/ws/SocketRoute.js +7 -7
  71. package/styles/code-highlight.css +16 -0
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const PAGE_BINDINGS = new WeakMap();
4
+ const UNBOUND = Symbol('unbound');
5
+
6
+ function key(value) {
7
+ if (value === undefined || value === null || value === '') return UNBOUND;
8
+ if (typeof value === 'string') return value;
9
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
10
+ if (typeof value === 'bigint' || typeof value === 'boolean') return value;
11
+ throw new TypeError('Live resource keys must be non-empty strings, finite numbers, bigints, booleans, null, or undefined.');
12
+ }
13
+
14
+ function safeProperty(value) {
15
+ if (typeof value !== 'string' || !value || value.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(value)) {
16
+ throw new TypeError('Live resource properties must be safe non-empty names of at most 128 characters.');
17
+ }
18
+ return value;
19
+ }
20
+
21
+ class LiveResource {
22
+ constructor() {
23
+ this.subscribers = new Map();
24
+ }
25
+
26
+ publish(resourceKey, value) {
27
+ const resolved = key(resourceKey);
28
+ if (resolved === UNBOUND) throw new TypeError('Live resources cannot publish without a key.');
29
+ const subscribers = [...(this.subscribers.get(resolved) || [])];
30
+ subscribers.forEach(binding => {
31
+ if (!binding.active || binding.key !== resolved) return;
32
+ binding.page[binding.property] = value;
33
+ });
34
+ return subscribers.length;
35
+ }
36
+
37
+ bind(page, property, select) {
38
+ if (!page || typeof page !== 'object') throw new TypeError('Live resources bind to page instances.');
39
+ safeProperty(property);
40
+ if (typeof select !== 'function') throw new TypeError('Live resources require a key selector.');
41
+ const binding = { resource: this, page, property, select, key: UNBOUND, active: true };
42
+ const bindings = PAGE_BINDINGS.get(page) || new Set();
43
+ bindings.add(binding);
44
+ PAGE_BINDINGS.set(page, bindings);
45
+ try { this.refresh(binding); }
46
+ catch (error) {
47
+ this.release(binding);
48
+ throw error;
49
+ }
50
+ return binding;
51
+ }
52
+
53
+ refresh(binding) {
54
+ if (!binding.active) return false;
55
+ const next = key(binding.select(binding.page));
56
+ if (Object.is(next, binding.key)) return false;
57
+ if (binding.key !== UNBOUND) {
58
+ const subscribers = this.subscribers.get(binding.key);
59
+ subscribers?.delete(binding);
60
+ if (subscribers?.size === 0) this.subscribers.delete(binding.key);
61
+ }
62
+ binding.key = next;
63
+ if (next !== UNBOUND) {
64
+ const subscribers = this.subscribers.get(next) || new Set();
65
+ subscribers.add(binding);
66
+ this.subscribers.set(next, subscribers);
67
+ }
68
+ return true;
69
+ }
70
+
71
+ release(binding) {
72
+ if (!binding?.active) return false;
73
+ binding.active = false;
74
+ if (binding.key !== UNBOUND) {
75
+ const subscribers = this.subscribers.get(binding.key);
76
+ subscribers?.delete(binding);
77
+ if (subscribers?.size === 0) this.subscribers.delete(binding.key);
78
+ }
79
+ const bindings = PAGE_BINDINGS.get(binding.page);
80
+ bindings?.delete(binding);
81
+ if (bindings?.size === 0) PAGE_BINDINGS.delete(binding.page);
82
+ return true;
83
+ }
84
+
85
+ static refresh(page) {
86
+ PAGE_BINDINGS.get(page)?.forEach(binding => binding.resource.refresh(binding));
87
+ }
88
+
89
+ static release(page) {
90
+ [...(PAGE_BINDINGS.get(page) || [])].forEach(binding => binding.resource.release(binding));
91
+ }
92
+ }
93
+
94
+ function liveResource() { return new LiveResource(); }
95
+
96
+ module.exports = { LiveResource, liveResource };
@@ -1,4 +1,7 @@
1
1
  const { createHash, randomUUID } = require('crypto');
2
+ const { Transform } = require('stream');
3
+ const { finished } = require('stream/promises');
4
+ const PageTaskLane = require('./PageTaskLane');
2
5
  const { RequestFailure } = require('../access/RequestFailure');
3
6
  const path = require('path');
4
7
  const { setMaxListeners } = require('events');
@@ -10,7 +13,7 @@ const LivePage = require('./LivePage');
10
13
  const ReactiveRenderer = require('./ReactiveRenderer');
11
14
  const browserRuntime = require('./browserRuntime');
12
15
  const { isHtml, renderValue, trustedHtml } = require('./Html');
13
- const { getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot } = require('./metadata');
16
+ const { getActionImplementation, getInjectMetadata, getPageMetadata, getPageStylesheetRoots, getPageTemplateRoot, getUploadMetadata } = require('./metadata');
14
17
  const synchronous = require('./synchronous');
15
18
  const { AccessDenied } = require('../access/AccessPolicy');
16
19
  const { PageIdentity, AuthenticationFailure, isPrincipal } = require('./PageIdentity');
@@ -25,6 +28,7 @@ const DEFAULT_PATHS = Object.freeze({
25
28
  client: '/__redweb/client.js',
26
29
  runtime: '/__redweb/runtime.js',
27
30
  css: '/__redweb/css',
31
+ upload: '/__redweb/upload',
28
32
  });
29
33
 
30
34
  function boundedName(value, label) {
@@ -70,7 +74,7 @@ function matchesIfNoneMatch(header, etag) {
70
74
  }
71
75
 
72
76
  class PageManager {
73
- constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, logger = console }, reservedPaths = {}) {
77
+ constructor({ pages, templateRoot, paths = {}, sessionTtlMs = 30_000, maxSessions = 1000, maxConcurrentRenders = maxSessions, shutdownTimeoutMs = 1000, uploadTimeoutMs = 30_000, heartbeat = DEFAULT_HEARTBEAT, authenticate, authenticationTimeoutMs, origins, providers = {}, logger = console }, reservedPaths = {}) {
74
78
  if (!Array.isArray(pages) || pages.length === 0) throw new TypeError('`pages` must be a non-empty array.');
75
79
  if (templateRoot !== undefined && (typeof templateRoot !== 'string' || !templateRoot)) throw new TypeError('`templateRoot` must be a non-empty string.');
76
80
  if (!Number.isInteger(sessionTtlMs) || sessionTtlMs < 0) throw new TypeError('`sessionTtlMs` must be a non-negative integer.');
@@ -79,11 +83,18 @@ class PageManager {
79
83
  throw new TypeError('`maxConcurrentRenders` must be a positive integer.');
80
84
  }
81
85
  if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
86
+ if (!Number.isInteger(uploadTimeoutMs) || uploadTimeoutMs < 1 || uploadTimeoutMs > 300_000) throw new TypeError('`uploadTimeoutMs` must be an integer between 1 and 300000.');
82
87
  if (!paths || typeof paths !== 'object' || Array.isArray(paths)) throw new TypeError('`paths` must be an object.');
83
88
  if (origins !== undefined && typeof origins !== 'function' &&
84
89
  (!Array.isArray(origins) || origins.some(origin => typeof origin !== 'string' || !origin))) {
85
90
  throw new TypeError('`origins` must be a function or an array of non-empty origins.');
86
91
  }
92
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers) || Object.getPrototypeOf(providers) !== Object.prototype) {
93
+ throw new TypeError('`providers` must be a plain object.');
94
+ }
95
+ if (Object.keys(providers).some(name => !/^[A-Za-z_$][\w$-]{0,127}$/.test(name) || ['__proto__', 'prototype', 'constructor'].includes(name))) {
96
+ throw new TypeError('Provider names must be safe identifiers of at most 128 characters.');
97
+ }
87
98
  this.paths = { ...DEFAULT_PATHS, ...paths, ...reservedPaths };
88
99
  Object.entries(this.paths).forEach(([name, value]) => {
89
100
  internalPath(value, name);
@@ -103,8 +114,10 @@ class PageManager {
103
114
  this.maxSessions = maxSessions;
104
115
  this.maxConcurrentRenders = maxConcurrentRenders;
105
116
  this.shutdownTimeoutMs = shutdownTimeoutMs;
117
+ this.uploadTimeoutMs = uploadTimeoutMs;
106
118
  this.heartbeat = heartbeat;
107
119
  this.logger = logger || { log() {}, warn() {}, error() {} };
120
+ this.providers = Object.freeze({ ...providers });
108
121
  this.authenticateRequest = authenticate;
109
122
  this.identity = new PageIdentity(authenticate, authenticationTimeoutMs);
110
123
  this.lifetimes = new Set();
@@ -167,6 +180,11 @@ class PageManager {
167
180
  instantiate(record) {
168
181
  const instance = new record.PageClass();
169
182
  if (!(instance instanceof record.PageClass)) throw new TypeError('Page construction returned an incompatible object.');
183
+ getInjectMetadata(record.PageClass).forEach((provider, property) => {
184
+ if (!Object.hasOwn(this.providers, provider)) throw new Error(`Page requires missing provider "${provider}".`);
185
+ if (instance[property] !== undefined) throw new Error(`Injected property "${property}" must not have an initializer.`);
186
+ Object.defineProperty(instance, property, { configurable: false, enumerable: true, writable: false, value: this.providers[provider] });
187
+ });
170
188
  const page = LivePage.adopt(instance);
171
189
  page._activateState();
172
190
  return page;
@@ -177,6 +195,11 @@ class PageManager {
177
195
  const clientFile = path.join(path.dirname(require.resolve('redweb-client/live-html')), 'live-html.js');
178
196
  app.get(this.paths.client, (_request, response) => response.sendFile(clientFile));
179
197
  app.get(this.paths.runtime, (_request, response) => response.type('text/javascript').send(browserRuntime(this.paths.client)));
198
+ app.post(this.paths.upload, (request, response) => this.receiveUpload(request, response).catch(error => {
199
+ if (response.headersSent) return response.destroy();
200
+ const failure = RequestFailure.from(error, 'UPLOAD_FAILED');
201
+ response.status(failure.status).json({ error: { code: failure.code, message: failure.message } });
202
+ }));
180
203
  }
181
204
  this.stylesheets.forEach((content, url) => app.get(url, (_request, response) => {
182
205
  response.set('Cache-Control', 'public, max-age=31536000, immutable').type('text/css').send(content);
@@ -265,10 +288,10 @@ class PageManager {
265
288
  if (!isHtml(result)) throw new TypeError('Page layouts must return html.');
266
289
  return renderValue(result);
267
290
  };
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.');
291
+ const withContext = callback => require('./SocketAction').withRoute(record.socketHandlers,
292
+ () => LivePage.withRenderContext(context, callback));
293
+ const markup = await lifetime.wait(() => renderer ? renderer.initialize(render, withContext) : withContext(render));
294
+ if (record.metadata.socket && !renderer.enabled) throw new TypeError('Socket-bound pages must render TSX.');
272
295
  const document = this.createDocument(record, request);
273
296
  if (record.metadata.live === false) {
274
297
  const result = document(markup, null);
@@ -279,8 +302,9 @@ class PageManager {
279
302
  session.renderLifetime = renderer;
280
303
  const config = {
281
304
  pageId: session.id,
282
- socketPath: record.socketPath || this.paths.socket,
305
+ socketPath: record.socketPath || this.paths.socket,
283
306
  runtimePath: this.paths.runtime,
307
+ uploadPath: this.paths.upload,
284
308
  version: PROTOCOL_VERSION,
285
309
  };
286
310
  if (renderer.enabled) {
@@ -309,7 +333,8 @@ class PageManager {
309
333
 
310
334
  createSession(page, ownsPage, principal, context = {}, record, lifetime = this.createLifetime(principal)) {
311
335
  const id = randomUUID();
312
- const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null };
336
+ const session = { id, page, ownsPage, principal, context, record, lifetime, socket: null, timer: null, detaching: null,
337
+ tasks: new PageTaskLane() };
313
338
  lifetime.session = session;
314
339
  this.pending.set(id, session);
315
340
  this.expire(session);
@@ -324,7 +349,7 @@ class PageManager {
324
349
  session.timer.unref?.();
325
350
  }
326
351
 
327
- async authenticate(request, RouteClass) {
352
+ async authenticate(request, RouteClass) {
328
353
  let id;
329
354
  try {
330
355
  id = new URL(request.url, `http://${request.headers.host || 'localhost'}`).searchParams.get('pageId');
@@ -332,9 +357,9 @@ class PageManager {
332
357
  return false;
333
358
  }
334
359
  if (typeof id !== 'string' || id.length > 128) 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;
360
+ const session = this.pending.get(id) || this.active.get(id);
361
+ if (!session || session.socket || session.detaching) return false;
362
+ if (session.record?.metadata.socket !== RouteClass) return false;
338
363
  try {
339
364
  const principal = await this.identity.resolve(request, session.lifetime.signal);
340
365
  if (!Object.is(principal, session.principal)) return false;
@@ -388,7 +413,7 @@ class PageManager {
388
413
  connectionContext(session, socket) { return Object.freeze({ ...session.context, socket, signal: session.connection.signal, principal: session.principal }); }
389
414
 
390
415
  checkConnected(session, socket) {
391
- if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
416
+ if (!this.available(session) || session.socket !== socket || (socket.readyState !== undefined && socket.readyState !== 1)) throw new AccessDenied('ACCESS_CANCELLED');
392
417
  session.connection.check();
393
418
  }
394
419
 
@@ -416,6 +441,7 @@ class PageManager {
416
441
 
417
442
  async release(session) {
418
443
  session.lifetime.revoked = true;
444
+ session.tasks.close(new AccessDenied('ACCESS_CANCELLED'));
419
445
  const cleanup = [];
420
446
  if (session.socket) {
421
447
  session.socket.terminate?.();
@@ -459,9 +485,19 @@ class PageManager {
459
485
  return affected.length;
460
486
  }
461
487
 
488
+ enqueue(session, task) {
489
+ const result = session.tasks.enqueue(task);
490
+ if (!result) throw new RequestFailure('ACCESS_CAPACITY');
491
+ return result;
492
+ }
493
+
462
494
  async receive(socket, message) {
463
495
  const session = socket.__redwebPageSession;
464
496
  if (!session) throw new Error('Page session is not connected.');
497
+ return this.enqueue(session, () => this.receiveMessage(session, socket, message));
498
+ }
499
+
500
+ async receiveMessage(session, socket, message) {
465
501
  this.checkConnected(session, socket);
466
502
  const payload = message.payload;
467
503
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('Live HTML payload must be an object.');
@@ -487,6 +523,83 @@ class PageManager {
487
523
  throw new TypeError('Live HTML message kind must be "action" or "state".');
488
524
  }
489
525
 
526
+ async receiveUpload(request, response) {
527
+ const address = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
528
+ const id = address.searchParams.get('pageId');
529
+ const name = boundedName(address.searchParams.get('action'), 'Upload action name');
530
+ const component = address.searchParams.get('component');
531
+ const session = typeof id === 'string' ? this.pending.get(id) || this.active.get(id) : null;
532
+ if (!session || !this.available(session)) throw new AccessDenied('ACCESS_DENIED');
533
+ if (request.headers.origin !== undefined && !await this.acceptsOrigin(request.headers.origin, request)) throw new RequestFailure('ORIGIN_DENIED');
534
+ if (request.headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(request.headers['sec-fetch-site'])) {
535
+ throw new RequestFailure('ORIGIN_DENIED');
536
+ }
537
+ return this.enqueue(session, () => this.receiveUploadTask(session, request, response, name, component));
538
+ }
539
+
540
+ async receiveUploadTask(session, request, response, name, component) {
541
+ session.lifetime.check();
542
+ let stream;
543
+ let timer;
544
+ let cancelled = request.destroyed || response.destroyed || session.lifetime.signal.aborted;
545
+ const cancel = () => { cancelled = true; stream?.destroy(new AccessDenied('ACCESS_CANCELLED')); };
546
+ request.once('aborted', cancel);
547
+ request.once('error', cancel);
548
+ response.once('close', cancel);
549
+ session.lifetime.signal.addEventListener('abort', cancel, { once: true });
550
+ try {
551
+ if (cancelled) throw new AccessDenied('ACCESS_CANCELLED');
552
+ const principal = await this.identity.resolve(request, session.lifetime.signal);
553
+ if (!Object.is(principal, session.principal)) throw new AccessDenied('ACCESS_DENIED');
554
+ const snapshot = requestSnapshot(request);
555
+ const context = Object.freeze({ ...session.context, request: snapshot, params: snapshot.params, query: snapshot.query,
556
+ body: snapshot.body, principal, signal: session.lifetime.signal });
557
+ await session.record.metadata.policy?.check(context);
558
+ session.lifetime.check();
559
+ if (cancelled || request.destroyed || response.destroyed) throw new AccessDenied('ACCESS_CANCELLED');
560
+ const target = component === null || component === '' ? session.page : session.page._component(boundedName(component, 'Upload component name'));
561
+ if (!target) throw new AccessDenied('ACCESS_DENIED');
562
+ const config = getUploadMetadata(target.constructor).get(name);
563
+ const implementation = getActionImplementation(target.constructor, name);
564
+ if (!config || !implementation || target[name] !== implementation) throw new AccessDenied('ACCESS_DENIED');
565
+ const contentLength = Number(request.headers['content-length']);
566
+ if (Number.isFinite(contentLength) && contentLength > config.maxBytes) throw new RequestFailure('UPLOAD_TOO_LARGE');
567
+ const contentType = String(request.headers['content-type'] || '').split(';', 1)[0].toLowerCase();
568
+ if (config.accept.length && !config.accept.some(type => type === contentType || type.endsWith('/*') && contentType.startsWith(type.slice(0, -1)))) {
569
+ throw new RequestFailure('UPLOAD_TYPE_REJECTED');
570
+ }
571
+ let bytes = 0;
572
+ stream = request.pipe(new Transform({ transform(chunk, _encoding, done) {
573
+ bytes += chunk.length;
574
+ if (bytes > config.maxBytes) return done(new RequestFailure('UPLOAD_TOO_LARGE'));
575
+ done(null, chunk);
576
+ } }));
577
+ stream.once('error', () => {});
578
+ timer = setTimeout(() => stream.destroy(new RequestFailure('UPLOAD_TIMEOUT')), this.uploadTimeoutMs);
579
+ timer.unref?.();
580
+ const uploadedName = request.headers['x-redweb-upload-name'];
581
+ let decodedName = null;
582
+ if (typeof uploadedName === 'string' && uploadedName.length > 0 && uploadedName.length <= 768) {
583
+ try { decodedName = decodeURIComponent(uploadedName); }
584
+ catch { decodedName = null; }
585
+ }
586
+ const file = Object.freeze({ stream, type: contentType,
587
+ name: decodedName && decodedName.length <= 256 ? decodedName : null });
588
+ await implementation.call(target, file, context);
589
+ if (!stream.readableEnded) {
590
+ stream.resume();
591
+ await finished(stream);
592
+ }
593
+ response.status(204).end();
594
+ } finally {
595
+ request.off('aborted', cancel);
596
+ request.off('error', cancel);
597
+ response.off('close', cancel);
598
+ session.lifetime.signal.removeEventListener('abort', cancel);
599
+ clearTimeout(timer);
600
+ }
601
+ }
602
+
490
603
  route() {
491
604
  const manager = this;
492
605
  class LiveHtmlHandler extends BaseHandler {
@@ -1,132 +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 };
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 };