redweb 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +458 -307
  3. package/client.d.ts +42 -0
  4. package/client.js +55 -0
  5. package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
  6. package/docs/PRODUCTION_READINESS.md +68 -0
  7. package/docs/VERIFICATION_EVIDENCE.md +20 -0
  8. package/index.d.ts +320 -114
  9. package/index.js +27 -12
  10. package/package.json +28 -15
  11. package/src/htmx/HtmxRenderer.js +13 -13
  12. package/src/http/BaseHttpServer.js +112 -112
  13. package/src/http/HttpServer.js +18 -18
  14. package/src/http/HttpsServer.js +20 -20
  15. package/src/serverLifecycle.js +46 -46
  16. package/src/ws/AdmissionPolicy.js +145 -0
  17. package/src/ws/BaseHandler.js +40 -40
  18. package/src/ws/BaseSocketServer.js +195 -100
  19. package/src/ws/DefaultHandler.js +5 -5
  20. package/src/ws/DefaultRoute.js +8 -8
  21. package/src/ws/DistributionBridge.js +271 -0
  22. package/src/ws/FixedStepService.js +74 -0
  23. package/src/ws/HeartbeatMonitor.js +75 -0
  24. package/src/ws/Metrics.js +34 -0
  25. package/src/ws/ProtocolPolicy.js +130 -0
  26. package/src/ws/RoomRegistry.js +117 -0
  27. package/src/ws/RouteRuntime.js +146 -0
  28. package/src/ws/SecureSocketServer.js +9 -9
  29. package/src/ws/SessionRegistry.js +135 -0
  30. package/src/ws/SocketRoute.js +523 -254
  31. package/src/ws/SocketServer.js +8 -8
  32. package/src/ws/TaskQueue.js +64 -0
  33. package/src/ws/TokenBucket.js +31 -0
  34. package/src/ws/TransportPolicy.js +68 -0
  35. package/src/ws/index.js +7 -2
  36. package/src/ws/protocol-schema.json +13 -0
  37. package/src/ws/protocol-validation.js +21 -0
  38. package/src/ws/shutdown.js +33 -33
  39. package/src/ws/util.js +38 -30
@@ -1,293 +1,562 @@
1
1
  const { WebSocketServer } = require("ws");
2
- const { sendJson, broadcast } = require("./util");
3
- const { randomUUID } = require("crypto");
4
- const { settleTasks, throwCleanupErrors } = require('../serverLifecycle');
5
- const { closeWebSocketServer } = require('./shutdown');
6
-
7
- function errorMessage(error) {
8
- return error instanceof Error ? error.message : String(error);
9
- }
10
-
11
- function instantiate(ClassType, label) {
12
- if (typeof ClassType !== 'function') throw new TypeError(`${label} entries must be constructor functions.`);
13
- return new ClassType();
14
- }
2
+ const { sendJson, sendPayload, broadcast } = require("./util");
3
+ const { randomUUID } = require("crypto");
4
+ const { settleTasks, throwCleanupErrors } = require('../serverLifecycle');
5
+ const { closeWebSocketServer } = require('./shutdown');
6
+ const { AdmissionPolicy } = require('./AdmissionPolicy');
7
+ const TransportPolicy = require('./TransportPolicy');
8
+ const Metrics = require('./Metrics');
9
+ const RouteRuntime = require('./RouteRuntime');
10
+ const { ProtocolPolicy, ERROR_CODES } = require('./ProtocolPolicy');
11
+
12
+ function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+
16
+ function instantiate(ClassType, label) {
17
+ if (typeof ClassType !== 'function') throw new TypeError(`${label} entries must be constructor functions.`);
18
+ return new ClassType();
19
+ }
20
+
21
+ function withinDeadline(promise, timeoutMs, message) {
22
+ let timer;
23
+ const timeout = new Promise((_, reject) => {
24
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
25
+ timer.unref?.();
26
+ });
27
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
28
+ }
29
+
30
+ function sendJsonFromSocket(data) {
31
+ return this.__redwebRouteOwner.send(this, data);
32
+ }
33
+
34
+ function broadcastFromSocket(data) {
35
+ const route = this.__redwebRouteOwner;
36
+ const sent = broadcast(
37
+ [...route.clients.values()].filter(socket => socket !== this),
38
+ data,
39
+ route.transportPolicy
40
+ );
41
+ if (sent) route.metrics?.increment('redweb.messages.outbound', sent);
42
+ return sent;
43
+ }
44
+
45
+ function sendEventFromSocket(type, payload, metadata) {
46
+ const route = this.__redwebRouteOwner;
47
+ return route.send(this, route.protocolPolicy.envelope(this.context.protocol.version, type, payload, metadata));
48
+ }
49
+
50
+ function sendProtocolErrorFromSocket(code, message, metadata) {
51
+ const route = this.__redwebRouteOwner;
52
+ return route.send(this, route.protocolPolicy.error(this.context.protocol.version, code, message, metadata));
53
+ }
54
+
55
+ function sendBinaryEventFromSocket(value) {
56
+ return this.__redwebRouteOwner.sendBinary(this, value);
57
+ }
58
+
59
+ function handleRuntimeError(error) {
60
+ const socket = this;
61
+ socket.__redwebRouteOwner.handleError(socket, error);
62
+ socket.close?.(1011, 'Message processing failed');
63
+ }
15
64
 
16
65
  /**
17
66
  * Represents a WebSocket route configuration.
18
67
  * This class is used to define a specific WebSocket endpoint (`path`) and its associated handlers.
19
68
  */
20
- class SocketRoute {
69
+ class SocketRoute {
21
70
  /**
22
71
  * Creates a new instance of `SocketRoute`.
23
72
  * @param {Object} options - Configuration options for the WebSocket route.
24
73
  * @param {string} options.path - The path of the WebSocket route (e.g., `/chat`, `/lobby`).
25
- * @param {boolean} options.allowDuplicateConnections - Whether to allow multiple connections from the same client IP address.
26
- * @param {import('./BaseHandler').BaseHandler[]} options.handlers - An array of handler instances that manage connections and messages for this route.
27
- * @param {Array<new () => SocketService>} [options.services]
28
- * @param {import('ws').ServerOptions} [options.websocketOptions] - Options passed to the underlying WebSocketServer.
29
- */
30
- constructor({
31
- path,
32
- handlers,
33
- services = [],
34
- allowDuplicateConnections = false,
35
- websocketOptions = {},
36
- trustProxy = false,
37
- getClientKey,
38
- exposeErrors = false,
39
- logger = console,
40
- shutdownTimeoutMs = 1000,
41
- } = {}) {
42
- if (typeof path !== 'string' || !path.startsWith('/')) {
43
- throw new Error('A `path` beginning with "/" must be specified for the SocketRoute.');
44
- }
45
- if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
46
- throw new Error('At least one handler must be specified for the SocketRoute.');
47
- }
48
- if (!Array.isArray(services)) throw new TypeError('`services` must be an array.');
49
- if (!websocketOptions || typeof websocketOptions !== 'object' || Array.isArray(websocketOptions)) {
50
- throw new TypeError('`websocketOptions` must be an object.');
51
- }
52
- if (getClientKey !== undefined && typeof getClientKey !== 'function') {
53
- throw new TypeError('`getClientKey` must be a function.');
54
- }
55
- const reservedOption = ['noServer', 'path', 'server', 'port']
56
- .find(option => Object.prototype.hasOwnProperty.call(websocketOptions, option));
57
- if (reservedOption) throw new TypeError(`Redweb controls websocketOptions.${reservedOption}.`);
58
- if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {
59
- throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
60
- }
74
+ * @param {boolean} options.allowDuplicateConnections - Whether to allow multiple connections from the same client IP address.
75
+ * @param {import('./BaseHandler').BaseHandler[]} options.handlers - An array of handler instances that manage connections and messages for this route.
76
+ * @param {Array<new () => SocketService>} [options.services]
77
+ * @param {import('ws').ServerOptions} [options.websocketOptions] - Options passed to the underlying WebSocketServer.
78
+ */
79
+ constructor({
80
+ path,
81
+ handlers,
82
+ services = [],
83
+ allowDuplicateConnections = false,
84
+ websocketOptions = {},
85
+ trustProxy = false,
86
+ getClientKey,
87
+ exposeErrors = false,
88
+ logger = console,
89
+ shutdownTimeoutMs = 1000,
90
+ admission,
91
+ limits,
92
+ orderedMessages = false,
93
+ heartbeat,
94
+ rooms,
95
+ sessions,
96
+ metrics,
97
+ distribution,
98
+ drainHandlers = false,
99
+ protocol,
100
+ maxPendingUpgrades = 64,
101
+ } = {}) {
102
+ if (typeof path !== 'string' || !path.startsWith('/')) {
103
+ throw new Error('A `path` beginning with "/" must be specified for the SocketRoute.');
104
+ }
105
+ if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
106
+ throw new Error('At least one handler must be specified for the SocketRoute.');
107
+ }
108
+ if (!Array.isArray(services)) throw new TypeError('`services` must be an array.');
109
+ if (!websocketOptions || typeof websocketOptions !== 'object' || Array.isArray(websocketOptions)) {
110
+ throw new TypeError('`websocketOptions` must be an object.');
111
+ }
112
+ if (getClientKey !== undefined && typeof getClientKey !== 'function') {
113
+ throw new TypeError('`getClientKey` must be a function.');
114
+ }
115
+ const reservedOption = ['noServer', 'path', 'server', 'port']
116
+ .find(option => Object.prototype.hasOwnProperty.call(websocketOptions, option));
117
+ if (reservedOption) throw new TypeError(`Redweb controls websocketOptions.${reservedOption}.`);
118
+ if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {
119
+ throw new TypeError('`shutdownTimeoutMs` must be a non-negative integer.');
120
+ }
121
+ if (typeof orderedMessages !== 'boolean') {
122
+ throw new TypeError('`orderedMessages` must be a boolean.');
123
+ }
124
+ if (typeof drainHandlers !== 'boolean') {
125
+ throw new TypeError('`drainHandlers` must be a boolean.');
126
+ }
127
+ if (!Number.isInteger(maxPendingUpgrades) || maxPendingUpgrades < 1) {
128
+ throw new TypeError('`maxPendingUpgrades` must be a positive integer.');
129
+ }
61
130
  /**
62
131
  * The path of the WebSocket route.
63
132
  * This determines the endpoint that clients must connect to (e.g., `ws://localhost:3000/chat`).
64
133
  * @type {string}
65
134
  */
66
- this.path = path;
67
- this.websocketOptions = { ...websocketOptions };
68
- this.logger = logger || { log() {}, warn() {}, error() {} };
69
- this.trustProxy = trustProxy;
70
- this.getClientKey = getClientKey;
71
- this.exposeErrors = exposeErrors;
72
- this.shutdownTimeoutMs = shutdownTimeoutMs;
135
+ this.path = path;
136
+ this.websocketOptions = { ...websocketOptions };
137
+ this.logger = logger || { log() {}, warn() {}, error() {} };
138
+ this.trustProxy = trustProxy;
139
+ this.getClientKey = getClientKey;
140
+ this.exposeErrors = exposeErrors;
141
+ this.shutdownTimeoutMs = shutdownTimeoutMs;
142
+ this.admissionPolicy = admission === undefined ? null : new AdmissionPolicy(admission);
143
+ this.transportPolicy = limits === undefined && !orderedMessages
144
+ ? null
145
+ : new TransportPolicy(limits, orderedMessages);
146
+ this.metrics = metrics === undefined ? null : new Metrics(metrics, path, this.logger);
147
+ this.protocolPolicy = protocol === undefined || protocol === false ? null : new ProtocolPolicy(protocol);
148
+ this.draining = false;
149
+ this.maxPendingUpgrades = maxPendingUpgrades;
150
+ this.pendingUpgrades = 0;
151
+ this.pendingCapacity = 0;
73
152
  /**
74
153
  * The array of handler instances associated with this route.
75
154
  * Each handler is responsible for managing WebSocket connections and message handling logic.
76
155
  * @type {import('./BaseHandler').BaseHandler[]}
77
156
  */
78
- this.handlers = handlers.map(HandlerClass => instantiate(HandlerClass, 'Handler'));
79
- const handlerNames = this.handlers.map(handler => handler.name);
80
- if (handlerNames.some(name => typeof name !== 'string' || !name)) {
81
- throw new TypeError('Every handler must have a non-empty name.');
82
- }
83
- if (new Set(handlerNames).size !== handlerNames.length) {
84
- throw new Error('Handler names must be unique within a route.');
85
- }
86
- this.clients = new Map();
87
- this.server = new WebSocketServer({ ...websocketOptions, noServer: true });
88
- this.server.on('connection', this.handleConnection.bind(this));
89
- this.allowDuplicateConnections = Boolean(allowDuplicateConnections);
157
+ this.handlers = handlers.map(HandlerClass => instantiate(HandlerClass, 'Handler'));
158
+ const handlerNames = this.handlers.map(handler => handler.name);
159
+ if (handlerNames.some(name => typeof name !== 'string' || !name)) {
160
+ throw new TypeError('Every handler must have a non-empty name.');
161
+ }
162
+ if (new Set(handlerNames).size !== handlerNames.length) {
163
+ throw new Error('Handler names must be unique within a route.');
164
+ }
165
+ this.clients = new Map();
166
+ this.server = new WebSocketServer({ ...websocketOptions, noServer: true });
167
+ this.server.on('connection', this.handleConnection.bind(this));
168
+ this.allowDuplicateConnections = Boolean(allowDuplicateConnections);
90
169
 
91
170
  /* ─── ROUTE‑SCOPED SERVICES ─────────────────────────── */
92
- this.services = [];
93
- try {
94
- services.forEach(SvcClass => {
95
- const svc = instantiate(SvcClass, 'Service');
96
- this.services.push(svc);
97
- if (typeof svc.onInit !== 'function') return;
98
- const result = svc.onInit(this);
99
- if (result && typeof result.then === 'function') {
100
- result.catch(() => {});
101
- throw new TypeError('SocketService.onInit must be synchronous.');
102
- }
103
- });
104
- } catch (error) {
105
- this.disposeServices();
106
- this.server.close();
107
- throw error;
108
- }
109
- }
110
-
111
- disposeServices() {
112
- this.services.forEach(service => {
113
- Promise.resolve()
114
- .then(() => service.onShutdown?.())
115
- .catch(error => this.logger.error?.('Error shutting down service:', error));
116
- });
117
- }
171
+ this.services = [];
172
+ try {
173
+ services.forEach(SvcClass => {
174
+ const svc = instantiate(SvcClass, 'Service');
175
+ this.services.push(svc);
176
+ if (typeof svc.onInit !== 'function') return;
177
+ const result = svc.onInit(this);
178
+ if (result && typeof result.then === 'function') {
179
+ result.catch(() => {});
180
+ throw new TypeError('SocketService.onInit must be synchronous.');
181
+ }
182
+ });
183
+ } catch (error) {
184
+ this.disposeServices();
185
+ this.server.close();
186
+ throw error;
187
+ }
188
+ try {
189
+ this.runtime = new RouteRuntime(this, { heartbeat, rooms, sessions, distribution, drainHandlers });
190
+ Object.assign(this, this.runtime.expose());
191
+ } catch (error) {
192
+ this.disposeServices();
193
+ this.server.close();
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ disposeServices() {
199
+ this.services.forEach(service => {
200
+ Promise.resolve()
201
+ .then(() => service.onShutdown?.())
202
+ .catch(error => this.logger.error?.('Error shutting down service:', error));
203
+ });
204
+ }
118
205
  /**
119
206
  * Adds a new handler to the WebSocket server.
120
207
  * @param {new () => BaseHandler} HandlerClass - The handler class to add.
121
208
  */
122
- addHandler(HandlerClass) {
123
- const newHandler = instantiate(HandlerClass, 'Handler');
209
+ addHandler(HandlerClass) {
210
+ const newHandler = instantiate(HandlerClass, 'Handler');
124
211
  if (this.handlers.find(handler => handler.name === newHandler.name)) {
125
- this.logger.warn?.(`Handler with name '${newHandler.name}' already exists.`);
126
- return false;
127
- }
128
- if (typeof newHandler.name !== 'string' || !newHandler.name) throw new TypeError('A handler must have a non-empty name.');
129
- this.handlers.push(newHandler);
130
- this.logger.log?.(`Handler '${newHandler.name}' added successfully.`);
131
- return true;
132
- }
133
-
134
- resolveRemoteAddress(req) {
135
- if (this.getClientKey) return String(this.getClientKey(req));
136
- if (this.trustProxy) {
137
- const forwarded = req?.headers?.['x-forwarded-for'];
138
- if (typeof forwarded === 'string' && forwarded.trim()) return forwarded.split(',')[0].trim();
139
- }
140
- return req?.socket?.remoteAddress || 'unknown';
141
- }
212
+ this.logger.warn?.(`Handler with name '${newHandler.name}' already exists.`);
213
+ return false;
214
+ }
215
+ if (typeof newHandler.name !== 'string' || !newHandler.name) throw new TypeError('A handler must have a non-empty name.');
216
+ this.handlers.push(newHandler);
217
+ this.logger.log?.(`Handler '${newHandler.name}' added successfully.`);
218
+ return true;
219
+ }
220
+
221
+ resolveRemoteAddress(req) {
222
+ if (this.getClientKey) return String(this.getClientKey(req));
223
+ if (this.trustProxy) {
224
+ const forwarded = req?.headers?.['x-forwarded-for'];
225
+ if (typeof forwarded === 'string' && forwarded.trim()) return forwarded.split(',')[0].trim();
226
+ }
227
+ return req?.socket?.remoteAddress || 'unknown';
228
+ }
229
+
230
+ authorizeUpgrade(request, rawSocket, signal) {
231
+ if (!this.admissionPolicy && !this.protocolPolicy) return true;
232
+ return this.authorizePolicies(request, rawSocket, signal);
233
+ }
234
+
235
+ async authorizePolicies(request, rawSocket, signal) {
236
+ if (this.admissionPolicy && !await this.admissionPolicy.authorize(request, rawSocket, this, signal)) return false;
237
+ return this.protocolPolicy ? this.protocolPolicy.negotiate(request) : true;
238
+ }
239
+
240
+ reserveUpgrade(request) {
241
+ if (this.draining || this.pendingUpgrades >= this.maxPendingUpgrades) return null;
242
+ const clientKey = this.resolveRemoteAddress(request);
243
+ const replacing = !this.allowDuplicateConnections && this.clients.has(clientKey);
244
+ const capacity = !replacing;
245
+ if (capacity && this.clients.size + this.pendingCapacity >= (this.transportPolicy?.maxConnections ?? Infinity)) {
246
+ return null;
247
+ }
248
+ this.pendingUpgrades += 1;
249
+ if (capacity) this.pendingCapacity += 1;
250
+ return { capacity };
251
+ }
252
+
253
+ releaseUpgrade(reservation) {
254
+ if (!reservation) return false;
255
+ this.pendingUpgrades = Math.max(0, this.pendingUpgrades - 1);
256
+ if (reservation.capacity) this.pendingCapacity = Math.max(0, this.pendingCapacity - 1);
257
+ return true;
258
+ }
142
259
  /**
143
260
  * Handles a new WebSocket connection.
144
261
  * @param {WebSocket} socket - The WebSocket connection instance.
145
262
  * @param {import('http').IncomingMessage} req - The HTTP request object associated with the connection.
146
263
  */
147
- handleConnection(socket, req) {
148
- const ip = this.resolveRemoteAddress(req);
149
- const clientKey = this.allowDuplicateConnections ? randomUUID() : ip;
150
-
151
- this.logger.log?.(`New client connected: ${ip}`);
152
-
153
- if (!this.allowDuplicateConnections) {
154
- const existing = this.clients.get(clientKey);
155
- if (existing) {
156
- this.logger.warn?.(`Client ${ip} already connected, disconnecting existing connection.`);
157
- sendJson(existing, { msg: 'You are being disconnected because a new client is connected with your IP address.' });
158
- existing.close?.(1000, 'Replaced by a new connection');
159
- }
160
- }
161
-
162
- this.clients.set(clientKey, socket);
163
- socket.clientKey = clientKey;
164
- socket.__redwebClientKey = clientKey;
165
- socket.remoteAddress = socket.remoteAddress || ip;
166
- socket.isAssigned = false; // Tracks whether the socket has been assigned a handler.
167
- socket.sendJson = (data) => sendJson(socket, data);
168
- socket.broadcast = (data) => broadcast([...this.clients.values()].filter(sock => sock !== socket), data);
169
-
170
- socket.on('close', () => this.handleClose(socket));
171
- socket.on('error', (error) => this.handleError(socket, error));
172
- socket.on('message', (message, isBinary) => {
173
- if (isBinary) {
174
- void this.handleBinaryMessage(socket, message);
175
- return;
176
- }
177
-
178
- try {
179
- const parsed = JSON.parse(message);
180
- void this.handleMessage(socket, parsed);
181
- } catch (error) {
182
- this.logger.error?.(`Error parsing message from ${ip}:`, error);
183
- socket.sendJson({ error: 'Invalid JSON format' });
184
- socket.close?.(1003, 'Invalid JSON');
185
- return;
186
- }
187
- });
188
-
189
- this.invokeLifecycleHook(socket, () => this.connectionOpenCallback(socket, req), true);
190
- this.handlers.forEach((handler) => {
191
- this.invokeLifecycleHook(socket, () => handler.onInitialContact?.(socket, req), true);
192
- });
193
- }
194
-
195
- invokeLifecycleHook(socket, hook, closeOnError) {
196
- Promise.resolve()
197
- .then(hook)
198
- .catch(error => {
199
- this.handleError(socket, error);
200
- if (!closeOnError) return;
201
- sendJson(socket, { error: 'Connection initialization failed' });
202
- socket.close?.(1011, 'Connection initialization failed');
203
- });
204
- }
264
+ handleConnection(socket, req) {
265
+ const ip = this.resolveRemoteAddress(req);
266
+ const clientKey = this.allowDuplicateConnections ? randomUUID() : ip;
267
+
268
+ this.runtime.decorate(socket, req);
269
+
270
+ this.logger.log?.(`New client connected: ${ip}`);
271
+
272
+ if (!this.allowDuplicateConnections) {
273
+ const existing = this.clients.get(clientKey);
274
+ if (existing) {
275
+ this.logger.warn?.(`Client ${ip} already connected, disconnecting existing connection.`);
276
+ if (existing.sendEvent) {
277
+ existing.sendEvent('system.disconnect', { reason: 'replaced' });
278
+ } else {
279
+ this.send(existing, { msg: 'You are being disconnected because a new client is connected with your IP address.' });
280
+ }
281
+ existing.close?.(1000, 'Replaced by a new connection');
282
+ }
283
+ }
284
+
285
+ const replacesExisting = !this.allowDuplicateConnections && this.clients.has(clientKey);
286
+ if (!replacesExisting && this.clients.size >= (this.transportPolicy?.maxConnections ?? Infinity)) {
287
+ this.sendFailure(socket, ERROR_CODES.CAPACITY_REACHED, 'Server capacity reached');
288
+ socket.close?.(1013, 'Server capacity reached');
289
+ this.metrics?.increment('redweb.connections.rejected');
290
+ return;
291
+ }
292
+
293
+ this.clients.set(clientKey, socket);
294
+ socket.__redwebRouteOwner = this;
295
+ socket.clientKey = clientKey;
296
+ socket.__redwebClientKey = clientKey;
297
+ socket.remoteAddress = socket.remoteAddress || ip;
298
+ socket.isAssigned = false; // Tracks whether the socket has been assigned a handler.
299
+ socket.sendJson = sendJsonFromSocket;
300
+ socket.broadcast = broadcastFromSocket;
301
+ if (this.protocolPolicy) {
302
+ socket.sendEvent = sendEventFromSocket;
303
+ socket.sendProtocolError = sendProtocolErrorFromSocket;
304
+ if (this.protocolPolicy.binary) socket.sendBinaryEvent = sendBinaryEventFromSocket;
305
+ }
306
+ socket.__redwebRuntime = this.transportPolicy?.createRuntime(handleRuntimeError, socket) || null;
307
+
308
+ socket.on('close', () => this.handleClose(socket));
309
+ socket.on('error', (error) => this.handleError(socket, error));
310
+ socket.on('message', (message, isBinary) => this.receiveMessage(socket, message, isBinary));
311
+ this.runtime.attach(socket);
312
+ this.metrics?.increment('redweb.connections.accepted');
313
+ this.metrics?.gauge('redweb.connections.active', this.clients.size);
314
+
315
+ this.invokeLifecycleHook(socket, () => this.connectionOpenCallback(socket, req), true);
316
+ this.handlers.forEach((handler) => {
317
+ this.invokeLifecycleHook(socket, () => handler.onInitialContact?.(socket, req), true);
318
+ });
319
+ }
320
+
321
+ invokeLifecycleHook(socket, hook, closeOnError) {
322
+ const task = () => Promise.resolve().then(hook).catch(error => {
323
+ this.handleError(socket, error);
324
+ if (!closeOnError) return;
325
+ this.sendFailure(socket, ERROR_CODES.INITIALIZATION_FAILED, 'Connection initialization failed');
326
+ socket.close?.(1011, 'Connection initialization failed');
327
+ });
328
+ void this.runtime.run(task);
329
+ }
330
+
331
+ send(socket, data) {
332
+ const sent = this.transportPolicy
333
+ ? sendJson(socket, data, this.transportPolicy)
334
+ : sendJson(socket, data);
335
+ if (sent) this.metrics?.increment('redweb.messages.outbound');
336
+ return sent;
337
+ }
338
+
339
+ sendFailure(socket, code, message, metadata) {
340
+ if (!this.protocolPolicy || !socket.context?.protocol) return this.send(socket, { error: message });
341
+ return this.send(
342
+ socket,
343
+ this.protocolPolicy.error(socket.context.protocol.version, code, message, metadata)
344
+ );
345
+ }
346
+
347
+ async sendBinary(socket, value) {
348
+ try {
349
+ const encoded = await this.protocolPolicy.encodeBinary(value, socket.context);
350
+ if (!encoded) return false;
351
+ const sent = sendPayload(socket, encoded, this.transportPolicy);
352
+ if (sent) this.metrics?.increment('redweb.messages.outbound');
353
+ return sent;
354
+ } catch (error) {
355
+ this.handleError(socket, error);
356
+ return false;
357
+ }
358
+ }
359
+
360
+ receiveMessage(socket, message, isBinary) {
361
+ if (this.draining) return false;
362
+ this.metrics?.increment('redweb.messages.inbound');
363
+ const runtime = socket.__redwebRuntime;
364
+ if (this.transportPolicy && !this.transportPolicy.acceptsMessage(runtime)) {
365
+ this.metrics?.increment('redweb.messages.rate_limited');
366
+ if (this.transportPolicy.messageRate.action === 'disconnect') {
367
+ this.sendFailure(socket, ERROR_CODES.RATE_LIMITED, 'Message rate exceeded');
368
+ socket.close?.(1008, 'Message rate exceeded');
369
+ }
370
+ return false;
371
+ }
372
+ const task = () => this.runMessageTask(() => this.dispatchMessage(socket, message, isBinary));
373
+ if (!runtime?.queue) {
374
+ void task();
375
+ return true;
376
+ }
377
+ if (runtime.queue.enqueue(task)) return true;
378
+ runtime.queue.close();
379
+ this.sendFailure(socket, ERROR_CODES.QUEUE_FULL, 'Message queue full');
380
+ socket.close?.(1013, 'Message queue full');
381
+ this.metrics?.increment('redweb.messages.queue_full');
382
+ return false;
383
+ }
384
+
385
+ runMessageTask(task) {
386
+ return this.runtime.run(task);
387
+ }
388
+
389
+ beginDrain() {
390
+ if (this.draining) return false;
391
+ this.draining = true;
392
+ this.runtime.beginDrain();
393
+ this.metrics?.gauge('redweb.ready', 0);
394
+ return true;
395
+ }
396
+
397
+ isReady() {
398
+ return !this.draining && this.runtime.isReady();
399
+ }
400
+
401
+ publish(type, payload) {
402
+ return this.distribution ? this.distribution.publish(type, payload) : Promise.resolve(false);
403
+ }
404
+
405
+ dispatchMessage(socket, message, isBinary) {
406
+ if (isBinary) return this.handleBinaryMessage(socket, message);
407
+ try {
408
+ return this.handleMessage(socket, JSON.parse(message));
409
+ } catch (error) {
410
+ this.logger.error?.(`Error parsing message from ${socket.remoteAddress}:`, error);
411
+ this.metrics?.increment('redweb.messages.malformed');
412
+ this.sendFailure(socket, ERROR_CODES.INVALID_MESSAGE, 'Invalid JSON format');
413
+ socket.close?.(1003, 'Invalid JSON');
414
+ return false;
415
+ }
416
+ }
205
417
 
206
418
  connectionOpenCallback(socket) {
207
- this.logger.log?.(`Opening new connection: ${socket.remoteAddress}`);
419
+ this.logger.log?.(`Opening new connection: ${socket.remoteAddress}`);
420
+ }
421
+
422
+ async handleMessage(sock, data) {
423
+ if (this.protocolPolicy && !this.protocolPolicy.validateEnvelope(data, sock.context?.protocol?.version)) {
424
+ this.sendFailure(sock, ERROR_CODES.INVALID_MESSAGE, 'Invalid protocol envelope');
425
+ sock.close?.(1008, 'Invalid message');
426
+ return false;
427
+ }
428
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string' || !data.type) {
429
+ this.sendFailure(sock, ERROR_CODES.INVALID_MESSAGE, 'Message must be an object with a non-empty string `type`');
430
+ sock.close?.(1008, 'Invalid message');
431
+ return false;
432
+ }
433
+ const handler = this.handlers.find((handler) => handler.name == data.type);
434
+ if (!handler) {
435
+ this.sendFailure(sock, ERROR_CODES.UNKNOWN_HANDLER, `No such handler ${data.type}`, { requestId: data.requestId });
436
+ sock.close?.(1008, 'Unknown handler');
437
+ return false;
438
+ } else {
439
+ try {
440
+ await handler.handleMessage(sock, data);
441
+ return true;
442
+ } catch (error) {
443
+ this.logger.error?.(`Error handling message in handler ${handler.name}:`, error);
444
+ this.metrics?.increment('redweb.handlers.failed');
445
+ this.sendFailure(sock, ERROR_CODES.HANDLER_FAILED, this.exposeErrors ? errorMessage(error) : 'Handler failed', { requestId: data.requestId });
446
+ sock.close?.(1011, 'Handler failed');
447
+ return false;
448
+ }
449
+ }
208
450
  }
209
451
 
210
- async handleMessage(sock, data) {
211
- if (!data || typeof data !== 'object' || typeof data.type !== 'string' || !data.type) {
212
- sendJson(sock, { error: 'Message must be an object with a non-empty string `type`' });
213
- sock.close?.(1008, 'Invalid message');
214
- return false;
215
- }
216
- const handler = this.handlers.find((handler) => handler.name == data.type);
217
- if (!handler) {
218
- sendJson(sock, { error: `No such handler ${data.type}` });
219
- sock.close?.(1008, 'Unknown handler');
220
- return false;
221
- } else {
222
- try {
223
- await handler.handleMessage(sock, data);
224
- return true;
225
- } catch (error) {
226
- this.logger.error?.(`Error handling message in handler ${handler.name}:`, error);
227
- sendJson(sock, { error: this.exposeErrors ? errorMessage(error) : 'Handler failed' });
228
- sock.close?.(1011, 'Handler failed');
229
- return false;
230
- }
231
- }
232
- }
233
-
234
- async handleBinaryMessage(socket, buffer) {
235
- try {
236
- const handlersWithPredicate = this.handlers.filter(handler => typeof handler.acceptsBinary === 'function');
237
- const handler = handlersWithPredicate.length
238
- ? handlersWithPredicate.find(handler => handler.acceptsBinary(socket, buffer))
239
- : this.handlers.find(handler => handler.onBinaryMessage !== undefined);
240
-
241
- if (!handler) {
242
- sendJson(socket, { error: 'Binary messages are not supported on this route' });
243
- return false;
244
- }
245
-
246
- await handler.handleBinaryMessage(socket, buffer);
247
- return true;
248
- } catch (error) {
249
- this.logger.error?.('Error handling binary message:', error);
250
- sendJson(socket, { error: this.exposeErrors ? errorMessage(error) : 'Binary handler failed' });
251
- socket.close?.(1011, 'Binary handler failed');
252
- return false;
253
- }
254
- }
452
+ async handleBinaryMessage(socket, buffer) {
453
+ try {
454
+ if (this.protocolPolicy) {
455
+ if (!this.protocolPolicy.binary) {
456
+ this.sendFailure(socket, ERROR_CODES.BINARY_UNSUPPORTED, 'Binary messages are not supported on this protocol route');
457
+ return false;
458
+ }
459
+ const decoded = await this.protocolPolicy.decodeBinary(buffer, socket.context);
460
+ if (!decoded) {
461
+ this.sendFailure(socket, ERROR_CODES.INVALID_MESSAGE, 'Invalid binary protocol message');
462
+ return false;
463
+ }
464
+ return this.handleMessage(socket, decoded);
465
+ }
466
+ const handlersWithPredicate = this.handlers.filter(handler => typeof handler.acceptsBinary === 'function');
467
+ const handler = handlersWithPredicate.length
468
+ ? handlersWithPredicate.find(handler => handler.acceptsBinary(socket, buffer))
469
+ : this.handlers.find(handler => handler.onBinaryMessage !== undefined);
470
+
471
+ if (!handler) {
472
+ this.sendFailure(socket, ERROR_CODES.BINARY_UNSUPPORTED, 'Binary messages are not supported on this route');
473
+ return false;
474
+ }
475
+
476
+ await handler.handleBinaryMessage(socket, buffer);
477
+ return true;
478
+ } catch (error) {
479
+ this.logger.error?.('Error handling binary message:', error);
480
+ this.metrics?.increment('redweb.handlers.failed');
481
+ this.sendFailure(socket, ERROR_CODES.HANDLER_FAILED, this.exposeErrors ? errorMessage(error) : 'Binary handler failed');
482
+ socket.close?.(1011, 'Binary handler failed');
483
+ return false;
484
+ }
485
+ }
255
486
 
256
487
  /**
257
488
  * Handles socket disconnection.
258
489
  * @param {WebSocket} socket - The WebSocket connection instance.
259
490
  * @param {string} ip - The client's IP address.
260
491
  */
261
- handleClose(socket) {
262
- const key = socket.clientKey || socket.__redwebClientKey;
263
- const ip = socket.remoteAddress || 'unknown';
264
- this.logger.log?.(`Client disconnected: ${ip}`);
265
- if (key !== undefined && key !== null && this.clients.get(key) === socket) this.clients.delete(key);
266
- this.invokeLifecycleHook(socket, () => this.connectionCloseCallback?.(socket), false);
267
- }
268
-
269
- shutdown() {
270
- if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
271
- return this._shutdownPromise;
272
- }
273
-
274
- async performShutdown() {
275
- const errors = await settleTasks(this.services.map(service => () => service.onShutdown?.()));
276
- const clients = [...this.clients.values()];
277
- clients.forEach(socket => {
278
- try {
279
- socket.close?.(1001, 'Server shutting down');
280
- } catch (error) {
281
- errors.push(error);
282
- }
283
- });
284
- this.clients.clear();
285
- try {
286
- await closeWebSocketServer(this.server, clients, this.shutdownTimeoutMs);
287
- } catch (error) {
288
- errors.push(error);
289
- }
290
- throwCleanupErrors(errors, 'One or more WebSocket route cleanup operations failed.');
492
+ handleClose(socket) {
493
+ if (socket.__redwebCloseHandled) return false;
494
+ socket.__redwebCloseHandled = true;
495
+ const key = socket.clientKey || socket.__redwebClientKey;
496
+ const ip = socket.remoteAddress || 'unknown';
497
+ this.logger.log?.(`Client disconnected: ${ip}`);
498
+ if (key !== undefined && key !== null && this.clients.get(key) === socket) this.clients.delete(key);
499
+ this.runtime.detach(socket);
500
+ socket.__redwebRuntime?.queue?.close();
501
+ this.metrics?.increment('redweb.connections.closed');
502
+ this.metrics?.gauge('redweb.connections.active', this.clients.size);
503
+ this.invokeLifecycleHook(socket, () => this.connectionCloseCallback?.(socket), false);
504
+ return true;
505
+ }
506
+
507
+ shutdown() {
508
+ if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
509
+ return this._shutdownPromise;
510
+ }
511
+
512
+ async performShutdown() {
513
+ this.beginDrain();
514
+ const deadline = Date.now() + this.shutdownTimeoutMs;
515
+ this.runtime.stopHeartbeat();
516
+ const cleanup = settleTasks([
517
+ ...this.services.map(service => () => service.onShutdown?.()),
518
+ () => this.runtime.closeDistribution(),
519
+ () => this.inFlight ? Promise.allSettled([...this.inFlight]) : undefined,
520
+ ]);
521
+ let errors;
522
+ try {
523
+ errors = await withinDeadline(
524
+ cleanup,
525
+ Math.max(0, deadline - Date.now()),
526
+ 'Route cleanup exceeded shutdownTimeoutMs.'
527
+ );
528
+ } catch (error) {
529
+ errors = [error];
530
+ }
531
+ this.services = [];
532
+ this.runtime.closeState();
533
+ const clients = [...this.clients.values()];
534
+ clients.forEach(socket => {
535
+ try {
536
+ socket.close?.(1001, 'Server shutting down');
537
+ } catch (error) {
538
+ errors.push(error);
539
+ }
540
+ });
541
+ this.clients.clear();
542
+ try {
543
+ await closeWebSocketServer(this.server, clients, Math.max(0, deadline - Date.now()));
544
+ } catch (error) {
545
+ errors.push(error);
546
+ }
547
+ clients.forEach(socket => this.handleClose(socket));
548
+ this.runtime.stopAcceptingWork();
549
+ try {
550
+ await withinDeadline(
551
+ this.inFlight ? Promise.allSettled([...this.inFlight]) : Promise.resolve(),
552
+ Math.max(0, deadline - Date.now()),
553
+ 'Route lifecycle cleanup exceeded shutdownTimeoutMs.'
554
+ );
555
+ } catch (error) {
556
+ errors.push(error);
557
+ }
558
+ this.runtime.clearInFlight();
559
+ throwCleanupErrors(errors, 'One or more WebSocket route cleanup operations failed.');
291
560
  }
292
561
 
293
562
  /**
@@ -296,10 +565,10 @@ class SocketRoute {
296
565
  * @param {Error} error - The error object.
297
566
  * @param {string} ip - The client's IP address.
298
567
  */
299
- handleError(socket, error) {
300
- const ip = socket.remoteAddress || 'unknown';
301
- this.logger.error?.(`Socket error from ${ip}:`, error);
302
- }
303
- }
568
+ handleError(socket, error) {
569
+ const ip = socket.remoteAddress || 'unknown';
570
+ this.logger.error?.(`Socket error from ${ip}:`, error);
571
+ }
572
+ }
304
573
 
305
- module.exports = SocketRoute;
574
+ module.exports = SocketRoute;