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.
- package/CHANGELOG.md +170 -9
- package/README.md +283 -629
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +778 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +12 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +61 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/releases/0.13.0.json +2154 -0
- package/docs/releases/audit-0.13.0.json +1549 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +26 -8
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- 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.
|
|
51
|
-
this.
|
|
52
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
119
|
-
if (route.isReady?.() === false) return this.
|
|
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
|
|
126
|
-
this.
|
|
137
|
+
} catch {
|
|
138
|
+
this.logAdmissionFailure('WebSocket admission reservation failed:');
|
|
139
|
+
return this.rejectFailure(sock, 'ADMISSION_FAILED');
|
|
127
140
|
}
|
|
128
|
-
if (!reservation) return this.
|
|
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.
|
|
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.
|
|
155
|
+
: this.rejectFailure(sock, 'AUTHENTICATION_REQUIRED');
|
|
143
156
|
}
|
|
144
157
|
this.completeUpgrade(route, req, sock, head);
|
|
145
158
|
})
|
|
146
|
-
.catch(
|
|
147
|
-
this.
|
|
148
|
-
if (!sock.destroyed) this.
|
|
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
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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);
|
package/src/ws/ProtocolPolicy.js
CHANGED
|
@@ -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;
|
package/src/ws/RoomRegistry.js
CHANGED
|
@@ -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.
|
|
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
|
|
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() {
|
package/src/ws/RouteRuntime.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
}
|