redweb 0.7.7 → 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.
@@ -1,6 +1,66 @@
1
1
  const { WebSocketServer } = require("ws");
2
- const { sendJson, broadcast } = require("./util");
2
+ const { sendJson, sendPayload, broadcast } = require("./util");
3
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
+ }
4
64
 
5
65
  /**
6
66
  * Represents a WebSocket route configuration.
@@ -11,41 +71,135 @@ class SocketRoute {
11
71
  * Creates a new instance of `SocketRoute`.
12
72
  * @param {Object} options - Configuration options for the WebSocket route.
13
73
  * @param {string} options.path - The path of the WebSocket route (e.g., `/chat`, `/lobby`).
14
- * @param {boolean} options.allowDuplicateConnections - Whether to allow multiple connections from the same client IP address.
15
- * @param {import('./BaseHandler').BaseHandler[]} options.handlers - An array of handler instances that manage connections and messages for this route.
16
- * @param {Array<new () => SocketService>} [options.services]
17
- * @param {import('ws').ServerOptions} [options.websocketOptions] - Options passed to the underlying WebSocketServer.
18
- */
19
- constructor({ path, handlers, services = [], allowDuplicateConnections, websocketOptions = {} } = {}) {
20
- if (!path) {
21
- throw new Error('A `path` must be specified for the SocketRoute.');
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.');
22
104
  }
23
105
  if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
24
106
  throw new Error('At least one handler must be specified for the SocketRoute.');
25
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
+ }
26
130
  /**
27
131
  * The path of the WebSocket route.
28
132
  * This determines the endpoint that clients must connect to (e.g., `ws://localhost:3000/chat`).
29
133
  * @type {string}
30
134
  */
31
- this.path = path;
32
- this.websocketOptions = websocketOptions;
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;
33
152
  /**
34
153
  * The array of handler instances associated with this route.
35
154
  * Each handler is responsible for managing WebSocket connections and message handling logic.
36
155
  * @type {import('./BaseHandler').BaseHandler[]}
37
156
  */
38
- this.handlers = handlers.map(HandlerClass => new HandlerClass());
39
- this.clients = new Map();
40
- this.server = new WebSocketServer({ noServer: true, path, ...websocketOptions });
41
- this.server.on('connection', this.handleConnection.bind(this));
42
- this.allowDuplicateConnections = 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);
43
169
 
44
170
  /* ─── ROUTE‑SCOPED SERVICES ─────────────────────────── */
45
- this.services = services.map(SvcClass => {
46
- const svc = new SvcClass();
47
- if (typeof svc.onInit === 'function') svc.onInit(this);
48
- return svc;
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));
49
203
  });
50
204
  }
51
205
  /**
@@ -53,123 +207,356 @@ class SocketRoute {
53
207
  * @param {new () => BaseHandler} HandlerClass - The handler class to add.
54
208
  */
55
209
  addHandler(HandlerClass) {
56
- const newHandler = new HandlerClass();
210
+ const newHandler = instantiate(HandlerClass, 'Handler');
57
211
  if (this.handlers.find(handler => handler.name === newHandler.name)) {
58
- console.warn(`Handler with name '${newHandler.name}' already exists.`);
59
- return;
212
+ this.logger.warn?.(`Handler with name '${newHandler.name}' already exists.`);
213
+ return false;
60
214
  }
215
+ if (typeof newHandler.name !== 'string' || !newHandler.name) throw new TypeError('A handler must have a non-empty name.');
61
216
  this.handlers.push(newHandler);
62
- console.log(`Handler '${newHandler.name}' added successfully.`);
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;
63
258
  }
64
259
  /**
65
260
  * Handles a new WebSocket connection.
66
261
  * @param {WebSocket} socket - The WebSocket connection instance.
67
262
  * @param {import('http').IncomingMessage} req - The HTTP request object associated with the connection.
68
263
  */
69
- handleConnection(socket, req) {
70
- const ip = req?.socket?.remoteAddress || 'unknown';
71
- const clientKey = this.allowDuplicateConnections ? randomUUID() : ip;
72
-
73
- console.log(`New client connected: ${ip}`);
74
-
75
- if (!this.allowDuplicateConnections) {
76
- const existing = this.clients.get(clientKey);
77
- if (existing) {
78
- console.warn(`Client ${ip} already connected, disconnecting existing connection.`);
79
- existing.send(
80
- JSON.stringify({ msg: 'You are being disconnected because a new client is connected with your IP address.' })
81
- );
82
- existing.close();
83
- }
84
- }
85
-
86
- this.clients.set(clientKey, socket);
87
- socket.clientKey = clientKey;
88
- socket.__redwebClientKey = clientKey;
89
- socket.remoteAddress = socket.remoteAddress || ip;
90
- socket.isAssigned = false; // Tracks whether the socket has been assigned a handler.
91
- socket.sendJson = (data) => sendJson(socket, data);
92
- socket.broadcast = (data) => broadcast([...this.clients.values()].filter(sock => sock !== socket), data);
93
-
94
- this.connectionOpenCallback(socket);
95
- socket.on('close', () => this.handleClose(socket));
96
- socket.on('error', (error) => this.handleError(socket, error));
97
- socket.on('message', (message, isBinary) => {
98
- if (isBinary) {
99
- this.handleBinaryMessage(socket, message);
100
- return;
101
- }
102
-
103
- try {
104
- const parsed = JSON.parse(message);
105
- this.handleMessage(socket, parsed);
106
- } catch (error) {
107
- console.error(`Error parsing message from ${ip}:`, error);
108
- socket.sendJson({ error: 'Invalid JSON format' });
109
- socket.close();
110
- return;
111
- }
112
- });
113
- }
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
+ }
114
417
 
115
418
  connectionOpenCallback(socket) {
116
- console.log(`Opening new connection: ${socket.remoteAddress}`);
419
+ this.logger.log?.(`Opening new connection: ${socket.remoteAddress}`);
117
420
  }
118
421
 
119
- handleMessage(sock, data) {
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
+ }
120
433
  const handler = this.handlers.find((handler) => handler.name == data.type);
121
434
  if (!handler) {
122
- sendJson(sock, { error: `No such handler ${data.type}` });
123
- sock.close();
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;
124
438
  } else {
125
439
  try {
126
- handler.handleMessage(sock, data);
440
+ await handler.handleMessage(sock, data);
441
+ return true;
127
442
  } catch (error) {
128
- console.error(`Error handling message in handler ${handler.name}:`, error);
129
- sendJson(sock, { error: `${error.message}` });
130
- sock.close();
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
+ }
450
+ }
451
+
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;
131
474
  }
132
- }
133
- }
134
-
135
- handleBinaryMessage(socket, buffer) {
136
- const hasBinaryPredicate = this.handlers.some(handler => typeof handler.acceptsBinary === 'function');
137
- const handler = hasBinaryPredicate
138
- ? this.handlers.find(handler => handler.acceptsBinary?.(socket, buffer))
139
- : this.handlers.find(handler => typeof handler.handleBinaryMessage === 'function');
140
-
141
- if (handler) {
142
- try {
143
- handler.handleBinaryMessage(socket, buffer);
144
- } catch (error) {
145
- console.error(`Error handling binary message in handler ${handler.name}:`, error);
146
- sendJson(socket, { error: `${error.message}` });
147
- socket.close();
148
- }
149
- return;
150
- }
151
-
152
- sendJson(socket, {
153
- error: 'Binary messages are not supported on this route'
154
- });
155
- }
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
+ }
156
486
 
157
487
  /**
158
488
  * Handles socket disconnection.
159
489
  * @param {WebSocket} socket - The WebSocket connection instance.
160
490
  * @param {string} ip - The client's IP address.
161
491
  */
162
- handleClose(socket) {
163
- const key = socket.clientKey || socket.__redwebClientKey;
164
- const ip = socket.remoteAddress || 'unknown';
165
- console.log(`Client disconnected: ${ip}`);
166
- if (key && this.clients.get(key) === socket) this.clients.delete(key);
167
- if (this.connectionCloseCallback) this.connectionCloseCallback(socket);
168
- }
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
+ }
169
506
 
170
507
  shutdown() {
171
- this.services.forEach(svc => svc.onShutdown && svc.onShutdown());
172
- this.server.close();
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.');
173
560
  }
174
561
 
175
562
  /**
@@ -178,10 +565,10 @@ class SocketRoute {
178
565
  * @param {Error} error - The error object.
179
566
  * @param {string} ip - The client's IP address.
180
567
  */
181
- handleError(socket, error) {
182
- const ip = socket.remoteAddress || 'unknown';
183
- console.error(`Socket error from ${ip}:`, error);
184
- }
185
- }
568
+ handleError(socket, error) {
569
+ const ip = socket.remoteAddress || 'unknown';
570
+ this.logger.error?.(`Socket error from ${ip}:`, error);
571
+ }
572
+ }
186
573
 
187
- module.exports = SocketRoute;
574
+ module.exports = SocketRoute;