stelar-time-real 2.0.4 → 3.2.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.
package/src/index.js CHANGED
@@ -1,26 +1,262 @@
1
- import { createServer } from 'http';
2
- import { WebSocketServer } from 'ws';
1
+ /**
2
+ * @stelar-time-real Server
3
+ *
4
+ * Dual-protocol real-time server: WebSocket (RFC 6455) + custom binary TCP.
5
+ * Zero external dependencies — uses only Node.js built-in modules.
6
+ */
7
+ import { createServer as createHttpServer } from 'http';
8
+ import { createServer as createTcpServer } from 'net';
3
9
  import { randomUUID } from 'crypto';
10
+ import { createServer as createTlsServer } from 'tls';
11
+ import { FrameParser, encodeJsonFrame, encodeBinaryFrame, encodePingFrame, encodePongFrame, encodeAckResFrame, encodeConnectFrame, encodeDisconnectFrame, encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ, FRAME_ACK_RES, FRAME_JOIN, FRAME_LEAVE, FRAME_CONNECT, ProtocolError, DEFAULT_MAX_FRAME_SIZE, } from './protocol.js';
12
+ import { WSFrameParser, buildUpgradeResponse, validateWSKey, createWSTextFrame, createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_NORMAL, CLOSE_GOING_AWAY, DEFAULT_MAX_WS_FRAME_SIZE, } from './websocket.js';
13
+ import { Logger, NULL_LOGGER } from './logger.js';
14
+ class RateLimiter {
15
+ constructor(maxPoints = 100, windowMs = 1000) {
16
+ this.limits = new Map();
17
+ this.maxPoints = maxPoints;
18
+ this.windowMs = windowMs;
19
+ }
20
+ check(id, cost = 1) {
21
+ const now = Date.now();
22
+ let entry = this.limits.get(id);
23
+ if (!entry || now >= entry.resetTime) {
24
+ entry = { count: 0, resetTime: now + this.windowMs };
25
+ this.limits.set(id, entry);
26
+ }
27
+ if (entry.count + cost > this.maxPoints) {
28
+ return false;
29
+ }
30
+ entry.count += cost;
31
+ return true;
32
+ }
33
+ cleanup() {
34
+ const now = Date.now();
35
+ for (const [id, entry] of this.limits) {
36
+ if (now >= entry.resetTime) {
37
+ this.limits.delete(id);
38
+ }
39
+ }
40
+ }
41
+ reset(id) {
42
+ this.limits.delete(id);
43
+ }
44
+ size() {
45
+ return this.limits.size;
46
+ }
47
+ }
48
+ class IPConnectionTracker {
49
+ constructor(maxPerIP = 50) {
50
+ this.ipCounts = new Map();
51
+ this.maxPerIP = maxPerIP;
52
+ }
53
+ check(ip) {
54
+ const current = this.ipCounts.get(ip) || 0;
55
+ return current < this.maxPerIP;
56
+ }
57
+ add(ip) {
58
+ this.ipCounts.set(ip, (this.ipCounts.get(ip) || 0) + 1);
59
+ }
60
+ remove(ip) {
61
+ const current = this.ipCounts.get(ip) || 0;
62
+ if (current <= 1) {
63
+ this.ipCounts.delete(ip);
64
+ }
65
+ else {
66
+ this.ipCounts.set(ip, current - 1);
67
+ }
68
+ }
69
+ getCount(ip) {
70
+ return this.ipCounts.get(ip) || 0;
71
+ }
72
+ cleanup() {
73
+ for (const [ip, count] of this.ipCounts) {
74
+ if (count <= 0)
75
+ this.ipCounts.delete(ip);
76
+ }
77
+ }
78
+ }
4
79
  class StelarServer {
5
80
  constructor(options = {}) {
6
- this.server = null;
7
- this.wss = null;
81
+ this.httpServer = null;
82
+ this.tcpServer = null;
8
83
  this.clients = new Map();
84
+ this.clientsById = new Map();
85
+ this.rooms = new Map(); // room -> Set of client IDs
9
86
  this.events = new Map();
10
87
  this.middlewares = [];
11
88
  this._hbTimer = null;
89
+ this._rateCleanupTimer = null;
12
90
  this._wildcardHandler = null;
13
91
  this._connectionHandler = null;
14
92
  this._acks = new Map();
15
93
  this._externalServers = new WeakSet();
94
+ this._upgradeHandler = null;
95
+ this._requestHandler = null;
96
+ this._started = false;
97
+ this._startTime = 0;
98
+ this._shuttingDown = false;
99
+ this._sigintHandler = null;
100
+ this._sigtermHandler = null;
101
+ this._totalConnections = 0;
102
+ this._totalMessagesReceived = 0;
103
+ this._totalMessagesSent = 0;
104
+ this._shutdownCallbacks = [];
16
105
  this.port = options.port || 3000;
17
- this.server = options.server || null;
106
+ this.httpServer = options.server || null;
18
107
  this.namespace = options.namespace || '/';
19
108
  this.heartbeatInterval = options.heartbeatInterval || 30000;
109
+ this.heartbeatTimeout = options.heartbeatTimeout || this.heartbeatInterval * 2;
110
+ this.tcpPort = options.tcpPort !== undefined ? options.tcpPort : false;
111
+ this.maxConnections = options.maxConnections || 10000;
112
+ this.maxRooms = options.maxRooms || 10000;
113
+ this.maxRoomsPerClient = options.maxRoomsPerClient || 50;
114
+ this.maxPayloadSize = options.maxPayloadSize || 10 * 1024 * 1024; // 10 MB
115
+ this.maxFrameSize = options.maxFrameSize || DEFAULT_MAX_FRAME_SIZE;
116
+ this.maxWSFrameSize = options.maxFrameSize || DEFAULT_MAX_WS_FRAME_SIZE;
117
+ this.connectTimeout = options.connectTimeout || 10000;
118
+ this.doGracefulShutdown = options.gracefulShutdown !== false;
119
+ this.shutdownTimeout = options.shutdownTimeout || 10000;
120
+ this.healthEndpoint = options.healthEndpoint !== undefined ? options.healthEndpoint : '/health';
121
+ this.tlsOptions = options.tls;
122
+ this.allowedOrigins = options.allowedOrigins || null;
123
+ this._customRateLimiter = options.customRateLimiter || null;
124
+ this._customIPTracker = options.customIPTracker || null;
125
+ this._generateClientId = options.generateClientId || null;
126
+ this._customHealthHandler = options.customHealthHandler || null;
127
+ this.hooks = options.hooks || {};
128
+ this.eventRateLimiters = new Map();
129
+ this._clientRateOverrides = new Map();
130
+ if (options.eventRateLimits) {
131
+ for (const [event, config] of Object.entries(options.eventRateLimits)) {
132
+ this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
133
+ }
134
+ }
135
+ if (options.rateLimit === false && !this._customRateLimiter) {
136
+ this.rateLimiter = null;
137
+ }
138
+ else if (!this._customRateLimiter) {
139
+ const rl = options.rateLimit || {};
140
+ this.rateLimiter = new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
141
+ }
142
+ else {
143
+ this.rateLimiter = null;
144
+ }
145
+ if (!this._customIPTracker) {
146
+ this.ipTracker = new IPConnectionTracker(options.maxConnectionsPerIP || 50);
147
+ }
148
+ else {
149
+ this.ipTracker = new IPConnectionTracker(50); // unused when custom tracker is set
150
+ }
151
+ if (options.logger === false) {
152
+ this.log = NULL_LOGGER;
153
+ }
154
+ else if (options.logger instanceof Logger) {
155
+ this.log = options.logger;
156
+ }
157
+ else {
158
+ this.log = new Logger({
159
+ level: options.logger || 'info',
160
+ prefix: 'stelar:server',
161
+ });
162
+ }
20
163
  }
21
164
  static of(path, options = {}) {
22
165
  return new StelarServer({ ...options, namespace: path });
23
166
  }
167
+ /** Update server configuration at runtime. */
168
+ updateConfig(options) {
169
+ if (options.maxConnections !== undefined)
170
+ this.maxConnections = options.maxConnections;
171
+ if (options.maxConnectionsPerIP !== undefined && !this._customIPTracker) {
172
+ this.ipTracker = new IPConnectionTracker(options.maxConnectionsPerIP);
173
+ }
174
+ if (options.maxRooms !== undefined)
175
+ this.maxRooms = options.maxRooms;
176
+ if (options.maxRoomsPerClient !== undefined)
177
+ this.maxRoomsPerClient = options.maxRoomsPerClient;
178
+ if (options.maxPayloadSize !== undefined)
179
+ this.maxPayloadSize = options.maxPayloadSize;
180
+ if (options.heartbeatInterval !== undefined)
181
+ this.heartbeatInterval = options.heartbeatInterval;
182
+ if (options.heartbeatTimeout !== undefined)
183
+ this.heartbeatTimeout = options.heartbeatTimeout;
184
+ if (options.allowedOrigins !== undefined)
185
+ this.allowedOrigins = options.allowedOrigins;
186
+ if (options.rateLimit === false) {
187
+ this.rateLimiter = null;
188
+ this._customRateLimiter = null;
189
+ }
190
+ else if (options.rateLimit && !this._customRateLimiter) {
191
+ const rl = options.rateLimit;
192
+ this.rateLimiter = new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
193
+ }
194
+ if (options.customRateLimiter !== undefined) {
195
+ this._customRateLimiter = options.customRateLimiter;
196
+ this.rateLimiter = null;
197
+ }
198
+ if (options.customIPTracker !== undefined) {
199
+ this._customIPTracker = options.customIPTracker;
200
+ }
201
+ if (options.generateClientId !== undefined) {
202
+ this._generateClientId = options.generateClientId;
203
+ }
204
+ if (options.customHealthHandler !== undefined) {
205
+ this._customHealthHandler = options.customHealthHandler;
206
+ }
207
+ if (options.hooks !== undefined) {
208
+ this.hooks = { ...this.hooks, ...options.hooks };
209
+ }
210
+ if (options.eventRateLimits !== undefined) {
211
+ this.eventRateLimiters.clear();
212
+ for (const [event, config] of Object.entries(options.eventRateLimits)) {
213
+ this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
214
+ }
215
+ }
216
+ this.log.info('Server configuration updated');
217
+ return this;
218
+ }
219
+ /** Set a per-client rate limit override. */
220
+ setClientRateLimit(clientId, config) {
221
+ this._clientRateOverrides.set(clientId, new RateLimiter(config.maxPoints, config.windowMs));
222
+ return this;
223
+ }
224
+ /** Remove a per-client rate limit override, falling back to the global limiter. */
225
+ removeClientRateLimit(clientId) {
226
+ this._clientRateOverrides.delete(clientId);
227
+ return this;
228
+ }
229
+ /** Set a per-event rate limit. */
230
+ setEventRateLimit(event, config) {
231
+ this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
232
+ return this;
233
+ }
234
+ /** Remove a per-event rate limit. */
235
+ removeEventRateLimit(event) {
236
+ this.eventRateLimiters.delete(event);
237
+ return this;
238
+ }
239
+ /** Get the current server configuration as a read-only object. */
240
+ getConfig() {
241
+ return Object.freeze({
242
+ maxConnections: this.maxConnections,
243
+ maxConnectionsPerIP: this._customIPTracker ? -1 : this.ipTracker.maxPerIP || 50,
244
+ maxRooms: this.maxRooms,
245
+ maxRoomsPerClient: this.maxRoomsPerClient,
246
+ maxPayloadSize: this.maxPayloadSize,
247
+ heartbeatInterval: this.heartbeatInterval,
248
+ heartbeatTimeout: this.heartbeatTimeout,
249
+ connectTimeout: this.connectTimeout,
250
+ shutdownTimeout: this.shutdownTimeout,
251
+ hasCustomRateLimiter: this._customRateLimiter !== null,
252
+ hasCustomIPTracker: this._customIPTracker !== null,
253
+ hasCustomClientIdGenerator: this._generateClientId !== null,
254
+ hasCustomHealthHandler: this._customHealthHandler !== null,
255
+ eventRateLimits: Array.from(this.eventRateLimiters.keys()),
256
+ hooks: Object.keys(this.hooks),
257
+ allowedOrigins: this.allowedOrigins,
258
+ });
259
+ }
24
260
  use(middleware) {
25
261
  this.middlewares.push(middleware);
26
262
  return this;
@@ -37,248 +273,1252 @@ class StelarServer {
37
273
  this._connectionHandler = handler;
38
274
  return this;
39
275
  }
276
+ onDisconnect(handler) {
277
+ this.events.set('disconnect', handler);
278
+ return this;
279
+ }
40
280
  onAck(name, handler) {
41
281
  this._acks.set(name, handler);
42
282
  return this;
43
283
  }
44
- broadcast(event, data) {
45
- this.clients.forEach((_, client) => {
46
- client.send(JSON.stringify({ event, data }));
284
+ broadcast(event, data, excludeId) {
285
+ if (this.hooks.onBeforeBroadcast) {
286
+ const result = this.hooks.onBeforeBroadcast({ event, data, excludeId });
287
+ if (result === false)
288
+ return this;
289
+ }
290
+ let sent = 0;
291
+ this.clients.forEach((record) => {
292
+ if (excludeId && record.info.id === excludeId)
293
+ return;
294
+ if (this._sendJsonToClient(record, event, data))
295
+ sent++;
47
296
  });
297
+ this._totalMessagesSent += sent;
48
298
  return this;
49
299
  }
50
300
  broadcastBinary(event, buffer) {
51
- const header = JSON.stringify({ event, _binary: true });
52
- const headerBytes = new TextEncoder().encode(header);
53
- const combined = new Uint8Array(headerBytes.length + 1 + buffer.byteLength);
54
- combined.set(headerBytes, 0);
55
- combined[headerBytes.length] = 0;
56
- combined.set(new Uint8Array(buffer), headerBytes.length + 1);
57
- this.clients.forEach((_, client) => {
58
- client.send(combined);
301
+ this.clients.forEach((record) => {
302
+ this._sendBinaryRaw(record, event, buffer);
59
303
  });
60
304
  }
61
- to(room, event, data) {
62
- this.clients.forEach((info, client) => {
63
- if (info.room === room) {
64
- client.send(JSON.stringify({ event, data }));
65
- }
66
- });
305
+ to(room, event, data, excludeId) {
306
+ const memberIds = this.rooms.get(room);
307
+ if (!memberIds)
308
+ return this;
309
+ let sent = 0;
310
+ for (const clientId of memberIds) {
311
+ if (excludeId && clientId === excludeId)
312
+ continue;
313
+ const record = this.clientsById.get(clientId);
314
+ if (record && this._sendJsonToClient(record, event, data))
315
+ sent++;
316
+ }
317
+ this._totalMessagesSent += sent;
67
318
  return this;
68
319
  }
69
320
  toId(id, event, data) {
70
- this.clients.forEach((info, client) => {
71
- if (info.id === id) {
72
- client.send(JSON.stringify({ event, data }));
73
- }
74
- });
321
+ const record = this.clientsById.get(id);
322
+ if (record && this._sendJsonToClient(record, event, data)) {
323
+ this._totalMessagesSent++;
324
+ }
75
325
  return this;
76
326
  }
77
327
  getClients(room) {
78
328
  const list = [];
79
- this.clients.forEach((info) => {
80
- if (!room || info.room === room)
81
- list.push({ id: info.id, room: info.room });
329
+ this.clients.forEach((record) => {
330
+ if (!room || record.info.rooms.has(room)) {
331
+ list.push({ id: record.info.id, rooms: Array.from(record.info.rooms) });
332
+ }
82
333
  });
83
334
  return list;
84
335
  }
336
+ getRoomMembers(room) {
337
+ const members = this.rooms.get(room);
338
+ return members ? Array.from(members) : [];
339
+ }
340
+ getRooms() {
341
+ return Array.from(this.rooms.keys());
342
+ }
85
343
  getPort() {
86
- const address = this.server?.address();
344
+ const address = this.httpServer?.address();
87
345
  if (address && typeof address === 'object') {
88
346
  return address.port;
89
347
  }
90
348
  return this.port;
91
349
  }
350
+ getStats() {
351
+ let wsConns = 0;
352
+ let tcpConns = 0;
353
+ this.clients.forEach((r) => {
354
+ if (r.protocol === 'ws')
355
+ wsConns++;
356
+ else
357
+ tcpConns++;
358
+ });
359
+ return {
360
+ totalConnections: this._totalConnections,
361
+ activeConnections: this.clients.size,
362
+ totalMessagesReceived: this._totalMessagesReceived,
363
+ totalMessagesSent: this._totalMessagesSent,
364
+ totalRooms: this.rooms.size,
365
+ uptime: this._startTime ? Date.now() - this._startTime : 0,
366
+ wsConnections: wsConns,
367
+ tcpConnections: tcpConns,
368
+ memoryUsage: process.memoryUsage(),
369
+ rateLimiterEntries: this._getRateLimiterSize(),
370
+ };
371
+ }
372
+ _getRateLimiterSize() {
373
+ if (this._customRateLimiter)
374
+ return this._customRateLimiter.size();
375
+ return this.rateLimiter?.size() || 0;
376
+ }
377
+ /** Check rate limit. Priority: per-client override > event-specific > custom/global. */
378
+ _checkRateLimit(clientId, event) {
379
+ const clientOverride = this._clientRateOverrides.get(clientId);
380
+ if (clientOverride) {
381
+ return clientOverride.check(clientId);
382
+ }
383
+ if (event && this.eventRateLimiters.has(event)) {
384
+ const eventLimiter = this.eventRateLimiters.get(event);
385
+ if (!eventLimiter.check(clientId))
386
+ return false;
387
+ }
388
+ if (this._customRateLimiter) {
389
+ return this._customRateLimiter.check(clientId);
390
+ }
391
+ if (this.rateLimiter) {
392
+ return this.rateLimiter.check(clientId);
393
+ }
394
+ return true;
395
+ }
396
+ _sendJsonToClient(record, event, data) {
397
+ if (record.socket.destroyed || record.socket.writableEnded)
398
+ return false;
399
+ try {
400
+ if (record.protocol === 'ws') {
401
+ const json = JSON.stringify({ event, data });
402
+ record.socket.write(createWSTextFrame(json));
403
+ }
404
+ else {
405
+ record.socket.write(encodeJsonFrame(event, data, this.maxFrameSize));
406
+ }
407
+ record.info.messagesSent++;
408
+ return true;
409
+ }
410
+ catch (err) {
411
+ this.log.error('Send error', { clientId: record.info.id, error: String(err) });
412
+ return false;
413
+ }
414
+ }
415
+ _sendBinaryRaw(record, event, buffer) {
416
+ if (record.socket.destroyed || record.socket.writableEnded)
417
+ return false;
418
+ try {
419
+ if (record.protocol === 'ws') {
420
+ const header = JSON.stringify({ event, _binary: true });
421
+ const headerBytes = Buffer.from(header, 'utf8');
422
+ const combined = Buffer.alloc(headerBytes.length + 1 + buffer.byteLength);
423
+ headerBytes.copy(combined, 0);
424
+ combined[headerBytes.length] = 0;
425
+ combined.set(new Uint8Array(buffer), headerBytes.length + 1);
426
+ record.socket.write(createWSBinaryFrame(combined));
427
+ }
428
+ else {
429
+ record.socket.write(encodeBinaryFrame(event, new Uint8Array(buffer), this.maxFrameSize));
430
+ }
431
+ record.info.messagesSent++;
432
+ return true;
433
+ }
434
+ catch (err) {
435
+ this.log.error('Binary send error', { clientId: record.info.id, error: String(err) });
436
+ return false;
437
+ }
438
+ }
439
+ _joinRoom(record, room) {
440
+ if (this.hooks.onClientJoinRoom) {
441
+ const result = this.hooks.onClientJoinRoom({
442
+ clientId: record.info.id,
443
+ room,
444
+ metadata: record.info.metadata,
445
+ });
446
+ if (result === false) {
447
+ this.log.info('Room join rejected by hook', { clientId: record.info.id, room });
448
+ return;
449
+ }
450
+ }
451
+ if (record.info.rooms.size >= this.maxRoomsPerClient) {
452
+ if (this.hooks.onMaxRoomsPerClientReached) {
453
+ const result = this.hooks.onMaxRoomsPerClientReached({
454
+ clientId: record.info.id,
455
+ room,
456
+ currentRooms: record.info.rooms.size,
457
+ max: this.maxRoomsPerClient,
458
+ });
459
+ if (result === false)
460
+ return;
461
+ }
462
+ this.log.warn('Client exceeded max rooms', { clientId: record.info.id, room, max: this.maxRoomsPerClient });
463
+ return;
464
+ }
465
+ if (this.rooms.size >= this.maxRooms && !this.rooms.has(room)) {
466
+ if (this.hooks.onMaxRoomsReached) {
467
+ const result = this.hooks.onMaxRoomsReached({
468
+ clientId: record.info.id,
469
+ room,
470
+ totalRooms: this.rooms.size,
471
+ max: this.maxRooms,
472
+ });
473
+ if (result === false)
474
+ return;
475
+ }
476
+ this.log.warn('Server exceeded max rooms', { room, max: this.maxRooms });
477
+ return;
478
+ }
479
+ record.info.rooms.add(room);
480
+ if (!this.rooms.has(room)) {
481
+ this.rooms.set(room, new Set());
482
+ }
483
+ this.rooms.get(room).add(record.info.id);
484
+ this._sendJsonToClient(record, 'joined-room', room);
485
+ }
486
+ _leaveRoom(record, room) {
487
+ if (this.hooks.onClientLeaveRoom) {
488
+ const result = this.hooks.onClientLeaveRoom({
489
+ clientId: record.info.id,
490
+ room,
491
+ });
492
+ if (result === false) {
493
+ this.log.info('Room leave rejected by hook', { clientId: record.info.id, room });
494
+ return;
495
+ }
496
+ }
497
+ record.info.rooms.delete(room);
498
+ const members = this.rooms.get(room);
499
+ if (members) {
500
+ members.delete(record.info.id);
501
+ if (members.size === 0) {
502
+ this.rooms.delete(room);
503
+ }
504
+ }
505
+ this._sendJsonToClient(record, 'left-room', room);
506
+ }
507
+ _removeFromAllRooms(record) {
508
+ for (const room of record.info.rooms) {
509
+ const members = this.rooms.get(room);
510
+ if (members) {
511
+ members.delete(record.info.id);
512
+ if (members.size === 0) {
513
+ this.rooms.delete(room);
514
+ }
515
+ }
516
+ }
517
+ record.info.rooms.clear();
518
+ }
519
+ _buildCtx(record, req) {
520
+ const self = this;
521
+ const ctx = {
522
+ id: record.info.id,
523
+ socket: record.socket,
524
+ req,
525
+ clientInfo: record.info,
526
+ emit: (evt, d) => { if (self._sendJsonToClient(record, evt, d))
527
+ self._totalMessagesSent++; },
528
+ send: (respId, d) => { if (self._sendJsonToClient(record, respId, { data: d, _isAck: true }))
529
+ self._totalMessagesSent++; },
530
+ emitBinary: (evt, buf) => { if (self._sendBinaryRaw(record, evt, buf))
531
+ self._totalMessagesSent++; },
532
+ broadcast: (evt, d) => self.broadcast(evt, d, record.info.id),
533
+ broadcastBinary: (evt, buf) => self.broadcastBinary(evt, buf),
534
+ to: (room, evt, d) => self.to(room, evt, d, record.info.id),
535
+ toId: (id, evt, d) => self.toId(id, evt, d),
536
+ getClients: (room) => self.getClients(room),
537
+ joinRoom: (room) => self._joinRoom(record, room),
538
+ leaveRoom: (room) => self._leaveRoom(record, room),
539
+ setMetadata: (key, value) => record.info.metadata.set(key, value),
540
+ getMetadata: (key) => record.info.metadata.get(key),
541
+ ack: (ackName, d) => {
542
+ const ackHandler = self._acks.get(ackName);
543
+ if (ackHandler) {
544
+ const result = ackHandler({ ...ctx, data: d });
545
+ if (result !== undefined) {
546
+ try {
547
+ if (record.protocol === 'ws') {
548
+ record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
549
+ }
550
+ else {
551
+ record.socket.write(encodeAckResFrame(ackName, result, self.maxFrameSize));
552
+ }
553
+ self._totalMessagesSent++;
554
+ }
555
+ catch (err) {
556
+ self.log.error('ACK send error', { ackName, error: String(err) });
557
+ }
558
+ }
559
+ }
560
+ }
561
+ };
562
+ return ctx;
563
+ }
92
564
  runMiddlewares(ctx, next) {
93
565
  const run = (i) => {
94
566
  if (i >= this.middlewares.length)
95
567
  return next();
96
- this.middlewares[i](ctx, () => run(i + 1));
568
+ try {
569
+ this.middlewares[i](ctx, () => run(i + 1));
570
+ }
571
+ catch (err) {
572
+ this.log.error('Middleware error', { error: String(err), clientId: ctx.id });
573
+ ctx.socket.destroy();
574
+ }
97
575
  };
98
576
  run(0);
99
577
  }
100
578
  startHeartbeat() {
101
579
  this._hbTimer = setInterval(() => {
102
- this.clients.forEach((info, client) => {
103
- if (info.lastPing && Date.now() - info.lastPing > this.heartbeatInterval * 2) {
104
- client.close();
105
- this.clients.delete(client);
580
+ const now = Date.now();
581
+ this.clients.forEach((record) => {
582
+ if (now - record.info.lastPing > this.heartbeatTimeout) {
583
+ this.log.info('Client heartbeat timeout', { clientId: record.info.id });
584
+ record.socket.destroy();
106
585
  }
107
586
  else {
108
- client.send(JSON.stringify({ event: 'ping', data: Date.now() }));
587
+ try {
588
+ if (record.protocol === 'ws') {
589
+ record.socket.write(createWSPingFrame());
590
+ }
591
+ else {
592
+ record.socket.write(encodePingFrame());
593
+ }
594
+ }
595
+ catch {
596
+ // socket may have closed
597
+ }
109
598
  }
110
599
  });
111
600
  }, this.heartbeatInterval);
601
+ if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
602
+ this._hbTimer.unref();
603
+ }
604
+ }
605
+ _getClientIP(socket, req) {
606
+ if (req) {
607
+ const forwarded = req.headers['x-forwarded-for'];
608
+ if (typeof forwarded === 'string') {
609
+ return forwarded.split(',')[0].trim();
610
+ }
611
+ }
612
+ return socket.remoteAddress || 'unknown';
112
613
  }
113
- handleConnection(client, req) {
614
+ _registerClient(socket, protocol, req, parser) {
615
+ if (this.clients.size >= this.maxConnections) {
616
+ const clientIP = this._getClientIP(socket, req);
617
+ if (this.hooks.onMaxConnectionsReached) {
618
+ this.hooks.onMaxConnectionsReached({
619
+ activeConnections: this.clients.size,
620
+ max: this.maxConnections,
621
+ ip: clientIP,
622
+ });
623
+ }
624
+ this.log.warn('Max connections reached, rejecting', { active: this.clients.size, max: this.maxConnections });
625
+ try {
626
+ if (protocol === 'ws') {
627
+ socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Server full'));
628
+ }
629
+ }
630
+ catch { /* ignore */ }
631
+ socket.destroy();
632
+ return null;
633
+ }
634
+ const clientIP = this._getClientIP(socket, req);
635
+ const ipTracker = this._customIPTracker || this.ipTracker;
636
+ if (!ipTracker.check(clientIP)) {
637
+ this.log.warn('Max connections per IP reached, rejecting', { ip: clientIP });
638
+ try {
639
+ if (protocol === 'ws') {
640
+ socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Too many connections from this IP'));
641
+ }
642
+ }
643
+ catch { /* ignore */ }
644
+ socket.destroy();
645
+ return null;
646
+ }
647
+ const clientId = this._generateClientId ? this._generateClientId() : randomUUID();
648
+ const info = {
649
+ id: clientId,
650
+ rooms: new Set(),
651
+ lastPing: Date.now(),
652
+ protocol,
653
+ connectedAt: Date.now(),
654
+ metadata: new Map(),
655
+ messagesReceived: 0,
656
+ messagesSent: 0,
657
+ remoteAddress: clientIP,
658
+ };
659
+ const record = { info, socket, parser, protocol };
660
+ this.clients.set(socket, record);
661
+ this.clientsById.set(clientId, record);
662
+ ipTracker.add(clientIP);
663
+ this._totalConnections++;
664
+ return record;
665
+ }
666
+ _unregisterClient(record, ctx) {
667
+ if (this.hooks.onClientDisconnect) {
668
+ this.hooks.onClientDisconnect({
669
+ clientId: record.info.id,
670
+ ip: record.info.remoteAddress,
671
+ protocol: record.info.protocol,
672
+ rooms: new Set(record.info.rooms),
673
+ });
674
+ }
675
+ this._removeFromAllRooms(record);
676
+ this.clientsById.delete(record.info.id);
677
+ this.clients.delete(record.socket);
678
+ const ipTracker = this._customIPTracker || this.ipTracker;
679
+ ipTracker.remove(record.info.remoteAddress);
680
+ if (this._customRateLimiter) {
681
+ this._customRateLimiter.reset(record.info.id);
682
+ }
683
+ else if (this.rateLimiter) {
684
+ this.rateLimiter.reset(record.info.id);
685
+ }
686
+ this._clientRateOverrides.delete(record.info.id);
687
+ if (this.events.has('disconnect')) {
688
+ const handler = this.events.get('disconnect');
689
+ try {
690
+ handler({ ...ctx, event: 'disconnect' });
691
+ }
692
+ catch (err) {
693
+ this.log.error('Disconnect handler error', { error: String(err) });
694
+ }
695
+ }
696
+ }
697
+ _checkOrigin(req) {
698
+ if (!this.allowedOrigins)
699
+ return true;
700
+ const origin = req.headers['origin'];
701
+ if (!origin)
702
+ return true;
703
+ return this.allowedOrigins.includes(origin);
704
+ }
705
+ handleWSUpgrade(req, socket, head) {
114
706
  const urlPath = new URL(req.url || '/', 'http://localhost').pathname;
115
707
  const nsPath = this.namespace === '/' ? '/' : this.namespace;
116
708
  if (nsPath !== '/' && urlPath !== nsPath) {
117
- client.close();
709
+ this.log.debug('Rejected WS: wrong namespace', { path: urlPath, expected: nsPath });
710
+ socket.destroy();
118
711
  return;
119
712
  }
120
- const clientId = randomUUID();
121
- const clientInfo = { id: clientId, room: null, lastPing: Date.now() };
122
- this.clients.set(client, clientInfo);
123
- const ctx = {
124
- id: clientId,
125
- socket: client,
126
- req,
127
- emit: (evt, d) => client.send(JSON.stringify({ event: evt, data: d })),
128
- send: (respId, d) => client.send(JSON.stringify({ event: respId, data: d, _isAck: true })),
129
- emitBinary: (evt, buffer) => client.send(buffer),
130
- broadcast: (evt, d) => this.broadcast(evt, d),
131
- broadcastBinary: (evt, buffer) => this.broadcastBinary(evt, buffer),
132
- to: (room, evt, d) => this.to(room, evt, d),
133
- toId: (id, evt, d) => this.toId(id, evt, d),
134
- getClients: (room) => this.getClients(room),
135
- joinRoom: (room) => {
136
- clientInfo.room = room;
137
- client.send(JSON.stringify({ event: 'joined-room', data: room }));
138
- },
139
- leaveRoom: () => {
140
- clientInfo.room = null;
141
- },
142
- ack: (ackName, data) => {
713
+ if (!this._checkOrigin(req)) {
714
+ this.log.warn('Rejected WS: origin not allowed', { origin: req.headers['origin'] });
715
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
716
+ socket.destroy();
717
+ return;
718
+ }
719
+ const key = req.headers['sec-websocket-key'];
720
+ if (!key || !validateWSKey(key)) {
721
+ this.log.warn('Invalid WebSocket key');
722
+ socket.destroy();
723
+ return;
724
+ }
725
+ try {
726
+ const extraHeaders = {};
727
+ const origin = req.headers['origin'];
728
+ if (origin && this.allowedOrigins && this.allowedOrigins.includes(origin)) {
729
+ extraHeaders['Access-Control-Allow-Origin'] = origin;
730
+ }
731
+ socket.write(buildUpgradeResponse(key, extraHeaders));
732
+ }
733
+ catch {
734
+ socket.destroy();
735
+ return;
736
+ }
737
+ const connectTimer = setTimeout(() => {
738
+ if (!this.clients.has(socket)) {
739
+ this.log.warn('WS connect timeout');
740
+ socket.destroy();
741
+ }
742
+ }, this.connectTimeout);
743
+ connectTimer.unref();
744
+ const record = this._registerClient(socket, 'ws', req, new WSFrameParser(this.maxWSFrameSize));
745
+ if (!record) {
746
+ clearTimeout(connectTimer);
747
+ return;
748
+ }
749
+ const ctx = this._buildCtx(record, req);
750
+ if (this.hooks.onClientConnect) {
751
+ this.hooks.onClientConnect({
752
+ clientId: record.info.id,
753
+ ip: record.info.remoteAddress,
754
+ protocol: 'ws',
755
+ metadata: record.info.metadata,
756
+ });
757
+ }
758
+ this.runMiddlewares(ctx, () => {
759
+ if (this._connectionHandler) {
760
+ try {
761
+ this._connectionHandler(ctx);
762
+ }
763
+ catch (err) {
764
+ this.log.error('Connection handler error', { error: String(err) });
765
+ }
766
+ }
767
+ });
768
+ this.log.info('WS client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
769
+ if (head.length > 0) {
770
+ this._processWSData(record, head, ctx, req);
771
+ }
772
+ socket.on('data', (data) => {
773
+ clearTimeout(connectTimer);
774
+ this._processWSData(record, data, ctx, req);
775
+ });
776
+ socket.on('close', () => {
777
+ clearTimeout(connectTimer);
778
+ this.log.debug('WS client socket closed', { clientId: record.info.id });
779
+ this._unregisterClient(record, ctx);
780
+ });
781
+ socket.on('error', (err) => {
782
+ this.log.warn('WS client error', { clientId: record.info.id, error: err.message });
783
+ this._handleError(record, ctx, err);
784
+ });
785
+ socket.on('drain', () => {
786
+ socket.resume();
787
+ });
788
+ }
789
+ _processWSData(record, data, ctx, req) {
790
+ let frames;
791
+ try {
792
+ frames = record.parser.feed(data);
793
+ }
794
+ catch (err) {
795
+ if (err instanceof WebSocketError) {
796
+ this.log.warn('WS protocol error', { clientId: record.info.id, code: err.code, message: err.message });
797
+ try {
798
+ record.socket.write(createWSCloseFrame(err.code, err.message));
799
+ }
800
+ catch { /* ignore */ }
801
+ }
802
+ else {
803
+ this.log.error('WS frame parse error', { clientId: record.info.id, error: String(err) });
804
+ }
805
+ record.socket.destroy();
806
+ return;
807
+ }
808
+ for (const frame of frames) {
809
+ if (record.socket.destroyed)
810
+ break;
811
+ this._handleWSFrame(record, frame, ctx, req);
812
+ }
813
+ }
814
+ _handleWSFrame(record, frame, ctx, _req) {
815
+ const { opcode, payload } = frame;
816
+ if (opcode === OP_PING) {
817
+ try {
818
+ record.socket.write(createWSPongFrame(payload));
819
+ }
820
+ catch { /* ignore */ }
821
+ return;
822
+ }
823
+ if (opcode === OP_CLOSE) {
824
+ try {
825
+ record.socket.write(createWSCloseFrame(CLOSE_NORMAL));
826
+ }
827
+ catch { /* ignore */ }
828
+ record.socket.end();
829
+ return;
830
+ }
831
+ if (opcode === OP_PONG) {
832
+ record.info.lastPing = Date.now();
833
+ return;
834
+ }
835
+ if (!this._checkRateLimit(record.info.id)) {
836
+ this.log.warn('Rate limit exceeded', { clientId: record.info.id });
837
+ if (this.hooks.onRateLimitExceeded) {
838
+ const result = this.hooks.onRateLimitExceeded({
839
+ clientId: record.info.id,
840
+ protocol: 'ws',
841
+ });
842
+ if (result === false)
843
+ return;
844
+ }
845
+ try {
846
+ record.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
847
+ }
848
+ catch { /* ignore */ }
849
+ record.socket.destroy();
850
+ return;
851
+ }
852
+ if (opcode === OP_TEXT) {
853
+ record.info.messagesReceived++;
854
+ this._totalMessagesReceived++;
855
+ if (payload.length > this.maxPayloadSize) {
856
+ if (this.hooks.onPayloadTooLarge) {
857
+ this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
858
+ }
859
+ this.log.warn('Payload too large', { clientId: record.info.id, size: payload.length });
860
+ try {
861
+ record.socket.write(createWSCloseFrame(CLOSE_MESSAGE_TOO_BIG, 'Payload too large'));
862
+ }
863
+ catch { /* ignore */ }
864
+ record.socket.destroy();
865
+ return;
866
+ }
867
+ let msg;
868
+ try {
869
+ msg = JSON.parse(payload.toString('utf8'));
870
+ }
871
+ catch {
872
+ if (this.hooks.onInvalidMessage) {
873
+ this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'ws' });
874
+ }
875
+ this.log.warn('Invalid JSON from client', { clientId: record.info.id });
876
+ return;
877
+ }
878
+ const event = String(msg.event || '');
879
+ const data = msg.data;
880
+ if (!event)
881
+ return;
882
+ if (event && !this._checkRateLimit(record.info.id, event)) {
883
+ this.log.warn('Event rate limit exceeded', { clientId: record.info.id, event });
884
+ if (this.hooks.onRateLimitExceeded) {
885
+ const result = this.hooks.onRateLimitExceeded({
886
+ clientId: record.info.id,
887
+ event,
888
+ protocol: 'ws',
889
+ });
890
+ if (result === false)
891
+ return;
892
+ }
893
+ try {
894
+ record.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
895
+ }
896
+ catch { /* ignore */ }
897
+ record.socket.destroy();
898
+ return;
899
+ }
900
+ if (event === 'pong') {
901
+ record.info.lastPing = Date.now();
902
+ return;
903
+ }
904
+ if (event === 'join-room') {
905
+ const room = String(data);
906
+ if (room)
907
+ this._joinRoom(record, room);
908
+ return;
909
+ }
910
+ if (event === 'leave-room') {
911
+ const room = String(data);
912
+ if (room)
913
+ this._leaveRoom(record, room);
914
+ return;
915
+ }
916
+ if (msg._ackName && this._acks.has(String(msg._ackName))) {
917
+ const ackName = String(msg._ackName);
143
918
  const ackHandler = this._acks.get(ackName);
144
- if (ackHandler) {
919
+ try {
145
920
  const result = ackHandler({ ...ctx, data });
146
921
  if (result !== undefined) {
147
- client.send(JSON.stringify({ event: ackName, data: result, _isAck: true }));
922
+ record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
923
+ this._totalMessagesSent++;
148
924
  }
149
925
  }
926
+ catch (err) {
927
+ this.log.error('ACK handler error', { ackName, error: String(err) });
928
+ }
929
+ return;
150
930
  }
151
- };
152
- this.runMiddlewares(ctx, () => {
153
- if (this._connectionHandler) {
154
- this._connectionHandler(ctx);
931
+ const eventCtx = { ...ctx, data, event };
932
+ const handler = this.events.get(event);
933
+ if (handler) {
934
+ try {
935
+ handler(eventCtx);
936
+ }
937
+ catch (err) {
938
+ this.log.error('Event handler error', { event, error: String(err) });
939
+ }
155
940
  }
156
- });
157
- client.on('message', (raw, isBinary) => {
158
- if (isBinary) {
941
+ if (this._wildcardHandler) {
159
942
  try {
160
- const view = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
161
- let headerEnd = -1;
162
- for (let i = 0; i < view.length; i++) {
163
- if (view[i] === 0) {
164
- headerEnd = i;
165
- break;
166
- }
943
+ this._wildcardHandler({ event, data: eventCtx });
944
+ }
945
+ catch (err) {
946
+ this.log.error('Wildcard handler error', { event, error: String(err) });
947
+ }
948
+ }
949
+ return;
950
+ }
951
+ if (opcode === OP_BINARY) {
952
+ record.info.messagesReceived++;
953
+ this._totalMessagesReceived++;
954
+ if (payload.length > this.maxPayloadSize) {
955
+ if (this.hooks.onPayloadTooLarge) {
956
+ this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
957
+ }
958
+ this.log.warn('Binary payload too large', { clientId: record.info.id, size: payload.length });
959
+ return;
960
+ }
961
+ try {
962
+ let headerEnd = -1;
963
+ for (let i = 0; i < payload.length; i++) {
964
+ if (payload[i] === 0) {
965
+ headerEnd = i;
966
+ break;
167
967
  }
168
- if (headerEnd === -1)
169
- return;
170
- const headerStr = new TextDecoder().decode(view.slice(0, headerEnd));
171
- const header = JSON.parse(headerStr);
172
- const data = view.slice(headerEnd + 1);
173
- const eventCtx = { ...ctx, data, buffer: data, isBinary: true };
174
- const handler = this.events.get(header.event);
175
- if (handler) {
968
+ }
969
+ if (headerEnd === -1)
970
+ return;
971
+ const headerStr = payload.subarray(0, headerEnd).toString('utf8');
972
+ const header = JSON.parse(headerStr);
973
+ const buffer = payload.subarray(headerEnd + 1);
974
+ if (header.event && !this._checkRateLimit(record.info.id, header.event)) {
975
+ this.log.warn('Binary event rate limit exceeded', { clientId: record.info.id, event: header.event });
976
+ if (this.hooks.onRateLimitExceeded) {
977
+ const result = this.hooks.onRateLimitExceeded({ clientId: record.info.id, event: header.event, protocol: 'ws' });
978
+ if (result === false)
979
+ return;
980
+ }
981
+ return;
982
+ }
983
+ const eventCtx = { ...ctx, data: buffer, buffer, isBinary: true, event: header.event };
984
+ const handler = this.events.get(header.event);
985
+ if (handler) {
986
+ try {
176
987
  handler(eventCtx);
177
988
  }
178
- else if (this._wildcardHandler) {
989
+ catch (err) {
990
+ this.log.error('Binary handler error', { error: String(err) });
991
+ }
992
+ }
993
+ if (this._wildcardHandler) {
994
+ try {
179
995
  this._wildcardHandler({ event: header.event, data: eventCtx });
180
996
  }
997
+ catch (err) {
998
+ this.log.error('Wildcard handler error', { error: String(err) });
999
+ }
181
1000
  }
182
- catch { }
183
- return;
184
1001
  }
1002
+ catch {
1003
+ if (this.hooks.onInvalidMessage) {
1004
+ this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid binary frame', protocol: 'ws' });
1005
+ }
1006
+ this.log.warn('Invalid binary frame from client', { clientId: record.info.id });
1007
+ }
1008
+ }
1009
+ }
1010
+ handleTCPConnection(socket) {
1011
+ const record = this._registerClient(socket, 'tcp', null, new FrameParser(this.maxFrameSize));
1012
+ if (!record)
1013
+ return;
1014
+ const ctx = this._buildCtx(record, null);
1015
+ try {
1016
+ socket.write(encodeConnectFrame(record.info.id));
1017
+ }
1018
+ catch {
1019
+ socket.destroy();
1020
+ return;
1021
+ }
1022
+ if (this.hooks.onClientConnect) {
1023
+ this.hooks.onClientConnect({
1024
+ clientId: record.info.id,
1025
+ ip: record.info.remoteAddress,
1026
+ protocol: 'tcp',
1027
+ metadata: record.info.metadata,
1028
+ });
1029
+ }
1030
+ this.runMiddlewares(ctx, () => {
1031
+ if (this._connectionHandler) {
1032
+ try {
1033
+ this._connectionHandler(ctx);
1034
+ }
1035
+ catch (err) {
1036
+ this.log.error('TCP connection handler error', { error: String(err) });
1037
+ }
1038
+ }
1039
+ });
1040
+ this.log.info('TCP client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
1041
+ socket.on('data', (data) => {
1042
+ this._processTCPData(record, data, ctx);
1043
+ });
1044
+ socket.on('close', () => {
1045
+ this.log.debug('TCP client socket closed', { clientId: record.info.id });
1046
+ this._unregisterClient(record, ctx);
1047
+ });
1048
+ socket.on('error', (err) => {
1049
+ this.log.warn('TCP client error', { clientId: record.info.id, error: err.message });
1050
+ this._handleError(record, ctx, err);
1051
+ });
1052
+ socket.on('drain', () => {
1053
+ socket.resume();
1054
+ });
1055
+ }
1056
+ _processTCPData(record, data, ctx) {
1057
+ let frames;
1058
+ try {
1059
+ frames = record.parser.feed(data);
1060
+ }
1061
+ catch (err) {
1062
+ if (err instanceof ProtocolError) {
1063
+ this.log.warn('TCP protocol error', { clientId: record.info.id, code: err.code, message: err.message });
1064
+ try {
1065
+ record.socket.write(encodeErrorFrame(err.message));
1066
+ }
1067
+ catch { /* ignore */ }
1068
+ }
1069
+ record.socket.destroy();
1070
+ return;
1071
+ }
1072
+ for (const frame of frames) {
1073
+ if (record.socket.destroyed)
1074
+ break;
1075
+ this._handleTCPFrame(record, frame, ctx);
1076
+ }
1077
+ }
1078
+ _handleTCPFrame(record, frame, ctx) {
1079
+ const { type, event, payload } = frame;
1080
+ if (type === FRAME_PING) {
185
1081
  try {
186
- const msg = JSON.parse(raw.toString());
187
- const { event, data } = msg;
188
- if (event === 'pong') {
189
- clientInfo.lastPing = Date.now();
1082
+ record.socket.write(encodePongFrame());
1083
+ }
1084
+ catch { /* ignore */ }
1085
+ record.info.lastPing = Date.now();
1086
+ return;
1087
+ }
1088
+ if (type === FRAME_PONG) {
1089
+ record.info.lastPing = Date.now();
1090
+ return;
1091
+ }
1092
+ if (!this._checkRateLimit(record.info.id, event)) {
1093
+ this.log.warn('TCP rate limit exceeded', { clientId: record.info.id, event });
1094
+ if (this.hooks.onRateLimitExceeded) {
1095
+ const result = this.hooks.onRateLimitExceeded({
1096
+ clientId: record.info.id,
1097
+ event: event || undefined,
1098
+ protocol: 'tcp',
1099
+ });
1100
+ if (result === false)
190
1101
  return;
1102
+ }
1103
+ try {
1104
+ record.socket.write(encodeErrorFrame('Rate limit exceeded'));
1105
+ }
1106
+ catch { /* ignore */ }
1107
+ record.socket.destroy();
1108
+ return;
1109
+ }
1110
+ if (type === FRAME_JOIN) {
1111
+ const room = payload.toString('utf8');
1112
+ if (room)
1113
+ this._joinRoom(record, room);
1114
+ return;
1115
+ }
1116
+ if (type === FRAME_LEAVE) {
1117
+ const room = payload.toString('utf8');
1118
+ if (room)
1119
+ this._leaveRoom(record, room);
1120
+ return;
1121
+ }
1122
+ if (type === FRAME_CONNECT) {
1123
+ return;
1124
+ }
1125
+ if (payload.length > this.maxPayloadSize) {
1126
+ if (this.hooks.onPayloadTooLarge) {
1127
+ this.hooks.onPayloadTooLarge({ clientId: record.info.id, event, size: payload.length, max: this.maxPayloadSize });
1128
+ }
1129
+ this.log.warn('TCP payload too large', { clientId: record.info.id, size: payload.length });
1130
+ return;
1131
+ }
1132
+ record.info.messagesReceived++;
1133
+ this._totalMessagesReceived++;
1134
+ if (type === FRAME_JSON) {
1135
+ let data;
1136
+ try {
1137
+ data = JSON.parse(payload.toString('utf8'));
1138
+ }
1139
+ catch {
1140
+ if (this.hooks.onInvalidMessage) {
1141
+ this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'tcp' });
191
1142
  }
192
- if (event === 'join-room') {
193
- if (typeof data === 'string') {
194
- clientInfo.room = data;
195
- client.send(JSON.stringify({ event: 'joined-room', data }));
196
- }
1143
+ this.log.warn('Invalid TCP JSON', { clientId: record.info.id });
1144
+ return;
1145
+ }
1146
+ const eventCtx = { ...ctx, data, event };
1147
+ const handler = this.events.get(event);
1148
+ if (handler) {
1149
+ try {
1150
+ handler(eventCtx);
1151
+ }
1152
+ catch (err) {
1153
+ this.log.error('TCP event handler error', { event, error: String(err) });
1154
+ }
1155
+ }
1156
+ if (this._wildcardHandler) {
1157
+ try {
1158
+ this._wildcardHandler({ event, data: eventCtx });
197
1159
  }
198
- if (event === 'leave-room') {
199
- clientInfo.room = null;
200
- client.send(JSON.stringify({ event: 'left-room', data }));
1160
+ catch (err) {
1161
+ this.log.error('TCP wildcard handler error', { error: String(err) });
201
1162
  }
202
- if (msg._ackName && this._acks.has(msg._ackName)) {
203
- const ackHandler = this._acks.get(msg._ackName);
1163
+ }
1164
+ return;
1165
+ }
1166
+ if (type === FRAME_ACK_REQ) {
1167
+ if (this._acks.has(event)) {
1168
+ try {
1169
+ const parsed = JSON.parse(payload.toString('utf8'));
1170
+ const data = parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
1171
+ const ackHandler = this._acks.get(event);
204
1172
  const result = ackHandler({ ...ctx, data });
205
1173
  if (result !== undefined) {
206
- client.send(JSON.stringify({ event: msg._ackName, data: result, _isAck: true }));
1174
+ record.socket.write(encodeAckResFrame(event, result, this.maxFrameSize));
1175
+ this._totalMessagesSent++;
207
1176
  }
208
- return;
209
1177
  }
210
- const eventCtx = { ...ctx, data };
211
- const handler = this.events.get(event);
212
- if (handler) {
1178
+ catch (err) {
1179
+ this.log.error('TCP ACK handler error', { event, error: String(err) });
1180
+ }
1181
+ }
1182
+ return;
1183
+ }
1184
+ if (type === FRAME_ACK_RES) {
1185
+ if (this._acks.has(event)) {
1186
+ try {
1187
+ const data = JSON.parse(payload.toString('utf8'));
1188
+ const ackHandler = this._acks.get(event);
1189
+ ackHandler({ ...ctx, data });
1190
+ }
1191
+ catch { /* ignore */ }
1192
+ }
1193
+ return;
1194
+ }
1195
+ if (type === FRAME_BINARY) {
1196
+ const eventCtx = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
1197
+ const handler = this.events.get(event);
1198
+ if (handler) {
1199
+ try {
213
1200
  handler(eventCtx);
214
1201
  }
215
- else if (this._wildcardHandler) {
1202
+ catch (err) {
1203
+ this.log.error('TCP binary handler error', { event, error: String(err) });
1204
+ }
1205
+ }
1206
+ if (this._wildcardHandler) {
1207
+ try {
216
1208
  this._wildcardHandler({ event, data: eventCtx });
217
1209
  }
1210
+ catch (err) {
1211
+ this.log.error('TCP wildcard handler error', { error: String(err) });
1212
+ }
218
1213
  }
219
- catch { }
220
- });
221
- client.on('close', () => {
222
- const info = this.clients.get(client);
223
- if (this.events.has('disconnect') && info) {
224
- const handler = this.events.get('disconnect');
225
- handler({ id: info.id, socket: client, req: req, emit: () => { }, send: () => { }, emitBinary: () => { }, broadcast: () => { }, broadcastBinary: () => { }, to: () => { }, toId: () => { }, getClients: () => [], joinRoom: () => { }, leaveRoom: () => { }, ack: () => { } });
1214
+ return;
1215
+ }
1216
+ }
1217
+ _handleError(record, ctx, err) {
1218
+ if (this.events.has('error')) {
1219
+ const handler = this.events.get('error');
1220
+ try {
1221
+ handler({ ...ctx, error: err, event: 'error' });
226
1222
  }
227
- this.clients.delete(client);
228
- });
229
- client.on('error', (err) => {
230
- if (this.events.has('error')) {
231
- const handler = this.events.get('error');
232
- handler({ id: clientId, socket: client, req: req, emit: () => { }, send: () => { }, emitBinary: () => { }, broadcast: () => { }, broadcastBinary: () => { }, to: () => { }, toId: () => { }, getClients: () => [], joinRoom: () => { }, leaveRoom: () => { }, ack: () => { }, error: err });
1223
+ catch (handlerErr) {
1224
+ this.log.error('Error handler threw', { error: String(handlerErr) });
233
1225
  }
234
- });
1226
+ }
1227
+ }
1228
+ _handleHealthCheck(req, res) {
1229
+ if (this._customHealthHandler) {
1230
+ const stats = this.getStats();
1231
+ try {
1232
+ this._customHealthHandler(req, res, stats);
1233
+ }
1234
+ catch (err) {
1235
+ this.log.error('Custom health handler error', { error: String(err) });
1236
+ if (!res.headersSent) {
1237
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1238
+ res.end(JSON.stringify({ status: 'error', message: 'Health check handler failed' }));
1239
+ }
1240
+ }
1241
+ return;
1242
+ }
1243
+ const origin = req.headers['origin'];
1244
+ if (origin && (!this.allowedOrigins || this.allowedOrigins.includes(origin))) {
1245
+ res.setHeader('Access-Control-Allow-Origin', origin);
1246
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
1247
+ res.setHeader('Access-Control-Max-Age', '86400');
1248
+ }
1249
+ if (req.method === 'OPTIONS') {
1250
+ res.writeHead(204);
1251
+ res.end();
1252
+ return;
1253
+ }
1254
+ if (this.healthEndpoint && req.url === this.healthEndpoint && req.method === 'GET') {
1255
+ const stats = this.getStats();
1256
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1257
+ res.end(JSON.stringify({
1258
+ status: 'ok',
1259
+ ...stats,
1260
+ uptimeSeconds: Math.floor(stats.uptime / 1000),
1261
+ memoryMB: Math.round(stats.memoryUsage.heapUsed / 1024 / 1024 * 100) / 100,
1262
+ }));
1263
+ return;
1264
+ }
1265
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
1266
+ res.end('Stelar Time Real v3 Server');
1267
+ }
1268
+ /** Register a callback for when graceful shutdown completes. */
1269
+ onShutdown(callback) {
1270
+ this._shutdownCallbacks.push(callback);
1271
+ return this;
1272
+ }
1273
+ _emitShutdown(signal, force) {
1274
+ for (const cb of this._shutdownCallbacks) {
1275
+ try {
1276
+ cb(signal, force);
1277
+ }
1278
+ catch { /* ignore */ }
1279
+ }
1280
+ if (this._shutdownCallbacks.length === 0) {
1281
+ process.exit(force ? 1 : 0);
1282
+ }
1283
+ }
1284
+ _setupGracefulShutdown() {
1285
+ if (!this.doGracefulShutdown)
1286
+ return;
1287
+ let isShuttingDown = false;
1288
+ const shutdown = (signal) => {
1289
+ if (isShuttingDown)
1290
+ return;
1291
+ isShuttingDown = true;
1292
+ this._shuttingDown = true;
1293
+ this.log.info(`Received ${signal}, shutting down gracefully...`);
1294
+ this.stop();
1295
+ const clientCount = this.clients.size;
1296
+ if (clientCount === 0) {
1297
+ this.log.info('No active connections, shutdown complete');
1298
+ this._emitShutdown(signal, false);
1299
+ return;
1300
+ }
1301
+ this.log.info(`Waiting for ${clientCount} connections to close (timeout: ${this.shutdownTimeout}ms)`);
1302
+ this.clients.forEach((record) => {
1303
+ try {
1304
+ if (record.protocol === 'ws') {
1305
+ record.socket.write(createWSCloseFrame(CLOSE_GOING_AWAY, 'Server shutting down'));
1306
+ }
1307
+ else {
1308
+ record.socket.write(encodeDisconnectFrame());
1309
+ }
1310
+ record.socket.end();
1311
+ }
1312
+ catch { /* ignore */ }
1313
+ });
1314
+ const forceTimeout = setTimeout(() => {
1315
+ this.log.warn('Shutdown timeout reached, force closing remaining connections');
1316
+ this.clients.forEach((record) => {
1317
+ try {
1318
+ record.socket.destroy();
1319
+ }
1320
+ catch { /* ignore */ }
1321
+ });
1322
+ this.clients.clear();
1323
+ this.clientsById.clear();
1324
+ this._emitShutdown(signal, true);
1325
+ }, this.shutdownTimeout);
1326
+ forceTimeout.unref();
1327
+ const checkInterval = setInterval(() => {
1328
+ if (this.clients.size === 0) {
1329
+ clearInterval(checkInterval);
1330
+ clearTimeout(forceTimeout);
1331
+ this.log.info('All connections closed, shutdown complete');
1332
+ this._emitShutdown(signal, false);
1333
+ }
1334
+ }, 100);
1335
+ checkInterval.unref();
1336
+ };
1337
+ this._sigintHandler = () => shutdown('SIGINT');
1338
+ this._sigtermHandler = () => shutdown('SIGTERM');
1339
+ process.on('SIGINT', this._sigintHandler);
1340
+ process.on('SIGTERM', this._sigtermHandler);
1341
+ }
1342
+ _removeSignalHandlers() {
1343
+ if (this._sigintHandler) {
1344
+ process.off('SIGINT', this._sigintHandler);
1345
+ this._sigintHandler = null;
1346
+ }
1347
+ if (this._sigtermHandler) {
1348
+ process.off('SIGTERM', this._sigtermHandler);
1349
+ this._sigtermHandler = null;
1350
+ }
235
1351
  }
236
1352
  start(callback) {
1353
+ if (this._started) {
1354
+ const port = this.getPort();
1355
+ if (callback)
1356
+ callback(port);
1357
+ return Promise.resolve(port);
1358
+ }
1359
+ this._started = true;
1360
+ this._startTime = Date.now();
237
1361
  return new Promise((resolve) => {
238
- const startServer = (httpServer) => {
239
- this.server = httpServer;
240
- this.wss = new WebSocketServer({ server: httpServer });
241
- this.wss.on('connection', (client, req) => this.handleConnection(client, req));
1362
+ const startHttpServer = (httpServer) => {
1363
+ this.httpServer = httpServer;
1364
+ this._requestHandler = (req, res) => {
1365
+ this._handleHealthCheck(req, res);
1366
+ };
1367
+ httpServer.on('request', this._requestHandler);
1368
+ this._upgradeHandler = (req, socket, head) => {
1369
+ this.handleWSUpgrade(req, socket, head);
1370
+ };
1371
+ httpServer.on('upgrade', this._upgradeHandler);
242
1372
  this.startHeartbeat();
1373
+ this._rateCleanupTimer = setInterval(() => {
1374
+ if (this._customRateLimiter) {
1375
+ this._customRateLimiter.cleanup();
1376
+ }
1377
+ else if (this.rateLimiter) {
1378
+ this.rateLimiter.cleanup();
1379
+ }
1380
+ const ipTracker = this._customIPTracker || this.ipTracker;
1381
+ ipTracker.cleanup();
1382
+ for (const [clientId, limiter] of this._clientRateOverrides) {
1383
+ limiter.cleanup();
1384
+ if (!this.clientsById.has(clientId)) {
1385
+ this._clientRateOverrides.delete(clientId);
1386
+ }
1387
+ }
1388
+ for (const [, limiter] of this.eventRateLimiters) {
1389
+ limiter.cleanup();
1390
+ }
1391
+ }, 30000);
1392
+ if (this._rateCleanupTimer && typeof this._rateCleanupTimer === 'object' && 'unref' in this._rateCleanupTimer) {
1393
+ this._rateCleanupTimer.unref();
1394
+ }
1395
+ this._setupGracefulShutdown();
243
1396
  const finalPort = this.getPort();
1397
+ this.log.info('Server started', { port: finalPort, namespace: this.namespace, tls: !!this.tlsOptions });
244
1398
  if (callback)
245
1399
  callback(finalPort);
246
1400
  resolve(finalPort);
247
1401
  };
248
- if (this.server) {
249
- this._externalServers.add(this.server);
250
- startServer(this.server);
1402
+ if (this.httpServer) {
1403
+ this._externalServers.add(this.httpServer);
1404
+ startHttpServer(this.httpServer);
251
1405
  }
252
1406
  else {
253
1407
  const tryListen = (port) => {
254
- this.server = createServer((_, res) => {
255
- res.writeHead(200, { 'Content-Type': 'text/plain' });
256
- res.end('Stelar Time Real Server');
257
- });
258
- this.server.on('error', (err) => {
1408
+ const httpServer = this.tlsOptions
1409
+ ? createHttpServer()
1410
+ : createHttpServer();
1411
+ httpServer.on('error', (err) => {
259
1412
  if (err.code === 'EADDRINUSE' && port < 65535) {
260
1413
  tryListen(port + 1);
261
1414
  }
1415
+ else {
1416
+ this.log.error('HTTP server error', { error: err.message });
1417
+ }
262
1418
  });
263
- this.server.listen(port, () => {
1419
+ httpServer.listen(port, () => {
264
1420
  this.port = port;
265
- startServer(this.server);
1421
+ startHttpServer(httpServer);
266
1422
  });
267
1423
  };
268
1424
  tryListen(this.port);
269
1425
  }
1426
+ if (this.tcpPort !== false) {
1427
+ const tcpPortNum = typeof this.tcpPort === 'number' ? this.tcpPort : this.port + 1;
1428
+ this._startTCPServer(tcpPortNum);
1429
+ }
1430
+ });
1431
+ }
1432
+ _startTCPServer(port, attempts = 0) {
1433
+ const tcpHandler = (socket) => this.handleTCPConnection(socket);
1434
+ if (this.tlsOptions) {
1435
+ try {
1436
+ const tlsServer = createTlsServer(this.tlsOptions, tcpHandler);
1437
+ this.tcpServer = tlsServer;
1438
+ this.tcpServer.on('error', (err) => {
1439
+ if (err.code === 'EADDRINUSE' && attempts < 10) {
1440
+ this.log.info(`TLS TCP port ${port} in use, trying ${port + 1}`);
1441
+ this.tcpServer = null;
1442
+ this._startTCPServer(port + 1, attempts + 1);
1443
+ }
1444
+ else {
1445
+ this.log.error('TLS TCP server error', { error: err.message });
1446
+ }
1447
+ });
1448
+ this.tcpServer.listen(port, () => {
1449
+ this.log.info('TLS TCP server started', { port });
1450
+ });
1451
+ }
1452
+ catch (err) {
1453
+ this.log.error('Failed to create TLS TCP server', { error: String(err) });
1454
+ this._startPlainTCPServer(port, attempts, tcpHandler);
1455
+ }
1456
+ }
1457
+ else {
1458
+ this._startPlainTCPServer(port, attempts, tcpHandler);
1459
+ }
1460
+ }
1461
+ _startPlainTCPServer(port, attempts, tcpHandler) {
1462
+ this.tcpServer = createTcpServer(tcpHandler);
1463
+ this.tcpServer.on('error', (err) => {
1464
+ if (err.code === 'EADDRINUSE' && attempts < 10) {
1465
+ this.log.info(`TCP port ${port} in use, trying ${port + 1}`);
1466
+ this.tcpServer = null;
1467
+ this._startTCPServer(port + 1, attempts + 1);
1468
+ }
1469
+ else {
1470
+ this.log.error('TCP server error', { error: err.message });
1471
+ }
1472
+ });
1473
+ this.tcpServer.listen(port, () => {
1474
+ this.log.info('TCP server started', { port });
270
1475
  });
271
1476
  }
272
1477
  stop() {
273
- if (this._hbTimer)
1478
+ if (this._hbTimer) {
274
1479
  clearInterval(this._hbTimer);
275
- if (this.wss)
276
- this.wss.close();
277
- if (this.server && !this._externalServers.has(this.server))
278
- this.server.close();
1480
+ this._hbTimer = null;
1481
+ }
1482
+ if (this._rateCleanupTimer) {
1483
+ clearInterval(this._rateCleanupTimer);
1484
+ this._rateCleanupTimer = null;
1485
+ }
1486
+ this.clients.forEach((record) => {
1487
+ if (!record.socket.destroyed) {
1488
+ record.socket.destroy();
1489
+ }
1490
+ });
1491
+ this.clients.clear();
1492
+ this.clientsById.clear();
1493
+ this.rooms.clear();
1494
+ this._clientRateOverrides.clear();
1495
+ if (this.httpServer) {
1496
+ if (this._upgradeHandler) {
1497
+ this.httpServer.off('upgrade', this._upgradeHandler);
1498
+ this._upgradeHandler = null;
1499
+ }
1500
+ if (this._requestHandler) {
1501
+ this.httpServer.off('request', this._requestHandler);
1502
+ this._requestHandler = null;
1503
+ }
1504
+ if (!this._externalServers.has(this.httpServer)) {
1505
+ this.httpServer.close();
1506
+ }
1507
+ this.httpServer = null;
1508
+ }
1509
+ if (this.tcpServer) {
1510
+ this.tcpServer.close();
1511
+ this.tcpServer = null;
1512
+ }
1513
+ this._started = false;
1514
+ this._removeSignalHandlers();
1515
+ this.log.info('Server stopped');
279
1516
  return this;
280
1517
  }
281
1518
  }
282
1519
  export default StelarServer;
283
1520
  export { StelarServer };
284
1521
  export { default as StelarClient } from './client.js';
1522
+ export { Logger, NULL_LOGGER } from './logger.js';
1523
+ export { ProtocolError, validateEventName, DEFAULT_MAX_FRAME_SIZE, MAX_EVENT_LENGTH, HEADER_SIZE } from './protocol.js';
1524
+ export { WebSocketError, DEFAULT_MAX_WS_FRAME_SIZE, CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_INVALID_PAYLOAD, CLOSE_UNSUPPORTED } from './websocket.js';