redweb 0.7.6 → 0.8.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.
@@ -0,0 +1,46 @@
1
+ function listenServer(server, { port, bind, callback, logger = console, name = 'Server' }) {
2
+ const onListening = callback || (() => logger?.log?.(`RedWeb ${name} listening on ${bind}:${port}`));
3
+ server.listen(port, bind, onListening);
4
+ }
5
+
6
+ function validateListenerOptions(options) {
7
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
8
+ throw new TypeError('`port` must be an integer between 0 and 65535.');
9
+ }
10
+ if (typeof options.bind !== 'string' || !options.bind) {
11
+ throw new TypeError('`bind` must be a non-empty string.');
12
+ }
13
+ if (typeof options.listen !== 'boolean') {
14
+ throw new TypeError('`listen` must be a boolean.');
15
+ }
16
+ if (options.listenCallback !== undefined && typeof options.listenCallback !== 'function') {
17
+ throw new TypeError('`listenCallback` must be a function.');
18
+ }
19
+ }
20
+
21
+ async function settleTasks(tasks) {
22
+ const results = await Promise.allSettled(tasks.map(task => Promise.resolve().then(task)));
23
+ return results.filter(result => result.status === 'rejected').map(result => result.reason);
24
+ }
25
+
26
+ function throwCleanupErrors(errors, message) {
27
+ if (!errors.length) return;
28
+ const aggregate = new Error(message);
29
+ aggregate.errors = errors;
30
+ throw aggregate;
31
+ }
32
+
33
+ function closeServer(server) {
34
+ return new Promise((resolve, reject) => {
35
+ if (!server?.listening) return resolve();
36
+ server.close((error) => error ? reject(error) : resolve());
37
+ });
38
+ }
39
+
40
+ module.exports = {
41
+ listenServer,
42
+ closeServer,
43
+ settleTasks,
44
+ throwCleanupErrors,
45
+ validateListenerOptions,
46
+ };
@@ -18,8 +18,16 @@ class BaseHandler {
18
18
  * @param {WebSocket & {sendJson: (message: Object) => void, broadcast: (message: Object) => void}} socket - The WebSocket connection that sent the message.
19
19
  * @param {any} message - The incoming message in parsed JSON.
20
20
  */
21
- handleMessage(socket, message) {
22
- this.onMessage(socket, message);
21
+ async handleMessage(socket, message) {
22
+ const validationResult = await this.validateMessage(message, socket);
23
+ if (validationResult === false) {
24
+ throw new Error('Invalid message');
25
+ }
26
+ return this.onMessage(socket, message);
27
+ }
28
+
29
+ validateMessage() {
30
+ return true;
23
31
  }
24
32
 
25
33
  /**
@@ -27,8 +35,8 @@ class BaseHandler {
27
35
  * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
28
36
  * @param {Buffer} buffer - The incoming binary message.
29
37
  */
30
- handleBinaryMessage(socket, buffer) {
31
- this.onBinaryMessage(socket, buffer);
38
+ async handleBinaryMessage(socket, buffer) {
39
+ return this.onBinaryMessage(socket, buffer);
32
40
  }
33
41
 
34
42
  /**
@@ -37,7 +45,7 @@ class BaseHandler {
37
45
  * @param {any} message - The incoming message in parsed JSON.
38
46
  */
39
47
  onMessage(socket, message) {
40
- throw "Not yet implemented!";
48
+ throw new Error('onMessage must be implemented by the handler.');
41
49
  }
42
50
 
43
51
  /**
@@ -6,13 +6,25 @@
6
6
  * @property {Array<new () => import('./SocketRoute').SocketRoute>} [routes]
7
7
  */
8
8
 
9
- const DefaultRoute = require('./DefaultRoute');
9
+ const DefaultRoute = require('./DefaultRoute');
10
+ const {
11
+ listenServer,
12
+ closeServer,
13
+ settleTasks,
14
+ throwCleanupErrors,
15
+ validateListenerOptions,
16
+ } = require('../serverLifecycle');
10
17
 
11
- const SOCKET_OPTIONS = {
18
+ const SOCKET_OPTIONS = {
12
19
  port: 3000,
20
+ bind: '0.0.0.0',
13
21
  ssl: null,
14
22
  listen: true,
15
- routes: []
23
+ routes: [],
24
+ fallbackToRoot: false,
25
+ closeServerOnShutdown: undefined,
26
+ logger: console,
27
+ listenCallback: undefined,
16
28
  };
17
29
 
18
30
  /**
@@ -23,17 +35,54 @@ class BaseSocketServer {
23
35
  * @param {import('http').Server} server
24
36
  * @param {SocketServerOptions} [options]
25
37
  */
26
- constructor(server, options = {}) {
27
- this.clients = new Map();
28
- Object.assign(this, { ...SOCKET_OPTIONS, ...options });
29
- this.server = server;
38
+ constructor(server, options = {}, ownsServer = false, name = 'SocketServer') {
39
+ if (!server || typeof server.on !== 'function') throw new TypeError('A Node HTTP(S) server is required.');
40
+ Object.assign(this, { ...SOCKET_OPTIONS, ...options });
41
+ validateListenerOptions(this);
42
+ if (!Array.isArray(this.routes)) throw new TypeError('`routes` must be an array.');
43
+ this.server = server;
44
+ this.ownsServer = ownsServer;
45
+ this.closeServerOnShutdown = options.closeServerOnShutdown ?? ownsServer;
30
46
 
31
47
  /* ─── ROUTE INITIALISATION ─────────────────────────── */
32
- if (!options.routes?.length) options.routes = [DefaultRoute];
33
- this.routes = options.routes.map(RouteClass => new RouteClass(server));
34
-
35
- this.server.on('upgrade', this.handleUpgrade.bind(this));
36
- }
48
+ const RouteClasses = options.routes?.length ? [...options.routes] : [DefaultRoute];
49
+ this.routes = [];
50
+ try {
51
+ for (const RouteClass of RouteClasses) {
52
+ const route = new RouteClass(server, { logger: this.logger });
53
+ if (this.routes.some(existing => existing.path === route.path)) {
54
+ this.disposeRoutes([route]);
55
+ throw new Error('WebSocket route paths must be unique.');
56
+ }
57
+ this.routes.push(route);
58
+ }
59
+ } catch (error) {
60
+ this.disposeRoutes(this.routes);
61
+ throw error;
62
+ }
63
+
64
+ this._upgradeHandler = this.handleUpgrade.bind(this);
65
+ this.server.on('upgrade', this._upgradeHandler);
66
+
67
+ const shouldListen = (ownsServer && this.listen !== false) || (!ownsServer && options.listen === true);
68
+ if (shouldListen) {
69
+ listenServer(this.server, {
70
+ port: this.port,
71
+ bind: this.bind,
72
+ callback: this.listenCallback,
73
+ logger: this.logger,
74
+ name,
75
+ });
76
+ }
77
+ }
78
+
79
+ disposeRoutes(routes) {
80
+ routes.forEach(route => {
81
+ Promise.resolve()
82
+ .then(() => route.shutdown?.())
83
+ .catch(error => this.logger?.error?.('Error shutting down route:', error));
84
+ });
85
+ }
37
86
 
38
87
  handleUpgrade(req, sock, head) {
39
88
  // Some websocket clients (e.g., certain UE plugins) are finicky about the
@@ -48,8 +97,8 @@ class BaseSocketServer {
48
97
  })();
49
98
 
50
99
  const route =
51
- this.routes.find(r => r.path === path) ||
52
- this.routes.find(r => r.path === '/');
100
+ this.routes.find(r => r.path === path) ||
101
+ (this.fallbackToRoot ? this.routes.find(r => r.path === '/') : undefined);
53
102
 
54
103
  if (!route) return sock.destroy();
55
104
 
@@ -62,17 +111,36 @@ class BaseSocketServer {
62
111
  * Dynamically attach a new route at runtime
63
112
  * @param {new () => import('./SocketRoute').SocketRoute} RouteClass
64
113
  */
65
- addRoute(RouteClass) {
66
- this.routes.push(new RouteClass(this.server));
114
+ addRoute(RouteClass) {
115
+ const route = new RouteClass(this.server, { logger: this.logger });
116
+ if (this.routes.some(existing => existing.path === route.path)) {
117
+ this.disposeRoutes([route]);
118
+ throw new Error(`A WebSocket route already exists at ${route.path}.`);
119
+ }
120
+ this.routes.push(route);
121
+ return route;
67
122
  }
68
123
 
69
124
  /**
70
125
  * Gracefully tear down all routes (and their services)
71
126
  */
72
- shutdown() {
73
- this.routes.forEach(route => route.shutdown?.());
74
- this.server.close();
75
- }
76
- }
127
+ shutdown() {
128
+ if (!this._shutdownPromise) this._shutdownPromise = this.performShutdown();
129
+ return this._shutdownPromise;
130
+ }
131
+
132
+ async performShutdown() {
133
+ this.server.off?.('upgrade', this._upgradeHandler);
134
+ const errors = await settleTasks(this.routes.map(route => () => route.shutdown?.()));
135
+ if (this.closeServerOnShutdown && this.server.listening) {
136
+ try {
137
+ await closeServer(this.server);
138
+ } catch (error) {
139
+ errors.push(error);
140
+ }
141
+ }
142
+ throwCleanupErrors(errors, 'One or more WebSocket server cleanup operations failed.');
143
+ }
144
+ }
77
145
 
78
146
  module.exports = { BaseSocketServer, SOCKET_OPTIONS };
@@ -1,14 +1,13 @@
1
1
  const { BaseHandler } = require("./BaseHandler");
2
- const { sendJson } = require("./util");
3
2
 
4
3
  class DefaultHandler extends BaseHandler {
5
4
  constructor() {
6
5
  super("DefaultHandler");
7
6
  }
8
7
 
9
- onMessage(socket, message) {
10
- socket.send(sendJson(`I got your message of ${JSON.stringify(message)}`));
11
- }
12
- }
8
+ onMessage(socket, message) {
9
+ socket.sendJson({ message: `I got your message of ${JSON.stringify(message)}` });
10
+ }
11
+ }
13
12
 
14
- module.exports = DefaultHandler;
13
+ module.exports = DefaultHandler;
@@ -2,13 +2,14 @@ const SocketRoute = require("./SocketRoute");
2
2
  const DefaultHandler = require('./DefaultHandler');
3
3
 
4
4
  class DefaultRoute extends SocketRoute {
5
- constructor(server) {
6
- super({
7
- server,
8
- path: "/",
9
- handlers: [DefaultHandler]
10
- })
5
+ constructor(server, options = {}) {
6
+ super({
7
+ server,
8
+ path: "/",
9
+ handlers: [DefaultHandler],
10
+ logger: options.logger,
11
+ })
11
12
  }
12
13
  }
13
14
 
14
- module.exports = DefaultRoute;
15
+ module.exports = DefaultRoute;
@@ -8,14 +8,11 @@ const { BaseSocketServer } = require('./BaseSocketServer');
8
8
  * @return {Object} WebSocket server instance.
9
9
  */
10
10
  class SecureSocketServer extends BaseSocketServer {
11
- constructor(options = {}) {
11
+ constructor(options) {
12
12
  const ownsServer = !options?.server;
13
- const sslOptions = ownsServer ? loadSslConfig(options.ssl) : null;
13
+ const sslOptions = ownsServer ? loadSslConfig(options?.ssl) : null;
14
14
  const server = options?.server || https.createServer(sslOptions);
15
- super(server, options);
16
- if ((ownsServer && this.listen !== false) || (!ownsServer && options.listen === true)) {
17
- server.listen(this.port, () => console.log(`RedWeb SecureSocketServer listening on port ${this.port}`));
18
- }
15
+ super(server, options, ownsServer, 'SecureSocketServer');
19
16
  return this;
20
17
  }
21
18
  }
@@ -1,12 +1,23 @@
1
1
  const { WebSocketServer } = require("ws");
2
2
  const { sendJson, broadcast } = require("./util");
3
- const { randomUUID } = require("crypto");
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
+ }
4
15
 
5
16
  /**
6
17
  * Represents a WebSocket route configuration.
7
18
  * This class is used to define a specific WebSocket endpoint (`path`) and its associated handlers.
8
19
  */
9
- class SocketRoute {
20
+ class SocketRoute {
10
21
  /**
11
22
  * Creates a new instance of `SocketRoute`.
12
23
  * @param {Object} options - Configuration options for the WebSocket route.
@@ -16,70 +27,135 @@ class SocketRoute {
16
27
  * @param {Array<new () => SocketService>} [options.services]
17
28
  * @param {import('ws').ServerOptions} [options.websocketOptions] - Options passed to the underlying WebSocketServer.
18
29
  */
19
- constructor({ path, handlers, services = [], allowDuplicateConnections, websocketOptions = {} } = {}) {
20
- if (!path) {
21
- throw new Error('A `path` must be specified for the SocketRoute.');
22
- }
23
- if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
24
- throw new Error('At least one handler must be specified for the SocketRoute.');
25
- }
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
+ }
26
61
  /**
27
62
  * The path of the WebSocket route.
28
63
  * This determines the endpoint that clients must connect to (e.g., `ws://localhost:3000/chat`).
29
64
  * @type {string}
30
65
  */
31
66
  this.path = path;
32
- this.websocketOptions = websocketOptions;
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;
33
73
  /**
34
74
  * The array of handler instances associated with this route.
35
75
  * Each handler is responsible for managing WebSocket connections and message handling logic.
36
76
  * @type {import('./BaseHandler').BaseHandler[]}
37
77
  */
38
- this.handlers = handlers.map(HandlerClass => new HandlerClass());
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
+ }
39
86
  this.clients = new Map();
40
- this.server = new WebSocketServer({ noServer: true, path, ...websocketOptions });
87
+ this.server = new WebSocketServer({ ...websocketOptions, noServer: true });
41
88
  this.server.on('connection', this.handleConnection.bind(this));
42
- this.allowDuplicateConnections = allowDuplicateConnections;
89
+ this.allowDuplicateConnections = Boolean(allowDuplicateConnections);
43
90
 
44
91
  /* ─── 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;
49
- });
50
- }
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
+ }
51
118
  /**
52
119
  * Adds a new handler to the WebSocket server.
53
120
  * @param {new () => BaseHandler} HandlerClass - The handler class to add.
54
121
  */
55
- addHandler(HandlerClass) {
56
- const newHandler = new HandlerClass();
122
+ addHandler(HandlerClass) {
123
+ const newHandler = instantiate(HandlerClass, 'Handler');
57
124
  if (this.handlers.find(handler => handler.name === newHandler.name)) {
58
- console.warn(`Handler with name '${newHandler.name}' already exists.`);
59
- return;
60
- }
61
- this.handlers.push(newHandler);
62
- console.log(`Handler '${newHandler.name}' added successfully.`);
63
- }
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
+ }
64
142
  /**
65
143
  * Handles a new WebSocket connection.
66
144
  * @param {WebSocket} socket - The WebSocket connection instance.
67
145
  * @param {import('http').IncomingMessage} req - The HTTP request object associated with the connection.
68
146
  */
69
147
  handleConnection(socket, req) {
70
- const ip = req?.socket?.remoteAddress || 'unknown';
148
+ const ip = this.resolveRemoteAddress(req);
71
149
  const clientKey = this.allowDuplicateConnections ? randomUUID() : ip;
72
150
 
73
- console.log(`New client connected: ${ip}`);
151
+ this.logger.log?.(`New client connected: ${ip}`);
74
152
 
75
153
  if (!this.allowDuplicateConnections) {
76
154
  const existing = this.clients.get(clientKey);
77
155
  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();
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');
83
159
  }
84
160
  }
85
161
 
@@ -91,67 +167,90 @@ class SocketRoute {
91
167
  socket.sendJson = (data) => sendJson(socket, data);
92
168
  socket.broadcast = (data) => broadcast([...this.clients.values()].filter(sock => sock !== socket), data);
93
169
 
94
- this.connectionOpenCallback(socket);
95
170
  socket.on('close', () => this.handleClose(socket));
96
171
  socket.on('error', (error) => this.handleError(socket, error));
97
172
  socket.on('message', (message, isBinary) => {
98
173
  if (isBinary) {
99
- this.handleBinaryMessage(socket, message);
174
+ void this.handleBinaryMessage(socket, message);
100
175
  return;
101
176
  }
102
177
 
103
178
  try {
104
179
  const parsed = JSON.parse(message);
105
- this.handleMessage(socket, parsed);
180
+ void this.handleMessage(socket, parsed);
106
181
  } catch (error) {
107
- console.error(`Error parsing message from ${ip}:`, error);
182
+ this.logger.error?.(`Error parsing message from ${ip}:`, error);
108
183
  socket.sendJson({ error: 'Invalid JSON format' });
109
- socket.close();
184
+ socket.close?.(1003, 'Invalid JSON');
110
185
  return;
111
186
  }
112
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
+ });
113
204
  }
114
205
 
115
206
  connectionOpenCallback(socket) {
116
- console.log(`Opening new connection: ${socket.remoteAddress}`);
207
+ this.logger.log?.(`Opening new connection: ${socket.remoteAddress}`);
117
208
  }
118
209
 
119
- handleMessage(sock, data) {
120
- const handler = this.handlers.find((handler) => handler.name == data.type);
121
- if (!handler) {
122
- sendJson(sock, { error: `No such handler ${data.type}` });
123
- sock.close();
124
- } else {
125
- try {
126
- handler.handleMessage(sock, data);
127
- } catch (error) {
128
- console.error(`Error handling message in handler ${handler.name}:`, error);
129
- sendJson(sock, { error: `${error.message}` });
130
- sock.close();
131
- }
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;
132
215
  }
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) {
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 {
142
222
  try {
143
- handler.handleBinaryMessage(socket, buffer);
223
+ await handler.handleMessage(sock, data);
224
+ return true;
144
225
  } catch (error) {
145
- console.error(`Error handling binary message in handler ${handler.name}:`, error);
146
- sendJson(socket, { error: `${error.message}` });
147
- socket.close();
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;
148
230
  }
149
- return;
150
231
  }
232
+ }
151
233
 
152
- sendJson(socket, {
153
- error: 'Binary messages are not supported on this route'
154
- });
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
+ }
155
254
  }
156
255
 
157
256
  /**
@@ -162,14 +261,33 @@ class SocketRoute {
162
261
  handleClose(socket) {
163
262
  const key = socket.clientKey || socket.__redwebClientKey;
164
263
  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);
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);
168
267
  }
169
268
 
170
- shutdown() {
171
- this.services.forEach(svc => svc.onShutdown && svc.onShutdown());
172
- this.server.close();
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.');
173
291
  }
174
292
 
175
293
  /**
@@ -180,7 +298,7 @@ class SocketRoute {
180
298
  */
181
299
  handleError(socket, error) {
182
300
  const ip = socket.remoteAddress || 'unknown';
183
- console.error(`Socket error from ${ip}:`, error);
301
+ this.logger.error?.(`Socket error from ${ip}:`, error);
184
302
  }
185
303
  }
186
304