stelar-time-real 2.0.4 → 3.2.1

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