redweb 0.12.0 → 0.13.1

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 (169) hide show
  1. package/CHANGELOG.md +170 -9
  2. package/README.md +283 -629
  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 +778 -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 +12 -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 +61 -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/releases/0.13.0.json +2154 -0
  66. package/docs/releases/audit-0.13.0.json +1549 -0
  67. package/docs/snippets/room-access.tsx +51 -0
  68. package/docs/topics.json +21 -0
  69. package/examples/live-html/chatroom.js +207 -268
  70. package/examples/live-html/chatroom.tsx +167 -0
  71. package/examples/live-html/jsx-page.js +1 -1
  72. package/examples/live-html/jsx-page.tsx +1 -1
  73. package/examples/live-html/tsconfig.json +8 -7
  74. package/index.d.ts +170 -45
  75. package/index.js +2 -0
  76. package/jsx-dev-runtime.js +2 -2
  77. package/jsx-runtime.d.ts +7 -2
  78. package/package.json +88 -7
  79. package/recipes/add/artifact.test.cjs +57 -0
  80. package/recipes/add/live.tsx +18 -0
  81. package/recipes/add/socket-route.ts +24 -0
  82. package/recipes/chat/README.md +22 -0
  83. package/recipes/chat/app.test.cjs +105 -0
  84. package/recipes/chat/app.tsx +9 -0
  85. package/recipes/dashboard/README.md +43 -0
  86. package/recipes/dashboard/admin.ts +21 -0
  87. package/recipes/dashboard/app.css +16 -0
  88. package/recipes/dashboard/app.test.cjs +450 -0
  89. package/recipes/dashboard/app.tsx +86 -0
  90. package/recipes/dashboard/auth.ts +80 -0
  91. package/recipes/dashboard/cards.tsx +102 -0
  92. package/recipes/dashboard/rate-window.test.cjs +17 -0
  93. package/recipes/dashboard/store.ts +120 -0
  94. package/recipes/http-ws/README.md +11 -0
  95. package/recipes/http-ws/app.test.cjs +92 -0
  96. package/recipes/http-ws/app.tsx +36 -0
  97. package/recipes/realtime/README.md +8 -0
  98. package/recipes/realtime/app.test.cjs +15 -0
  99. package/recipes/realtime/app.tsx +28 -0
  100. package/recipes/shared/README.md +40 -0
  101. package/recipes/shared/app.css +8 -0
  102. package/recipes/shared/copy-assets.cjs +8 -0
  103. package/recipes/shared/network.cjs +59 -0
  104. package/recipes/shared/run-app.test.cjs +158 -0
  105. package/recipes/shared/run-app.ts +50 -0
  106. package/recipes/site/README.md +4 -0
  107. package/recipes/site/app.test.cjs +19 -0
  108. package/recipes/site/app.tsx +25 -0
  109. package/recipes/socket/README.md +39 -0
  110. package/recipes/socket/app.test.cjs +85 -0
  111. package/recipes/socket/app.tsx +30 -0
  112. package/recipes/socket/contract.ts +12 -0
  113. package/recipes/socket/handlers.ts +40 -0
  114. package/src/OwnedServerLifecycle.js +66 -0
  115. package/src/access/AccessPolicy.js +37 -0
  116. package/src/access/AuthenticationFailure.js +13 -0
  117. package/src/access/RequestFailure.js +33 -0
  118. package/src/access/failure-codes.json +25 -0
  119. package/src/async/BoundedOperation.js +62 -0
  120. package/src/cli/ActionReferences.js +193 -0
  121. package/src/cli/AdditionLayout.js +140 -0
  122. package/src/cli/FilePlan.js +94 -0
  123. package/src/cli/ProjectAddition.js +60 -0
  124. package/src/cli/ProjectConfig.js +26 -0
  125. package/src/cli/ProjectDoctor.js +112 -0
  126. package/src/cli/ProjectInitializer.js +20 -30
  127. package/src/cli/SourceInspector.js +207 -0
  128. package/src/cli/StaticSource.js +192 -0
  129. package/src/cli/arguments.js +62 -0
  130. package/src/cli/formatCommand.js +10 -0
  131. package/src/cli/run.js +57 -0
  132. package/src/cli/templates.js +86 -87
  133. package/src/context/RequestSnapshot.js +41 -0
  134. package/src/dataProperty.js +11 -0
  135. package/src/development/DevelopmentPageManager.js +48 -0
  136. package/src/development/Inspection.js +104 -0
  137. package/src/development/ObservedRenderer.js +42 -0
  138. package/src/development/description.js +35 -0
  139. package/src/development/loopbackRequest.js +27 -0
  140. package/src/development/refreshBrowser.js +96 -0
  141. package/src/development/refreshStyles.js +9 -0
  142. package/src/development/settings.js +17 -0
  143. package/src/docs/Documentation.js +182 -0
  144. package/src/htmx/ActionDefinition.js +44 -0
  145. package/src/htmx/Jsx.js +24 -8
  146. package/src/htmx/LiveHtmlServer.js +41 -19
  147. package/src/htmx/LivePage.js +63 -13
  148. package/src/htmx/PageIdentity.js +32 -0
  149. package/src/htmx/PageLifetime.js +37 -0
  150. package/src/htmx/PageManager.js +203 -74
  151. package/src/htmx/ReactiveRenderer.js +241 -0
  152. package/src/htmx/StaticExporter.js +1 -1
  153. package/src/htmx/TemplateRenderer.js +13 -7
  154. package/src/htmx/browserRuntime.js +2 -93
  155. package/src/htmx/metadata.js +19 -7
  156. package/src/validation/ActionInputError.js +12 -0
  157. package/src/validation/SchemaValidator.js +38 -0
  158. package/src/ws/AdmissionPolicy.js +24 -23
  159. package/src/ws/BaseSocketServer.js +53 -38
  160. package/src/ws/ContractValidationError.js +12 -0
  161. package/src/ws/HeartbeatMonitor.js +26 -8
  162. package/src/ws/ProtocolPolicy.js +1 -1
  163. package/src/ws/RoomAccess.js +82 -0
  164. package/src/ws/RoomRegistry.js +56 -6
  165. package/src/ws/RouteRuntime.js +56 -10
  166. package/src/ws/SocketContract.js +112 -0
  167. package/src/ws/SocketRoute.js +18 -0
  168. package/src/ws/protocol-schema.json +6 -1
  169. package/examples/live-html/chatroom.ts +0 -217
@@ -7,8 +7,11 @@
7
7
  */
8
8
 
9
9
  const DefaultRoute = require('./DefaultRoute');
10
+ const { createInspection } = require('../development/Inspection');
10
11
  const { PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT } = require('./AdmissionPolicy');
11
12
  const { PROTOCOL_REJECTION } = require('./ProtocolPolicy');
13
+ const { RequestFailure, UPGRADE_REJECTION } = require('../access/RequestFailure');
14
+ const OwnedServerLifecycle = require('../OwnedServerLifecycle');
12
15
  const {
13
16
  listenServer,
14
17
  closeServer,
@@ -39,6 +42,7 @@ class BaseSocketServer {
39
42
  */
40
43
  constructor(server, options = {}, ownsServer = false, name = 'SocketServer') {
41
44
  if (!server || typeof server.on !== 'function') throw new TypeError('A Node HTTP(S) server is required.');
45
+ this._inspection = createInspection(options.development);
42
46
  Object.assign(this, { ...SOCKET_OPTIONS, ...options });
43
47
  validateListenerOptions(this);
44
48
  if (!Array.isArray(this.routes)) throw new TypeError('`routes` must be an array.');
@@ -47,12 +51,9 @@ class BaseSocketServer {
47
51
  this.closeServerOnShutdown = options.closeServerOnShutdown ?? ownsServer;
48
52
  this.draining = false;
49
53
  this.pendingUpgrades = new Map();
50
- this.rawConnections = this.closeServerOnShutdown ? new Set() : null;
51
- this._connectionHandler = this.rawConnections ? socket => {
52
- this.rawConnections.add(socket);
53
- socket.once('close', () => this.rawConnections.delete(socket));
54
- } : null;
55
- if (this._connectionHandler) this.server.on('connection', this._connectionHandler);
54
+ this._ownedServer = this.closeServerOnShutdown ? new OwnedServerLifecycle(server) : null;
55
+ this.rawConnections = this._ownedServer?.connections ?? null;
56
+ this._connectionHandler = this._ownedServer?.onConnection ?? null;
56
57
 
57
58
  /* ─── ROUTE INITIALISATION ─────────────────────────── */
58
59
  const RouteClasses = options.routes?.length ? [...options.routes] : [DefaultRoute];
@@ -67,6 +68,7 @@ class BaseSocketServer {
67
68
  this.routes.push(route);
68
69
  }
69
70
  } catch (error) {
71
+ this._ownedServer?.dispose();
70
72
  this.disposeRoutes(this.routes);
71
73
  throw error;
72
74
  }
@@ -75,14 +77,21 @@ class BaseSocketServer {
75
77
  this.server.on('upgrade', this._upgradeHandler);
76
78
 
77
79
  const shouldListen = (ownsServer && this.listen !== false) || (!ownsServer && options.listen === true);
78
- if (shouldListen) {
79
- listenServer(this.server, {
80
- port: this.port,
81
- bind: this.bind,
82
- callback: this.listenCallback,
83
- logger: this.logger,
84
- name,
85
- });
80
+ try {
81
+ if (shouldListen) {
82
+ listenServer(this.server, {
83
+ port: this.port,
84
+ bind: this.bind,
85
+ callback: this.listenCallback,
86
+ logger: this.logger,
87
+ name,
88
+ });
89
+ }
90
+ } catch (error) {
91
+ this.server.off('upgrade', this._upgradeHandler);
92
+ this._ownedServer?.dispose();
93
+ this.disposeRoutes(this.routes);
94
+ throw error;
86
95
  }
87
96
  }
88
97
 
@@ -115,37 +124,41 @@ class BaseSocketServer {
115
124
  (this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
116
125
 
117
126
  if (!route) return sock.destroy();
118
- if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
119
- if (route.isReady?.() === false) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
127
+ if (this.draining) return this.rejectFailure(sock, 'SERVER_DRAINING');
128
+ if (route.isReady?.() === false) return this.rejectFailure(sock, 'ROUTE_UNAVAILABLE');
129
+
130
+ try { route.runtime.prepareRequest(req); }
131
+ catch { return this.rejectFailure(sock, 'REQUEST_INVALID'); }
120
132
 
121
133
  if (route.admissionPolicy || route.protocolPolicy || route.transportPolicy && route.transportPolicy.maxConnections !== Infinity) {
122
134
  let reservation;
123
135
  try {
124
136
  reservation = route.reserveUpgrade(req);
125
- } catch (error) {
126
- this.logger?.error?.('WebSocket admission reservation failed:', error);
137
+ } catch {
138
+ this.logAdmissionFailure('WebSocket admission reservation failed:');
139
+ return this.rejectFailure(sock, 'ADMISSION_FAILED');
127
140
  }
128
- if (!reservation) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
141
+ if (!reservation) return this.rejectFailure(sock, 'ADMISSION_CAPACITY');
129
142
  const controller = new AbortController();
130
143
  this.pendingUpgrades.set(sock, { controller, route, reservation });
131
144
  void Promise.resolve()
132
145
  .then(() => route.authorizeUpgrade(req, sock, controller.signal))
133
146
  .then(accepted => {
134
147
  if (sock.destroyed) return;
135
- if (this.draining) return this.rejectUpgrade(sock, 503, 'Service Unavailable');
148
+ if (this.draining) return this.rejectFailure(sock, 'SERVER_DRAINING');
136
149
  if (!accepted) {
137
150
  const redirect = req[PLACEMENT_REDIRECT];
138
- if (redirect) return this.rejectUpgrade(sock, 307, 'Temporary Redirect', { Location: redirect });
139
- const rejection = req[PROTOCOL_REJECTION];
151
+ if (redirect) return this.rejectUpgrade(sock, 307, 'Temporary Redirect', { Location: redirect, 'Cache-Control': 'no-store' });
152
+ const rejection = req[UPGRADE_REJECTION] || req[PROTOCOL_REJECTION];
140
153
  return rejection
141
154
  ? this.rejectUpgrade(sock, rejection.statusCode, rejection.statusText, rejection.headers)
142
- : this.rejectUpgrade(sock, 401, 'Unauthorized');
155
+ : this.rejectFailure(sock, 'AUTHENTICATION_REQUIRED');
143
156
  }
144
157
  this.completeUpgrade(route, req, sock, head);
145
158
  })
146
- .catch(error => {
147
- this.logger?.error?.('WebSocket admission failed:', error);
148
- if (!sock.destroyed) this.rejectUpgrade(sock, 401, 'Unauthorized');
159
+ .catch(() => {
160
+ this.logAdmissionFailure('WebSocket admission failed:');
161
+ if (!sock.destroyed) this.rejectFailure(sock, 'ADMISSION_FAILED');
149
162
  })
150
163
  .finally(async () => {
151
164
  await req[ADMISSION_SETTLEMENT];
@@ -164,6 +177,16 @@ class BaseSocketServer {
164
177
  );
165
178
  }
166
179
 
180
+ logAdmissionFailure(message) {
181
+ try { this.logger?.error?.(message, new RequestFailure('ADMISSION_FAILED')); }
182
+ catch { /* A failing application logger must not prevent rejection or cleanup. */ }
183
+ }
184
+
185
+ rejectFailure(socket, code) {
186
+ const { statusCode, statusText, headers } = new RequestFailure(code).rejection;
187
+ return this.rejectUpgrade(socket, statusCode, statusText, headers);
188
+ }
189
+
167
190
  rejectUpgrade(socket, statusCode, statusText, headers = {}) {
168
191
  try {
169
192
  const extraHeaders = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join('');
@@ -204,6 +227,8 @@ class BaseSocketServer {
204
227
  return !this.draining && this.routes.every(route => route.isReady?.() !== false);
205
228
  }
206
229
 
230
+ inspect() { return this._inspection ? this._inspection.snapshot(this) : null; }
231
+
207
232
  beginDrain() {
208
233
  if (this.draining) return false;
209
234
  this.draining = true;
@@ -217,28 +242,18 @@ class BaseSocketServer {
217
242
  this.server.off?.('upgrade', this._upgradeHandler);
218
243
  const deadline = Date.now() + Math.max(0, ...this.routes.map(route => route.shutdownTimeoutMs));
219
244
  const errors = await settleTasks(this.routes.map(route => () => route.shutdown?.()));
220
- if (this.closeServerOnShutdown && this.server.listening) {
245
+ if (this.closeServerOnShutdown) {
221
246
  try {
222
247
  await this.closeOwnedServer(Math.max(0, deadline - Date.now()));
223
248
  } catch (error) {
224
249
  errors.push(error);
225
250
  }
226
251
  }
227
- if (this._connectionHandler) this.server.off?.('connection', this._connectionHandler);
228
252
  throwCleanupErrors(errors, 'One or more WebSocket server cleanup operations failed.');
229
253
  }
230
254
 
231
255
  closeOwnedServer(timeoutMs) {
232
- let timer;
233
- const closing = closeServer(this.server);
234
- const timeout = new Promise(resolve => {
235
- timer = setTimeout(() => {
236
- this.rawConnections?.forEach(socket => socket.destroy?.());
237
- resolve();
238
- }, timeoutMs);
239
- timer.unref?.();
240
- });
241
- return Promise.race([closing, timeout]).finally(() => clearTimeout(timer));
256
+ return this._ownedServer.close(timeoutMs, () => closeServer(this.server));
242
257
  }
243
258
  }
244
259
 
@@ -0,0 +1,12 @@
1
+ class ContractValidationError extends TypeError {
2
+ constructor(code = 'INVALID_PAYLOAD') {
3
+ super(code === 'UNKNOWN_HANDLER' ? 'Message type is not defined in the contract.' : 'Payload does not match the socket contract.');
4
+ this.name = 'ContractValidationError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ // Only errors from the inbound validation stage should be reported as a bad client payload.
10
+ class InboundContractValidationError extends ContractValidationError {}
11
+
12
+ module.exports = { ContractValidationError, InboundContractValidationError };
@@ -2,7 +2,12 @@ const { performance } = require('perf_hooks');
2
2
 
3
3
  function acknowledgePong() {
4
4
  const state = this.__redwebHeartbeatState;
5
- if (state) state.awaitingPong = false;
5
+ if (!state) return;
6
+ state.awaitingPong = false;
7
+ if (state.terminationTimer) {
8
+ clearTimeout(state.terminationTimer);
9
+ state.terminationTimer = null;
10
+ }
6
11
  }
7
12
 
8
13
  class HeartbeatMonitor {
@@ -25,7 +30,7 @@ class HeartbeatMonitor {
25
30
 
26
31
  attach(socket) {
27
32
  this.detach(socket);
28
- const state = { awaitingPong: false, lastPing: null };
33
+ const state = { awaitingPong: false, lastPing: null, terminationTimer: null };
29
34
  socket.__redwebHeartbeatState = state;
30
35
  this.sockets.set(socket, state);
31
36
  socket.on('pong', acknowledgePong);
@@ -34,6 +39,8 @@ class HeartbeatMonitor {
34
39
  detach(socket) {
35
40
  const state = this.sockets.get(socket);
36
41
  if (!state) return false;
42
+ if (state.terminationTimer) clearTimeout(state.terminationTimer);
43
+ state.terminationTimer = null;
37
44
  socket.off?.('pong', acknowledgePong);
38
45
  delete socket.__redwebHeartbeatState;
39
46
  this.sockets.delete(socket);
@@ -44,12 +51,7 @@ class HeartbeatMonitor {
44
51
  const now = this.clock();
45
52
  this.sockets.forEach((state, socket) => {
46
53
  if (state.awaitingPong && now - state.lastPing >= this.timeoutMs) {
47
- this.detach(socket);
48
- try {
49
- socket.terminate?.();
50
- } catch (error) {
51
- this.logger?.error?.('Error terminating unresponsive socket:', error);
52
- }
54
+ this.scheduleTermination(socket, state);
53
55
  return;
54
56
  }
55
57
  if (!state.awaitingPong && (state.lastPing === null || now - state.lastPing >= this.intervalMs)) {
@@ -64,6 +66,22 @@ class HeartbeatMonitor {
64
66
  });
65
67
  }
66
68
 
69
+ scheduleTermination(socket, state) {
70
+ if (state.terminationTimer) return;
71
+ // Give an already-sent pong one full timeout window to reach JavaScript.
72
+ // This prevents a delayed server event loop from blaming a responsive peer.
73
+ state.terminationTimer = setTimeout(() => {
74
+ state.terminationTimer = null;
75
+ this.detach(socket);
76
+ try {
77
+ socket.terminate?.();
78
+ } catch (error) {
79
+ this.logger?.error?.('Error terminating unresponsive socket:', error);
80
+ }
81
+ }, this.timeoutMs);
82
+ state.terminationTimer.unref();
83
+ }
84
+
67
85
  stop() {
68
86
  if (!this.timer) return;
69
87
  clearInterval(this.timer);
@@ -77,7 +77,7 @@ class ProtocolPolicy {
77
77
  request[PROTOCOL_REJECTION] = {
78
78
  statusCode: 426,
79
79
  statusText: 'Upgrade Required',
80
- headers: { 'Redweb-Versions': this.versions.join(', ') },
80
+ headers: { 'Redweb-Versions': this.versions.join(', '), 'Redweb-Error': 'PROTOCOL_UNSUPPORTED', 'Cache-Control': 'no-store' },
81
81
  message,
82
82
  };
83
83
  return false;
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { AccessPolicy, AccessDenied } = require('../access/AccessPolicy');
4
+
5
+ /** Admission work is charged until the underlying policy settles, even after timeout. */
6
+ class RoomAccess {
7
+ constructor(options, contextFor) {
8
+ const { authorize, authorizationTimeoutMs, maxPendingAuthorizations = 128, maxPendingPerConnection = 4 } = options;
9
+ for (const [name, value] of Object.entries({ maxPendingAuthorizations, maxPendingPerConnection })) {
10
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`rooms.${name} must be a positive safe integer.`);
11
+ }
12
+ if (typeof authorize !== 'function') throw new TypeError('rooms.authorize must be a function.');
13
+ this.policy = new AccessPolicy(async (context, attempt) => {
14
+ attempt.started = true;
15
+ try { return await authorize(context, attempt.roomId); }
16
+ finally { attempt.release(); }
17
+ }, authorizationTimeoutMs);
18
+ this.contextFor = contextFor;
19
+ this.maximum = maxPendingAuthorizations;
20
+ this.perConnection = maxPendingPerConnection;
21
+ this.running = 0;
22
+ this.counts = new WeakMap();
23
+ this.pending = new Map();
24
+ this.cancelling = new Set();
25
+ }
26
+
27
+ enter(roomId, socket, commit) {
28
+ if (this.cancelling.has(socket)) return Promise.reject(new AccessDenied('ACCESS_CANCELLED'));
29
+ const existing = this.pending.get(socket)?.get(roomId);
30
+ if (existing) return existing.promise;
31
+ const count = this.counts.get(socket) || 0;
32
+ if (this.running >= this.maximum || count >= this.perConnection) return Promise.reject(new AccessDenied('ACCESS_CAPACITY'));
33
+ const context = this.contextFor(socket);
34
+ if (!context?.signal || context.signal.aborted) return Promise.reject(new AccessDenied('ACCESS_CANCELLED'));
35
+ const controller = new AbortController();
36
+ const attempt = { roomId, controller, started: false, release: () => {
37
+ this.running--;
38
+ this.counts.set(socket, this.counts.get(socket) - 1);
39
+ } };
40
+ this.running++;
41
+ this.counts.set(socket, count + 1);
42
+ const group = this.pending.get(socket) || new Map();
43
+ group.set(roomId, attempt);
44
+ this.pending.set(socket, group);
45
+ const abort = () => controller.abort();
46
+ context.signal.addEventListener('abort', abort, { once: true });
47
+ attempt.promise = (async () => {
48
+ try {
49
+ await this.policy.check({ ...context, signal: controller.signal }, attempt);
50
+ if (controller.signal.aborted) throw new AccessDenied('ACCESS_CANCELLED');
51
+ return commit();
52
+ } finally {
53
+ context.signal.removeEventListener('abort', abort);
54
+ if (!attempt.started) attempt.release();
55
+ if (this.pending.get(socket)?.get(roomId) === attempt) {
56
+ group.delete(roomId);
57
+ if (!group.size) this.pending.delete(socket);
58
+ }
59
+ }
60
+ })();
61
+ return attempt.promise;
62
+ }
63
+
64
+ cancel(socket, roomId) {
65
+ const group = this.pending.get(socket);
66
+ if (!group) return;
67
+ const attempts = [...group.values()].filter(attempt => roomId === undefined || attempt.roomId === roomId);
68
+ if (!attempts.length) return;
69
+ for (const attempt of attempts) group.delete(attempt.roomId);
70
+ if (!group.size) this.pending.delete(socket);
71
+ const nested = this.cancelling.has(socket);
72
+ this.cancelling.add(socket);
73
+ try { for (const attempt of attempts) attempt.controller.abort(); }
74
+ finally { if (!nested) this.cancelling.delete(socket); }
75
+ }
76
+
77
+ clear() {
78
+ for (const socket of [...this.pending.keys()]) this.cancel(socket);
79
+ }
80
+ }
81
+
82
+ module.exports = RoomAccess;
@@ -1,7 +1,8 @@
1
- const { broadcast } = require('./util');
1
+ const { broadcast, sendPayload } = require('./util');
2
+ const RoomAccess = require('./RoomAccess');
2
3
 
3
4
  class RoomRegistry {
4
- constructor(options = {}, { hasConnection, policy, onChange } = {}) {
5
+ constructor(options = {}, { hasConnection, policy, onChange, contextFor = socket => socket.context } = {}) {
5
6
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
6
7
  throw new TypeError('`rooms` must be an object or true.');
7
8
  }
@@ -27,6 +28,12 @@ class RoomRegistry {
27
28
  this.rooms = new Map();
28
29
  this.memberships = new WeakMap();
29
30
  this.closed = false;
31
+ this.clearing = false;
32
+ if (typeof contextFor !== 'function') throw new TypeError('contextFor must be a function.');
33
+ if (options.authorize === undefined && ['authorizationTimeoutMs', 'maxPendingAuthorizations', 'maxPendingPerConnection'].some(name => options[name] !== undefined)) {
34
+ throw new TypeError('Room authorization options require authorize.');
35
+ }
36
+ this.access = options.authorize === undefined ? null : new RoomAccess(options, contextFor);
30
37
  }
31
38
 
32
39
  validateRoomId(roomId) {
@@ -37,7 +44,18 @@ class RoomRegistry {
37
44
 
38
45
  join(roomId, socket) {
39
46
  this.validateRoomId(roomId);
40
- if (this.closed) return false;
47
+ if (this.access) throw new TypeError('Protected rooms require await enterRoom(roomId) or rooms.enter(roomId, socket).');
48
+ return this.#commit(roomId, socket);
49
+ }
50
+
51
+ enter(roomId, socket) {
52
+ this.validateRoomId(roomId);
53
+ if (this.closed || this.clearing || !socket || !this.hasConnection(socket)) return Promise.resolve(false);
54
+ return this.access ? this.access.enter(roomId, socket, () => this.#commit(roomId, socket)) : Promise.resolve(this.#commit(roomId, socket));
55
+ }
56
+
57
+ #commit(roomId, socket) {
58
+ if (this.closed || this.clearing) return false;
41
59
  if (!socket || !this.hasConnection(socket)) return false;
42
60
  let members = this.rooms.get(roomId);
43
61
  if (members?.has(socket)) return true;
@@ -58,21 +76,29 @@ class RoomRegistry {
58
76
 
59
77
  leave(roomId, socket) {
60
78
  this.validateRoomId(roomId);
79
+ const removed = this.#remove(roomId, socket);
80
+ this.access?.cancel(socket, roomId);
81
+ if (removed) this.onChange('leave', roomId, socket);
82
+ return removed;
83
+ }
84
+
85
+ #remove(roomId, socket) {
61
86
  const members = this.rooms.get(roomId);
62
87
  if (!members?.delete(socket)) return false;
63
88
  const memberships = this.memberships.get(socket);
64
89
  memberships?.delete(roomId);
65
90
  if (!memberships?.size) this.memberships.delete(socket);
66
91
  if (!members.size) this.rooms.delete(roomId);
67
- this.onChange('leave', roomId, socket);
68
92
  return true;
69
93
  }
70
94
 
71
95
  leaveAll(socket) {
72
96
  const memberships = this.memberships.get(socket);
73
- if (!memberships) return 0;
97
+ if (!memberships) { this.access?.cancel(socket); return 0; }
74
98
  const roomIds = [...memberships];
75
- roomIds.forEach(roomId => this.leave(roomId, socket));
99
+ roomIds.forEach(roomId => this.#remove(roomId, socket));
100
+ this.access?.cancel(socket);
101
+ roomIds.forEach(roomId => this.onChange('leave', roomId, socket));
76
102
  return roomIds.length;
77
103
  }
78
104
 
@@ -87,10 +113,30 @@ class RoomRegistry {
87
113
  }
88
114
 
89
115
  broadcast(roomId, data, { except } = {}) {
116
+ return this.#broadcast(roomId, data, except);
117
+ }
118
+
119
+ broadcastFrom(socket, roomId, data, { except } = {}) {
120
+ if (!socket) return 0;
121
+ return this.#broadcast(roomId, data, except, socket);
122
+ }
123
+
124
+ #broadcast(roomId, data, except, sender) {
90
125
  this.validateRoomId(roomId);
91
126
  if (this.closed) return 0;
92
127
  const members = this.rooms.get(roomId);
93
128
  if (!members) return 0;
129
+ if (this.access) {
130
+ const eligible = socket => members.has(socket) && this.hasConnection(socket);
131
+ if (sender && !eligible(sender)) return 0;
132
+ const payload = JSON.stringify(data);
133
+ if (this.closed || this.rooms.get(roomId) !== members || (sender && !eligible(sender))) return 0;
134
+ let sent = 0;
135
+ for (const socket of members) {
136
+ if (socket !== except && eligible(socket) && sendPayload(socket, payload, this.policy)) sent++;
137
+ }
138
+ return sent;
139
+ }
94
140
  const recipients = except === undefined
95
141
  ? members
96
142
  : [...members].filter(socket => socket !== except);
@@ -100,6 +146,10 @@ class RoomRegistry {
100
146
  clear() {
101
147
  this.rooms.clear();
102
148
  this.memberships = new WeakMap();
149
+ const nested = this.clearing;
150
+ this.clearing = true;
151
+ try { this.access?.clear(); }
152
+ finally { if (!nested) this.clearing = false; }
103
153
  }
104
154
 
105
155
  close() {
@@ -5,10 +5,12 @@ const HeartbeatMonitor = require('./HeartbeatMonitor');
5
5
  const RoomRegistry = require('./RoomRegistry');
6
6
  const SessionRegistry = require('./SessionRegistry');
7
7
  const DistributionBridge = require('./DistributionBridge');
8
+ const requestSnapshot = require('../context/RequestSnapshot');
8
9
 
9
10
  function joinRoom(roomId) { return this.__redwebRuntimeOwner.rooms.join(roomId, this); }
11
+ function enterRoom(roomId) { return this.__redwebRuntimeOwner.rooms.enter(roomId, this); }
10
12
  function leaveRoom(roomId) { return this.__redwebRuntimeOwner.rooms.leave(roomId, this); }
11
- function roomBroadcast(roomId, data, options) { return this.__redwebRuntimeOwner.rooms.broadcast(roomId, data, options); }
13
+ function roomBroadcast(roomId, data, options) { return this.__redwebRuntimeOwner.rooms.broadcastFrom(this, roomId, data, options); }
12
14
  function createSession(sessionId, data) {
13
15
  this.__redwebRuntimeOwner.ensureContext(this);
14
16
  return this.__redwebRuntimeOwner.sessions.create(sessionId, data, this);
@@ -18,6 +20,7 @@ function resumeSession(sessionId) {
18
20
  return this.__redwebRuntimeOwner.sessions.resume(sessionId, this);
19
21
  }
20
22
  function publishEvent(type, payload) { return this.__redwebRuntimeOwner.route.publish(type, payload); }
23
+ function connectionContext() { return this.__redwebRuntimeOwner.ensureContext(this); }
21
24
 
22
25
  class RouteRuntime {
23
26
  constructor(route, { heartbeat, rooms, sessions, distribution, drainHandlers }) {
@@ -25,12 +28,17 @@ class RouteRuntime {
25
28
  this.inFlight = drainHandlers ? new Set() : null;
26
29
  this.abortController = drainHandlers ? new AbortController() : null;
27
30
  this.acceptingWork = true;
31
+ this.contexts = new WeakMap();
32
+ this.requests = new WeakMap();
33
+ this.needsContext = Boolean(route.admissionPolicy || route.protocolPolicy || rooms || sessions || drainHandlers);
28
34
  try {
29
35
  this.heartbeat = heartbeat === undefined ? null : new HeartbeatMonitor(heartbeat, route.logger);
30
36
  this.rooms = rooms === undefined || rooms === false
31
37
  ? null
32
38
  : new RoomRegistry(rooms === true ? {} : rooms, {
33
- hasConnection: socket => route.clients.get(socket.clientKey) === socket,
39
+ hasConnection: socket => !route.draining && route.clients.get(socket.clientKey) === socket &&
40
+ socket.readyState === 1 && this.contexts.get(socket)?.active !== false,
41
+ contextFor: socket => this.ensureContext(socket),
34
42
  policy: route.transportPolicy,
35
43
  onChange: action => {
36
44
  route.metrics?.increment(`redweb.room.${action}`);
@@ -65,10 +73,11 @@ class RouteRuntime {
65
73
  }
66
74
 
67
75
  decorate(socket, request) {
68
- if (request?.[ADMISSION_CONTEXT] || request?.[PROTOCOL_CONTEXT] || this.abortController) this.ensureContext(socket, request);
76
+ if (this.needsContext) this.prepareContext(socket, request);
69
77
  if (this.rooms || this.sessions || this.distribution) socket.__redwebRuntimeOwner = this;
70
78
  if (this.rooms) {
71
79
  socket.joinRoom = joinRoom;
80
+ socket.enterRoom = enterRoom;
72
81
  socket.leaveRoom = leaveRoom;
73
82
  socket.roomBroadcast = roomBroadcast;
74
83
  }
@@ -79,17 +88,46 @@ class RouteRuntime {
79
88
  if (this.distribution) socket.publishEvent = publishEvent;
80
89
  }
81
90
 
82
- ensureContext(socket, request) {
83
- if (socket.context) return socket.context;
84
- socket.context = {
85
- connectionId: randomUUID(),
91
+ prepareContext(socket, request) {
92
+ const existing = this.contexts.get(socket);
93
+ if (existing) return existing;
94
+ const record = {
95
+ snapshot: this.requests.get(request) || requestSnapshot(request || {}),
86
96
  principal: request?.[ADMISSION_CONTEXT]?.principal,
97
+ protocol: request?.[PROTOCOL_CONTEXT],
98
+ active: !this.route.draining, context: null, controller: null,
99
+ };
100
+ this.contexts.set(socket, record);
101
+ socket.__redwebRuntimeOwner = this;
102
+ Object.defineProperty(socket, 'context', { get: connectionContext, enumerable: true });
103
+ return record;
104
+ }
105
+
106
+ ensureContext(socket, request) {
107
+ const record = this.prepareContext(socket, request);
108
+ if (record.context) return record.context;
109
+ const controller = new AbortController();
110
+ const snapshot = record.snapshot;
111
+ const context = {
87
112
  session: null,
88
113
  metadata: Object.create(null),
89
- signal: this.abortController?.signal,
90
- protocol: request?.[PROTOCOL_CONTEXT],
91
114
  };
92
- return socket.context;
115
+ const stable = {
116
+ connectionId: randomUUID(),
117
+ principal: record.principal,
118
+ request: snapshot, params: snapshot.params, query: snapshot.query, body: snapshot.body,
119
+ signal: controller.signal,
120
+ protocol: record.protocol,
121
+ };
122
+ for (const [name, value] of Object.entries(stable)) Object.defineProperty(context, name, { value, enumerable: true });
123
+ record.context = context;
124
+ record.controller = controller;
125
+ if (!record.active) controller.abort();
126
+ return context;
127
+ }
128
+
129
+ prepareRequest(request) {
130
+ if (this.needsContext) this.requests.set(request, requestSnapshot(request));
93
131
  }
94
132
 
95
133
  attach(socket) {
@@ -97,7 +135,10 @@ class RouteRuntime {
97
135
  }
98
136
 
99
137
  detach(socket) {
138
+ const record = this.contexts.get(socket);
139
+ if (record) record.active = false;
100
140
  this.rooms?.leaveAll(socket);
141
+ record?.controller?.abort();
101
142
  this.sessions?.release(socket);
102
143
  this.heartbeat?.detach(socket);
103
144
  }
@@ -113,6 +154,11 @@ class RouteRuntime {
113
154
  }
114
155
 
115
156
  beginDrain() {
157
+ this.rooms?.close();
158
+ this.route.clients.forEach(socket => {
159
+ const record = this.contexts.get(socket);
160
+ if (record) { record.active = false; record.controller?.abort(); }
161
+ });
116
162
  this.abortController?.abort();
117
163
  this.route.clients.forEach(socket => socket.__redwebRuntime?.queue?.close());
118
164
  }