stelar-time-real 3.2.1 → 3.3.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.ts CHANGED
@@ -1,264 +1,73 @@
1
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.
2
+ * @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP
6
3
  */
7
4
 
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';
5
+ import { createServer as createHttp, IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
6
+ import { createServer as createTcp, Server as TcpServer, Socket as NetSocket } from 'net';
10
7
  import { randomUUID } from 'crypto';
11
- import type { TlsOptions } from 'tls';
12
- import { createServer as createTlsServer } from 'tls';
8
+ import { createServer as createTls, TlsOptions } from 'tls';
13
9
 
14
10
  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,
11
+ FrameParser, ParsedFrame, encodeJsonFrame, encodeBinaryFrame, encodePingFrame, encodePongFrame,
12
+ encodeAckResFrame, encodeConnectFrame, encodeDisconnectFrame, encodeJoinFrame, encodeLeaveFrame,
13
+ encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ, FRAME_ACK_RES,
14
+ FRAME_JOIN, FRAME_LEAVE, FRAME_CONNECT, ProtocolError, DEFAULT_MAX_FRAME_SIZE,
38
15
  } from './protocol.js';
39
16
 
40
17
  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,
18
+ WSFrameParser, WSFrame, buildUpgradeResponse, validateWSKey, createWSTextFrame,
19
+ createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame,
20
+ OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError,
21
+ CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_NORMAL, CLOSE_GOING_AWAY,
61
22
  DEFAULT_MAX_WS_FRAME_SIZE,
62
23
  } from './websocket.js';
63
24
 
64
25
  import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
65
26
 
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
- }
27
+ /* ── Interfaces ── */
76
28
 
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
- }
29
+ export interface IRateLimiter { check(id: string, cost?: number): boolean; reset(id: string): void; cleanup(): void; size(): number; }
30
+ export interface IIPTracker { check(ip: string): boolean; add(ip: string): void; remove(ip: string): void; getCount(ip: string): number; cleanup(): void; }
89
31
 
90
32
  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;
33
+ onRateLimitExceeded?: (i: { clientId: string; event?: string; protocol: 'ws' | 'tcp' }) => boolean | void;
34
+ onMaxConnectionsReached?: (i: { activeConnections: number; max: number; ip: string }) => void;
35
+ onMaxRoomsReached?: (i: { clientId: string; room: string; totalRooms: number; max: number }) => boolean | void;
36
+ onMaxRoomsPerClientReached?: (i: { clientId: string; room: string; currentRooms: number; max: number }) => boolean | void;
37
+ onPayloadTooLarge?: (i: { clientId: string; event?: string; size: number; max: number }) => void;
38
+ onInvalidMessage?: (i: { clientId: string; reason: string; protocol: 'ws' | 'tcp' }) => void;
39
+ onClientJoinRoom?: (i: { clientId: string; room: string; metadata: Map<string, unknown> }) => boolean | void;
40
+ onClientLeaveRoom?: (i: { clientId: string; room: string }) => boolean | void;
41
+ onBeforeBroadcast?: (i: { event: string; data: unknown; excludeId?: string }) => boolean | void;
42
+ onClientConnect?: (i: { clientId: string; ip: string; protocol: 'ws' | 'tcp'; metadata: Map<string, unknown> }) => void;
43
+ onClientDisconnect?: (i: { clientId: string; ip: string; protocol: 'ws' | 'tcp'; rooms: Set<string> }) => void;
113
44
  }
114
45
 
115
46
  export type EventRateLimits = Record<string, { maxPoints: number; windowMs: number }>;
116
47
 
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
- }
203
-
204
48
  export interface StelarOptions {
205
- port?: number;
206
- server?: HttpServer;
207
- namespace?: string;
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;
49
+ port?: number; server?: HttpServer; namespace?: string;
50
+ heartbeatInterval?: number; heartbeatTimeout?: number; tcpPort?: number | false;
51
+ maxConnections?: number; maxConnectionsPerIP?: number; maxRooms?: number;
52
+ maxRoomsPerClient?: number; maxPayloadSize?: number; maxFrameSize?: number;
53
+ rateLimit?: { maxPoints?: number; windowMs?: number } | false; connectTimeout?: number;
54
+ gracefulShutdown?: boolean; shutdownTimeout?: number; healthEndpoint?: string | false;
55
+ logger?: Logger | LogLevel | false; tls?: TlsOptions; allowedOrigins?: string[];
56
+ customRateLimiter?: IRateLimiter; customIPTracker?: IIPTracker;
57
+ generateClientId?: () => string; eventRateLimits?: EventRateLimits;
58
+ hooks?: StelarHooks; customHealthHandler?: (req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void;
238
59
  }
239
60
 
240
61
  export interface StelarClientInfo {
241
- id: string;
242
- rooms: Set<string>;
243
- lastPing: number;
244
- protocol: 'ws' | 'tcp';
245
- connectedAt: number;
246
- metadata: Map<string, unknown>;
247
- messagesReceived: number;
248
- messagesSent: number;
249
- remoteAddress: string;
62
+ id: string; rooms: Set<string>; lastPing: number; protocol: 'ws' | 'tcp';
63
+ connectedAt: number; metadata: Map<string, unknown>; messagesReceived: number;
64
+ messagesSent: number; remoteAddress: string;
250
65
  }
251
66
 
252
67
  export interface StelarContext {
253
- id: string;
254
- socket: NetSocket;
255
- req: IncomingMessage | null;
256
- data?: unknown;
257
- buffer?: Uint8Array;
258
- isBinary?: boolean;
259
- event?: string;
260
- error?: Error;
261
- clientInfo: StelarClientInfo;
68
+ id: string; socket: NetSocket; req: IncomingMessage | null; data?: unknown;
69
+ buffer?: Uint8Array; isBinary?: boolean; event?: string; error?: Error;
70
+ _correlationId?: string; clientInfo: StelarClientInfo;
262
71
  emit: (event: string, data: unknown) => void;
263
72
  send: (respId: string, data: unknown) => void;
264
73
  emitBinary: (event: string, buffer: ArrayBuffer) => void;
@@ -267,1186 +76,561 @@ export interface StelarContext {
267
76
  to: (room: string, event: string, data: unknown) => void;
268
77
  toId: (id: string, event: string, data: unknown) => void;
269
78
  getClients: (room?: string) => { id: string; rooms: string[] }[];
270
- joinRoom: (room: string) => void;
271
- leaveRoom: (room: string) => void;
272
- setMetadata: (key: string, value: unknown) => void;
273
- getMetadata: (key: string) => unknown;
79
+ joinRoom: (room: string) => void; leaveRoom: (room: string) => void;
80
+ setMetadata: (key: string, value: unknown) => void; getMetadata: (key: string) => unknown;
274
81
  ack: (ackName: string, data: unknown) => void;
275
82
  }
276
83
 
277
- export interface StelarMiddleware {
278
- (ctx: StelarContext, next: () => void): void;
279
- }
280
-
84
+ export interface StelarMiddleware { (ctx: StelarContext, next: () => void): void; }
281
85
  export type StelarEventHandler = (ctx: StelarContext) => void;
282
86
  export type StelarWildcardHandler = (data: { event: string; data: StelarContext }) => void;
283
87
 
284
88
  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;
89
+ totalConnections: number; activeConnections: number;
90
+ totalMessagesReceived: number; totalMessagesSent: number;
91
+ totalRooms: number; uptime: number; wsConnections: number;
92
+ tcpConnections: number; memoryUsage: NodeJS.MemoryUsage; rateLimiterEntries: number;
295
93
  }
296
94
 
297
- interface ClientRecord {
298
- info: StelarClientInfo;
299
- socket: NetSocket;
300
- parser: WSFrameParser | FrameParser;
301
- protocol: 'ws' | 'tcp';
95
+ /* ── Internal helpers ── */
96
+
97
+ interface ClientRecord { info: StelarClientInfo; socket: NetSocket; parser: WSFrameParser | FrameParser; protocol: 'ws' | 'tcp'; }
98
+
99
+ class RateLimiter implements IRateLimiter {
100
+ private limits = new Map<string, { count: number; resetTime: number }>();
101
+ constructor(private maxPts = 100, private winMs = 1000) {}
102
+ check(id: string, cost = 1): boolean {
103
+ const now = Date.now(); let e = this.limits.get(id);
104
+ if (!e || now >= e.resetTime) { e = { count: 0, resetTime: now + this.winMs }; this.limits.set(id, e); }
105
+ if (e.count + cost > this.maxPts) return false;
106
+ e.count += cost; return true;
107
+ }
108
+ cleanup() { const now = Date.now(); for (const [id, e] of this.limits) if (now >= e.resetTime) this.limits.delete(id); }
109
+ reset(id: string) { this.limits.delete(id); }
110
+ size() { return this.limits.size; }
302
111
  }
303
112
 
113
+ class IPTracker implements IIPTracker {
114
+ private m = new Map<string, number>();
115
+ constructor(private max = 50) {}
116
+ check(ip: string) { return (this.m.get(ip) || 0) < this.max; }
117
+ add(ip: string) { this.m.set(ip, (this.m.get(ip) || 0) + 1); }
118
+ remove(ip: string) { const c = this.m.get(ip) || 0; c <= 1 ? this.m.delete(ip) : this.m.set(ip, c - 1); }
119
+ getCount(ip: string) { return this.m.get(ip) || 0; }
120
+ cleanup() { for (const [ip, c] of this.m) if (c <= 0) this.m.delete(ip); }
121
+ }
122
+
123
+ /* ── Server ── */
124
+
304
125
  class StelarServer {
305
126
  private port: number;
306
127
  private httpServer: HttpServer | null = null;
307
128
  private tcpServer: TcpServer | null = null;
308
- private namespace: string;
309
- private heartbeatInterval: number;
310
- private heartbeatTimeout: number;
129
+ private ns: string;
130
+ private hbInterval: number;
131
+ private hbTimeout: number;
311
132
  private tcpPort: number | false;
312
- private maxConnections: number;
133
+ private maxConns: number;
313
134
  private maxRooms: number;
314
135
  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;
136
+ private maxPayload: number;
137
+ private maxFrame: number;
138
+ private maxWSFrame: number;
139
+ private connTimeout: number;
140
+ private doGraceful: boolean;
141
+ private shutdownMs: number;
142
+ private healthPath: string | false;
143
+ private tlsOpts: TlsOptions | undefined;
144
+ private origins: string[] | null;
145
+ private _crl: IRateLimiter | null;
146
+ private _cit: IIPTracker | null;
147
+ private _genId: (() => string) | null;
148
+ private _healthFn: ((req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void) | null;
329
149
  private hooks: StelarHooks;
330
- private eventRateLimiters: Map<string, RateLimiter>;
331
- private _clientRateOverrides: Map<string, RateLimiter>;
150
+ private evRateLimits = new Map<string, RateLimiter>();
151
+ private clientRates = new Map<string, RateLimiter>();
332
152
 
333
153
  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
336
- private events: Map<string, StelarEventHandler> = new Map();
337
- private middlewares: StelarMiddleware[] = [];
338
- private _hbTimer: ReturnType<typeof setInterval> | null = null;
339
- private _rateCleanupTimer: ReturnType<typeof setInterval> | null = null;
340
- private _wildcardHandler: StelarWildcardHandler | null = null;
341
- private _connectionHandler: StelarEventHandler | null = null;
342
- private _acks: Map<string, StelarEventHandler> = new Map();
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;
154
+ private byId = new Map<string, ClientRecord>();
155
+ private rooms = new Map<string, Set<string>>();
156
+ private events = new Map<string, StelarEventHandler>();
157
+ private mw: StelarMiddleware[] = [];
158
+ private _hb: ReturnType<typeof setInterval> | null = null;
159
+ private _rc: ReturnType<typeof setInterval> | null = null;
160
+ private _wild: StelarWildcardHandler | null = null;
161
+ private _connH: StelarEventHandler | null = null;
162
+ private _acks = new Map<string, StelarEventHandler>();
163
+ private _ext = new WeakSet<HttpServer>();
164
+ private _upgH: ((req: IncomingMessage, socket: NetSocket, head: Buffer) => void) | null = null;
165
+ private _reqH: ((req: IncomingMessage, res: ServerResponse) => void) | null = null;
346
166
  private _started = false;
347
167
  private _startTime = 0;
348
- private _shuttingDown = false;
349
- private _sigintHandler: (() => void) | null = null;
350
- private _sigtermHandler: (() => void) | null = null;
351
-
168
+ private _shutting = false;
169
+ private _sigH: { int: (() => void) | null; term: (() => void) | null } = { int: null, term: null };
352
170
  private rateLimiter: RateLimiter | null;
353
-
354
- private ipTracker: IPConnectionTracker;
355
-
356
- private _totalConnections = 0;
357
- private _totalMessagesReceived = 0;
358
- private _totalMessagesSent = 0;
359
-
171
+ private ipTracker: IPTracker;
172
+ private _totalConns = 0;
173
+ private _totalRecv = 0;
174
+ private _totalSent = 0;
175
+ private _shutdownCbs: Array<(sig: string, force: boolean) => void> = [];
360
176
  private log: Logger;
361
177
 
362
- constructor(options: StelarOptions = {}) {
363
- this.port = options.port || 3000;
364
- this.httpServer = options.server || null;
365
- this.namespace = options.namespace || '/';
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
- }
421
- }
422
-
423
- static of(path: string, options: StelarOptions = {}): StelarServer {
424
- return new StelarServer({ ...options, namespace: path });
425
- }
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));
178
+ constructor(o: StelarOptions = {}) {
179
+ this.port = o.port || 3000;
180
+ this.httpServer = o.server || null;
181
+ this.ns = o.namespace || '/';
182
+ this.hbInterval = o.heartbeatInterval || 30000;
183
+ this.hbTimeout = o.heartbeatTimeout || this.hbInterval * 2;
184
+ this.tcpPort = o.tcpPort !== undefined ? o.tcpPort : false;
185
+ this.maxConns = o.maxConnections || 10000;
186
+ this.maxRooms = o.maxRooms || 10000;
187
+ this.maxRoomsPerClient = o.maxRoomsPerClient || 50;
188
+ this.maxPayload = o.maxPayloadSize || 10 * 1024 * 1024;
189
+ this.maxFrame = o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE;
190
+ this.maxWSFrame = o.maxFrameSize || DEFAULT_MAX_WS_FRAME_SIZE;
191
+ this.connTimeout = o.connectTimeout || 10000;
192
+ this.doGraceful = o.gracefulShutdown !== false;
193
+ this.shutdownMs = o.shutdownTimeout || 10000;
194
+ this.healthPath = o.healthEndpoint !== undefined ? o.healthEndpoint : '/health';
195
+ this.tlsOpts = o.tls;
196
+ this.origins = o.allowedOrigins || null;
197
+ this._crl = o.customRateLimiter || null;
198
+ this._cit = o.customIPTracker || null;
199
+ this._genId = o.generateClientId || null;
200
+ this._healthFn = o.customHealthHandler || null;
201
+ this.hooks = o.hooks || {};
202
+ if (o.eventRateLimits) for (const [ev, c] of Object.entries(o.eventRateLimits)) this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs));
203
+ const rl = o.rateLimit && typeof o.rateLimit === 'object' ? o.rateLimit : {};
204
+ this.rateLimiter = o.rateLimit === false && !this._crl ? null : this._crl ? null : new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
205
+ this.ipTracker = this._cit ? new IPTracker() : new IPTracker(o.maxConnectionsPerIP || 50);
206
+ this.log = o.logger === false ? NULL_LOGGER : o.logger instanceof Logger ? o.logger : new Logger({ level: (o.logger as LogLevel) || 'info', prefix: 'stelar:server' });
207
+ }
208
+
209
+ static of(path: string, o: StelarOptions = {}) { return new StelarServer({ ...o, namespace: path }); }
210
+
211
+ /* ── Runtime config ── */
212
+
213
+ updateConfig(o: Partial<StelarOptions>): this {
214
+ if (o.maxConnections !== undefined) this.maxConns = o.maxConnections;
215
+ if (o.maxConnectionsPerIP !== undefined && !this._cit) this.ipTracker = new IPTracker(o.maxConnectionsPerIP);
216
+ if (o.maxRooms !== undefined) this.maxRooms = o.maxRooms;
217
+ if (o.maxRoomsPerClient !== undefined) this.maxRoomsPerClient = o.maxRoomsPerClient;
218
+ if (o.maxPayloadSize !== undefined) this.maxPayload = o.maxPayloadSize;
219
+ if (o.heartbeatInterval !== undefined) this.hbInterval = o.heartbeatInterval;
220
+ if (o.heartbeatTimeout !== undefined) this.hbTimeout = o.heartbeatTimeout;
221
+ if (o.allowedOrigins !== undefined) this.origins = o.allowedOrigins;
222
+ if (o.rateLimit === false) { this.rateLimiter = null; this._crl = null; }
223
+ else if (o.rateLimit && !this._crl) this.rateLimiter = new RateLimiter(o.rateLimit.maxPoints || 100, o.rateLimit.windowMs || 1000);
224
+ if (o.customRateLimiter !== undefined) { this._crl = o.customRateLimiter; this.rateLimiter = null; }
225
+ if (o.customIPTracker !== undefined) this._cit = o.customIPTracker;
226
+ if (o.generateClientId !== undefined) this._genId = o.generateClientId;
227
+ if (o.customHealthHandler !== undefined) this._healthFn = o.customHealthHandler;
228
+ if (o.hooks !== undefined) this.hooks = { ...this.hooks, ...o.hooks };
229
+ if (o.eventRateLimits !== undefined) { this.evRateLimits.clear(); for (const [ev, c] of Object.entries(o.eventRateLimits)) this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs)); }
230
+ this.log.info('Config updated');
478
231
  return this;
479
232
  }
480
233
 
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
- }
234
+ setClientRateLimit(id: string, c: { maxPoints: number; windowMs: number }) { this.clientRates.set(id, new RateLimiter(c.maxPoints, c.windowMs)); return this; }
235
+ removeClientRateLimit(id: string) { this.clientRates.delete(id); return this; }
236
+ setEventRateLimit(ev: string, c: { maxPoints: number; windowMs: number }) { this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs)); return this; }
237
+ removeEventRateLimit(ev: string) { this.evRateLimits.delete(ev); return this; }
492
238
 
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
- }> {
239
+ getConfig() {
518
240
  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,
241
+ maxConnections: this.maxConns, maxConnectionsPerIP: this._cit ? -1 : 50,
242
+ maxRooms: this.maxRooms, maxRoomsPerClient: this.maxRoomsPerClient, maxPayloadSize: this.maxPayload,
243
+ heartbeatInterval: this.hbInterval, heartbeatTimeout: this.hbTimeout, connectTimeout: this.connTimeout,
244
+ shutdownTimeout: this.shutdownMs, hasCustomRateLimiter: this._crl !== null, hasCustomIPTracker: this._cit !== null,
245
+ hasCustomClientIdGenerator: this._genId !== null, hasCustomHealthHandler: this._healthFn !== null,
246
+ eventRateLimits: Array.from(this.evRateLimits.keys()), hooks: Object.keys(this.hooks), allowedOrigins: this.origins,
535
247
  });
536
248
  }
537
249
 
538
- use(middleware: StelarMiddleware): this {
539
- this.middlewares.push(middleware);
540
- return this;
541
- }
542
-
543
- on(event: string, handler: StelarEventHandler): this {
544
- this.events.set(event, handler);
545
- return this;
546
- }
547
-
548
- onAll(handler: StelarWildcardHandler): this {
549
- this._wildcardHandler = handler;
550
- return this;
551
- }
552
-
553
- onConnection(handler: StelarEventHandler): this {
554
- this._connectionHandler = handler;
555
- return this;
556
- }
250
+ /* ── Event registration ── */
557
251
 
558
- onDisconnect(handler: StelarEventHandler): this {
559
- this.events.set('disconnect', handler);
560
- return this;
561
- }
252
+ use(mw: StelarMiddleware) { this.mw.push(mw); return this; }
253
+ on(ev: string, h: StelarEventHandler) { this.events.set(ev, h); return this; }
254
+ onAll(h: StelarWildcardHandler) { this._wild = h; return this; }
255
+ onConnection(h: StelarEventHandler) { this._connH = h; return this; }
256
+ onDisconnect(h: StelarEventHandler) { this.events.set('disconnect', h); return this; }
257
+ onAck(name: string, h: StelarEventHandler) { this._acks.set(name, h); return this; }
562
258
 
563
- onAck(name: string, handler: StelarEventHandler): this {
564
- this._acks.set(name, handler);
565
- return this;
566
- }
259
+ /* ── Messaging ── */
567
260
 
568
261
  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
-
262
+ if (this.hooks.onBeforeBroadcast?.({ event, data, excludeId }) === false) return this;
263
+ const wsF = createWSTextFrame(JSON.stringify({ event, data }));
264
+ const tcpF = encodeJsonFrame(event, data, this.maxFrame);
574
265
  let sent = 0;
575
- this.clients.forEach((record) => {
576
- if (excludeId && record.info.id === excludeId) return;
577
- if (this._sendJsonToClient(record, event, data)) sent++;
578
- });
579
- this._totalMessagesSent += sent;
266
+ this.clients.forEach(r => { if (excludeId && r.info.id === excludeId) return; if (this._write(r, wsF, tcpF)) sent++; });
267
+ this._totalSent += sent;
580
268
  return this;
581
269
  }
582
270
 
583
- broadcastBinary(event: string, buffer: ArrayBuffer): void {
584
- this.clients.forEach((record) => {
585
- this._sendBinaryRaw(record, event, buffer);
586
- });
587
- }
271
+ broadcastBinary(event: string, buf: ArrayBuffer) { this.clients.forEach(r => this._sendBin(r, event, buf)); }
588
272
 
589
273
  to(room: string, event: string, data: unknown, excludeId?: string): this {
590
- const memberIds = this.rooms.get(room);
591
- if (!memberIds) return this;
592
-
274
+ const ids = this.rooms.get(room);
275
+ if (!ids) return this;
276
+ const wsF = createWSTextFrame(JSON.stringify({ event, data }));
277
+ const tcpF = encodeJsonFrame(event, data, this.maxFrame);
593
278
  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;
279
+ for (const id of ids) { if (excludeId && id === excludeId) continue; const r = this.byId.get(id); if (r && this._write(r, wsF, tcpF)) sent++; }
280
+ this._totalSent += sent;
600
281
  return this;
601
282
  }
602
283
 
603
284
  toId(id: string, event: string, data: unknown): this {
604
- const record = this.clientsById.get(id);
605
- if (record && this._sendJsonToClient(record, event, data)) {
606
- this._totalMessagesSent++;
607
- }
285
+ const r = this.byId.get(id);
286
+ if (r && this._sendJson(r, event, data)) this._totalSent++;
608
287
  return this;
609
288
  }
610
289
 
611
- getClients(room?: string): { id: string; rooms: string[] }[] {
290
+ getClients(room?: string) {
612
291
  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
- }
617
- });
292
+ this.clients.forEach(r => { if (!room || r.info.rooms.has(room)) list.push({ id: r.info.id, rooms: [...r.info.rooms] }); });
618
293
  return list;
619
294
  }
620
295
 
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
-
630
- getPort(): number {
631
- const address = this.httpServer?.address();
632
- if (address && typeof address === 'object') {
633
- return address.port;
634
- }
635
- return this.port;
636
- }
296
+ getRoomMembers(room: string) { return this.rooms.get(room) ? [...this.rooms.get(room)!] : []; }
297
+ getRooms() { return [...this.rooms.keys()]; }
298
+ getPort() { const a = this.httpServer?.address(); return a && typeof a === 'object' ? a.port : this.port; }
637
299
 
638
300
  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
- });
301
+ let ws = 0, tcp = 0;
302
+ this.clients.forEach(r => r.protocol === 'ws' ? ws++ : tcp++);
645
303
  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(),
304
+ totalConnections: this._totalConns, activeConnections: this.clients.size,
305
+ totalMessagesReceived: this._totalRecv, totalMessagesSent: this._totalSent,
306
+ totalRooms: this.rooms.size, uptime: this._startTime ? Date.now() - this._startTime : 0,
307
+ wsConnections: ws, tcpConnections: tcp, memoryUsage: process.memoryUsage(),
308
+ rateLimiterEntries: this._crl?.size() ?? this.rateLimiter?.size() ?? 0,
656
309
  };
657
310
  }
658
311
 
659
- private _getRateLimiterSize(): number {
660
- if (this._customRateLimiter) return this._customRateLimiter.size();
661
- return this.rateLimiter?.size() || 0;
662
- }
312
+ onShutdown(cb: (sig: string, force: boolean) => void) { this._shutdownCbs.push(cb); return this; }
663
313
 
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
- }
670
-
671
- if (event && this.eventRateLimiters.has(event)) {
672
- const eventLimiter = this.eventRateLimiters.get(event)!;
673
- if (!eventLimiter.check(clientId)) return false;
674
- }
314
+ /* ── Private: send helpers ── */
675
315
 
676
- if (this._customRateLimiter) {
677
- return this._customRateLimiter.check(clientId);
678
- }
679
- if (this.rateLimiter) {
680
- return this.rateLimiter.check(clientId);
681
- }
316
+ private _sendJson(r: ClientRecord, event: string, data: unknown): boolean {
317
+ if (r.socket.destroyed || r.socket.writableEnded) return false;
318
+ try {
319
+ r.socket.write(r.protocol === 'ws' ? createWSTextFrame(JSON.stringify({ event, data })) : encodeJsonFrame(event, data, this.maxFrame));
320
+ r.info.messagesSent++; return true;
321
+ } catch { return false; }
322
+ }
682
323
 
683
- return true;
324
+ private _write(r: ClientRecord, wsF: Buffer, tcpF: Buffer): boolean {
325
+ if (r.socket.destroyed || r.socket.writableEnded) return false;
326
+ try { r.socket.write(r.protocol === 'ws' ? wsF : tcpF); r.info.messagesSent++; return true; } catch { return false; }
684
327
  }
685
328
 
686
- private _sendJsonToClient(record: ClientRecord, event: string, data: unknown): boolean {
687
- if (record.socket.destroyed || record.socket.writableEnded) return false;
329
+ private _sendBin(r: ClientRecord, event: string, buf: ArrayBuffer): boolean {
330
+ if (r.socket.destroyed || r.socket.writableEnded) return false;
688
331
  try {
689
- if (record.protocol === 'ws') {
690
- const json = JSON.stringify({ event, data });
691
- record.socket.write(createWSTextFrame(json));
332
+ if (r.protocol === 'ws') {
333
+ const hdr = Buffer.from(JSON.stringify({ event, _binary: true }), 'utf8');
334
+ const combined = Buffer.alloc(hdr.length + 1 + buf.byteLength);
335
+ hdr.copy(combined, 0); combined[hdr.length] = 0; combined.set(new Uint8Array(buf), hdr.length + 1);
336
+ r.socket.write(createWSBinaryFrame(combined));
692
337
  } else {
693
- record.socket.write(encodeJsonFrame(event, data, this.maxFrameSize));
338
+ r.socket.write(encodeBinaryFrame(event, new Uint8Array(buf), this.maxFrame));
694
339
  }
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
- }
340
+ r.info.messagesSent++; return true;
341
+ } catch { return false; }
701
342
  }
702
343
 
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));
716
- }
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
- }
344
+ private _checkRate(cid: string, event?: string): boolean {
345
+ const co = this.clientRates.get(cid);
346
+ if (co) return co.check(cid);
347
+ if (event && this.evRateLimits.has(event) && !this.evRateLimits.get(event)!.check(cid)) return false;
348
+ if (this._crl) return this._crl.check(cid);
349
+ if (this.rateLimiter) return this.rateLimiter.check(cid);
350
+ return true;
723
351
  }
724
352
 
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
- }
353
+ private _getIP(socket: NetSocket, req: IncomingMessage | null): string {
354
+ if (req) { const fwd = req.headers['x-forwarded-for']; if (typeof fwd === 'string') return fwd.split(',')[0].trim(); }
355
+ return socket.remoteAddress || 'unknown';
356
+ }
737
357
 
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);
358
+ /* ── Private: client lifecycle ── */
765
359
 
766
- if (!this.rooms.has(room)) {
767
- this.rooms.set(room, new Set());
360
+ private _register(socket: NetSocket, proto: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser): ClientRecord | null {
361
+ const ip = this._getIP(socket, req);
362
+ if (this.clients.size >= this.maxConns) {
363
+ this.hooks.onMaxConnectionsReached?.({ activeConnections: this.clients.size, max: this.maxConns, ip });
364
+ this.log.warn('Max connections reached', { active: this.clients.size, max: this.maxConns });
365
+ if (proto === 'ws') try { socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Server full')); } catch {}
366
+ socket.destroy(); return null;
768
367
  }
769
- this.rooms.get(room)!.add(record.info.id);
770
-
771
- this._sendJsonToClient(record, 'joined-room', room);
368
+ const tracker = this._cit || this.ipTracker;
369
+ if (!tracker.check(ip)) {
370
+ this.log.warn('Max connections per IP', { ip });
371
+ if (proto === 'ws') try { socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Too many connections')); } catch {}
372
+ socket.destroy(); return null;
373
+ }
374
+ const id = this._genId ? this._genId() : randomUUID();
375
+ const info: StelarClientInfo = { id, rooms: new Set(), lastPing: Date.now(), protocol: proto, connectedAt: Date.now(), metadata: new Map(), messagesReceived: 0, messagesSent: 0, remoteAddress: ip };
376
+ const record: ClientRecord = { info, socket, parser, protocol: proto };
377
+ this.clients.set(socket, record); this.byId.set(id, record); tracker.add(ip); this._totalConns++;
378
+ return record;
772
379
  }
773
380
 
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 });
782
- return;
783
- }
784
- }
381
+ private _unregister(r: ClientRecord, ctx: StelarContext) {
382
+ this.hooks.onClientDisconnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: r.info.protocol, rooms: new Set(r.info.rooms) });
383
+ for (const room of r.info.rooms) { const m = this.rooms.get(room); if (m) { m.delete(r.info.id); if (!m.size) this.rooms.delete(room); } }
384
+ r.info.rooms.clear();
385
+ this.byId.delete(r.info.id); this.clients.delete(r.socket);
386
+ (this._cit || this.ipTracker).remove(r.info.remoteAddress);
387
+ if (this._crl) this._crl.reset(r.info.id); else this.rateLimiter?.reset(r.info.id);
388
+ this.clientRates.delete(r.info.id);
389
+ const h = this.events.get('disconnect');
390
+ if (h) try { h({ ...ctx, event: 'disconnect' }); } catch (e) { this.log.error('Disconnect handler error', { error: String(e) }); }
391
+ }
785
392
 
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);
393
+ private _joinRoom(r: ClientRecord, room: string) {
394
+ if (this.hooks.onClientJoinRoom?.({ clientId: r.info.id, room, metadata: r.info.metadata }) === false) return;
395
+ if (r.info.rooms.size >= this.maxRoomsPerClient) { this.hooks.onMaxRoomsPerClientReached?.({ clientId: r.info.id, room, currentRooms: r.info.rooms.size, max: this.maxRoomsPerClient }); return; }
396
+ if (this.rooms.size >= this.maxRooms && !this.rooms.has(room)) { this.hooks.onMaxRoomsReached?.({ clientId: r.info.id, room, totalRooms: this.rooms.size, max: this.maxRooms }); return; }
397
+ r.info.rooms.add(room);
398
+ if (!this.rooms.has(room)) this.rooms.set(room, new Set());
399
+ this.rooms.get(room)!.add(r.info.id);
400
+ this._sendJson(r, 'joined-room', room);
795
401
  }
796
402
 
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);
804
- }
805
- }
806
- }
807
- record.info.rooms.clear();
403
+ private _leaveRoom(r: ClientRecord, room: string) {
404
+ if (this.hooks.onClientLeaveRoom?.({ clientId: r.info.id, room }) === false) return;
405
+ r.info.rooms.delete(room);
406
+ const m = this.rooms.get(room);
407
+ if (m) { m.delete(r.info.id); if (!m.size) this.rooms.delete(room); }
408
+ this._sendJson(r, 'left-room', room);
808
409
  }
809
410
 
810
- private _buildCtx(record: ClientRecord, req: IncomingMessage | null): StelarContext {
811
- const self = this;
411
+ /* ── Private: context & middleware ── */
412
+
413
+ private _buildCtx(r: ClientRecord, req: IncomingMessage | null): StelarContext {
414
+ const s = this;
812
415
  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) });
416
+ id: r.info.id, socket: r.socket, req, clientInfo: r.info,
417
+ emit: (ev, d) => { if (s._sendJson(r, ev, d)) s._totalSent++; },
418
+ send: (rid, d) => { if (s._sendJson(r, rid, { data: d, _isAck: true })) s._totalSent++; },
419
+ emitBinary: (ev, buf) => { if (s._sendBin(r, ev, buf)) s._totalSent++; },
420
+ broadcast: (ev, d) => s.broadcast(ev, d, r.info.id),
421
+ broadcastBinary: (ev, buf) => s.broadcastBinary(ev, buf),
422
+ to: (room, ev, d) => s.to(room, ev, d, r.info.id),
423
+ toId: (id, ev, d) => s.toId(id, ev, d),
424
+ getClients: (room) => s.getClients(room),
425
+ joinRoom: (room) => s._joinRoom(r, room),
426
+ leaveRoom: (room) => s._leaveRoom(r, room),
427
+ setMetadata: (k, v) => r.info.metadata.set(k, v),
428
+ getMetadata: (k) => r.info.metadata.get(k),
429
+ ack: (name, d) => {
430
+ const h = s._acks.get(name);
431
+ if (!h) return;
432
+ let res: unknown;
433
+ try { res = h({ ...ctx, data: d }); } catch (e) { s.log.error('ACK handler error', { name, error: String(e) }); return; }
434
+ if (res !== undefined) {
435
+ try {
436
+ if (r.protocol === 'ws') {
437
+ const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
438
+ if (ctx._correlationId) p._correlationId = ctx._correlationId;
439
+ r.socket.write(createWSTextFrame(JSON.stringify(p)));
440
+ } else {
441
+ r.socket.write(ctx._correlationId
442
+ ? encodeAckResFrame(name, { data: res, _correlationId: ctx._correlationId }, s.maxFrame)
443
+ : encodeAckResFrame(name, res, s.maxFrame));
843
444
  }
844
- }
445
+ s._totalSent++;
446
+ } catch (e) { s.log.error('ACK send error', { name, error: String(e) }); }
845
447
  }
846
- }
448
+ },
847
449
  };
848
450
  return ctx;
849
451
  }
850
452
 
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
- };
453
+ private _runMw(ctx: StelarContext, next: () => void) {
454
+ const run = (i: number) => { if (i >= this.mw.length) return next(); try { this.mw[i](ctx, () => run(i + 1)); } catch { ctx.socket.destroy(); } };
861
455
  run(0);
862
456
  }
863
457
 
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
880
- }
881
- }
882
- });
883
- }, this.heartbeatInterval);
884
- if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
885
- this._hbTimer.unref();
886
- }
887
- }
458
+ /* ── Private: event dispatch (shared by WS & TCP) ── */
888
459
 
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';
460
+ private _dispatch(r: ClientRecord, ctx: StelarContext, event: string, data: unknown, correlationId?: string) {
461
+ if (event === 'pong') { r.info.lastPing = Date.now(); return; }
462
+ if (event === 'join-room') { if (data) this._joinRoom(r, String(data)); return; }
463
+ if (event === 'leave-room') { if (data) this._leaveRoom(r, String(data)); return; }
464
+ const ectx: StelarContext = { ...ctx, data, event, _correlationId: correlationId };
465
+ const h = this.events.get(event);
466
+ if (h) try { h(ectx); } catch (e) { this.log.error('Event handler error', { event, error: String(e) }); }
467
+ if (this._wild) try { this._wild({ event, data: ectx }); } catch (e) { this.log.error('Wildcard error', { error: String(e) }); }
897
468
  }
898
469
 
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'));
913
- }
914
- } catch { /* ignore */ }
915
- socket.destroy();
916
- return null;
917
- }
470
+ /* ── Private: heartbeat ── */
918
471
 
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
- }
931
-
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;
951
- }
952
-
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),
472
+ private _startHeartbeat() {
473
+ this._hb = setInterval(() => {
474
+ const now = Date.now();
475
+ this.clients.forEach(r => {
476
+ if (now - r.info.lastPing > this.hbTimeout) { r.socket.destroy(); }
477
+ else try { r.socket.write(r.protocol === 'ws' ? createWSPingFrame() : encodePingFrame()); } catch {}
960
478
  });
961
- }
962
-
963
- this._removeFromAllRooms(record);
964
- this.clientsById.delete(record.info.id);
965
- this.clients.delete(record.socket);
966
-
967
- const ipTracker = this._customIPTracker || this.ipTracker;
968
- ipTracker.remove(record.info.remoteAddress);
969
-
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);
976
-
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) });
983
- }
984
- }
479
+ }, this.hbInterval);
480
+ this._hb?.unref?.();
985
481
  }
986
482
 
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
- }
483
+ /* ── Private: WS upgrade ── */
1009
484
 
485
+ private _wsUpgrade(req: IncomingMessage, socket: NetSocket, head: Buffer) {
486
+ const path = new URL(req.url || '/', 'http://localhost').pathname;
487
+ const nsPath = this.ns === '/' ? '/' : this.ns;
488
+ if (nsPath !== '/' && path !== nsPath) { socket.destroy(); return; }
489
+ if (this.origins && !this.origins.includes(req.headers['origin'] || '')) { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; }
1010
490
  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
-
491
+ if (!key || !validateWSKey(key)) { socket.destroy(); return; }
1017
492
  try {
1018
- const extraHeaders: Record<string, string> = {};
493
+ const extra: Record<string, string> = {};
1019
494
  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
- }
495
+ if (origin && this.origins?.includes(origin)) extra['Access-Control-Allow-Origin'] = origin;
496
+ socket.write(buildUpgradeResponse(key, extra));
497
+ } catch { socket.destroy(); return; }
1042
498
 
1043
- const ctx = this._buildCtx(record, req);
499
+ const timer = setTimeout(() => { if (!this.clients.has(socket)) socket.destroy(); }, this.connTimeout);
500
+ timer.unref();
1044
501
 
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
- }
502
+ const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame));
503
+ if (!r) { clearTimeout(timer); return; }
504
+ const ctx = this._buildCtx(r, req);
1053
505
 
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
- });
506
+ this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'ws', metadata: r.info.metadata });
507
+ this._runMw(ctx, () => { if (this._connH) try { this._connH(ctx); } catch (e) { this.log.error('Connection handler error', { error: String(e) }); } });
508
+ this.log.info('WS connected', { clientId: r.info.id, ip: r.info.remoteAddress });
1063
509
 
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
- });
510
+ if (head.length > 0) this._processWS(r, head, ctx);
511
+ socket.on('data', (d: Buffer) => { clearTimeout(timer); this._processWS(r, d, ctx); });
512
+ socket.on('close', () => { clearTimeout(timer); this._unregister(r, ctx); });
513
+ socket.on('error', (e: Error) => { this.log.warn('WS error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
514
+ socket.on('drain', () => socket.resume());
1089
515
  }
1090
516
 
1091
- private _processWSData(record: ClientRecord, data: Buffer, ctx: StelarContext, req: IncomingMessage | null): void {
517
+ private _processWS(r: ClientRecord, data: Buffer, ctx: StelarContext) {
1092
518
  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);
519
+ try { frames = (r.parser as WSFrameParser).feed(data); } catch (e) {
520
+ if (e instanceof WebSocketError) { this.log.warn('WS protocol error', { code: e.code, message: e.message }); try { r.socket.write(createWSCloseFrame(e.code, e.message)); } catch {} }
521
+ else this.log.error('WS parse error', { error: String(e) });
522
+ r.socket.destroy(); return;
1111
523
  }
524
+ for (const f of frames) { if (!r.socket.destroyed) this._handleWSFrame(r, f, ctx); }
1112
525
  }
1113
526
 
1114
- private _handleWSFrame(record: ClientRecord, frame: WSFrame, ctx: StelarContext, _req: IncomingMessage | null): void {
527
+ private _handleWSFrame(r: ClientRecord, frame: WSFrame, ctx: StelarContext) {
1115
528
  const { opcode, payload } = frame;
1116
529
 
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 });
530
+ if (opcode === OP_PING) { try { r.socket.write(createWSPongFrame(payload)); } catch {} return; }
531
+ if (opcode === OP_CLOSE) { try { r.socket.write(createWSCloseFrame()); } catch {} r.socket.end(); return; }
532
+ if (opcode === OP_PONG) { r.info.lastPing = Date.now(); return; }
1135
533
 
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;
534
+ if (!this._checkRate(r.info.id)) {
535
+ this.log.warn('Rate limit exceeded', { clientId: r.info.id });
536
+ if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, protocol: 'ws' }) === false) return;
537
+ try { r.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded')); } catch {} r.socket.destroy(); return;
1149
538
  }
1150
539
 
1151
540
  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
-
541
+ r.info.messagesReceived++; this._totalRecv++;
542
+ if (payload.length > this.maxPayload) { this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload }); try { r.socket.write(createWSCloseFrame(CLOSE_MESSAGE_TOO_BIG)); } catch {} r.socket.destroy(); return; }
1167
543
  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
-
544
+ try { msg = JSON.parse(payload.toString('utf8')); } catch { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid JSON', protocol: 'ws' }); return; }
545
+ const event = String(msg.event || ''), data = msg.data, corrId = msg._correlationId ? String(msg._correlationId) : undefined;
1181
546
  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;
547
+ if (!this._checkRate(r.info.id, event)) {
548
+ this.log.warn('Event rate limit', { clientId: r.info.id, event });
549
+ if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event, protocol: 'ws' }) === false) return;
550
+ try { r.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded')); } catch {} r.socket.destroy(); return;
1205
551
  }
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
552
  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) });
553
+ const name = String(msg._ackName), h = this._acks.get(name)!;
554
+ let res: unknown;
555
+ try { res = h({ ...ctx, data, _correlationId: corrId }); } catch (e) { this.log.error('ACK handler error', { name, error: String(e) }); return; }
556
+ if (res !== undefined) {
557
+ const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
558
+ if (corrId) p._correlationId = corrId;
559
+ try { r.socket.write(createWSTextFrame(JSON.stringify(p))); this._totalSent++; } catch {}
1230
560
  }
1231
561
  return;
1232
562
  }
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;
563
+ this._dispatch(r, ctx, event, data, corrId);
1251
564
  }
1252
565
 
1253
566
  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
-
567
+ r.info.messagesReceived++; this._totalRecv++;
568
+ if (payload.length > this.maxPayload) { this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload }); return; }
1265
569
  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
- }
570
+ let end = -1; for (let i = 0; i < payload.length; i++) if (payload[i] === 0) { end = i; break; }
571
+ if (end === -1) return;
572
+ const hdr = JSON.parse(payload.subarray(0, end).toString('utf8'));
573
+ const buf = payload.subarray(end + 1);
574
+ if (hdr.event && !this._checkRate(r.info.id, hdr.event)) {
575
+ this.log.warn('Binary rate limit', { clientId: r.info.id, event: hdr.event });
576
+ if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: hdr.event, protocol: 'ws' }) === false) return;
1282
577
  return;
1283
578
  }
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
- }
579
+ const ectx: StelarContext = { ...ctx, data: buf, buffer: buf, isBinary: true, event: hdr.event };
580
+ const h = this.events.get(hdr.event);
581
+ if (h) try { h(ectx); } catch {}
582
+ if (this._wild) try { this._wild({ event: hdr.event, data: ectx }); } catch {}
583
+ } catch { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid binary frame', protocol: 'ws' }); }
1299
584
  }
1300
585
  }
1301
586
 
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
- }
587
+ /* ── Private: TCP connection ── */
1314
588
 
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
- });
589
+ private _tcpConnect(socket: NetSocket) {
590
+ const r = this._register(socket, 'tcp', null, new FrameParser(this.maxFrame));
591
+ if (!r) return;
592
+ const ctx = this._buildCtx(r, null);
593
+ try { socket.write(encodeConnectFrame(r.info.id)); } catch { socket.destroy(); return; }
594
+ this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'tcp', metadata: r.info.metadata });
595
+ this._runMw(ctx, () => { if (this._connH) try { this._connH(ctx); } catch (e) { this.log.error('TCP connection handler error', { error: String(e) }); } });
596
+ this.log.info('TCP connected', { clientId: r.info.id, ip: r.info.remoteAddress });
597
+ socket.on('data', (d: Buffer) => this._processTCP(r, d, ctx));
598
+ socket.on('close', () => this._unregister(r, ctx));
599
+ socket.on('error', (e: Error) => { this.log.warn('TCP error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
600
+ socket.on('drain', () => socket.resume());
1349
601
  }
1350
602
 
1351
- private _processTCPData(record: ClientRecord, data: Buffer, ctx: StelarContext): void {
603
+ private _processTCP(r: ClientRecord, data: Buffer, ctx: StelarContext) {
1352
604
  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);
605
+ try { frames = (r.parser as FrameParser).feed(data); } catch (e) {
606
+ if (e instanceof ProtocolError) { this.log.warn('TCP protocol error', { code: e.code, message: e.message }); try { r.socket.write(encodeErrorFrame(e.message)); } catch {} }
607
+ r.socket.destroy(); return;
1369
608
  }
609
+ for (const f of frames) { if (!r.socket.destroyed) this._handleTCPFrame(r, f, ctx); }
1370
610
  }
1371
611
 
1372
- private _handleTCPFrame(record: ClientRecord, frame: ParsedFrame, ctx: StelarContext): void {
612
+ private _handleTCPFrame(r: ClientRecord, frame: ParsedFrame, ctx: StelarContext) {
1373
613
  const { type, event, payload } = frame;
614
+ if (type === FRAME_PING) { try { r.socket.write(encodePongFrame()); } catch {} r.info.lastPing = Date.now(); return; }
615
+ if (type === FRAME_PONG) { r.info.lastPing = Date.now(); return; }
616
+ if (type === FRAME_CONNECT) return;
1374
617
 
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;
618
+ if (!this._checkRate(r.info.id, event)) {
619
+ this.log.warn('TCP rate limit', { clientId: r.info.id, event });
620
+ if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: event || undefined, protocol: 'tcp' }) === false) return;
621
+ try { r.socket.write(encodeErrorFrame('Rate limit exceeded')); } catch {} r.socket.destroy(); return;
1384
622
  }
1385
623
 
1386
- if (!this._checkRateLimit(record.info.id, event)) {
1387
- this.log.warn('TCP rate limit exceeded', { clientId: record.info.id, event });
624
+ if (type === FRAME_JOIN) { if (payload.toString('utf8')) this._joinRoom(r, payload.toString('utf8')); return; }
625
+ if (type === FRAME_LEAVE) { if (payload.toString('utf8')) this._leaveRoom(r, payload.toString('utf8')); return; }
626
+ if (payload.length > this.maxPayload) { this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, event, size: payload.length, max: this.maxPayload }); return; }
1388
627
 
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++;
628
+ r.info.messagesReceived++; this._totalRecv++;
1429
629
 
1430
630
  if (type === FRAME_JSON) {
1431
631
  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
- }
632
+ try { data = JSON.parse(payload.toString('utf8')); } catch { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid JSON', protocol: 'tcp' }); return; }
633
+ this._dispatch(r, ctx, event, data);
1450
634
  return;
1451
635
  }
1452
636
 
@@ -1455,360 +639,154 @@ class StelarServer {
1455
639
  try {
1456
640
  const parsed = JSON.parse(payload.toString('utf8'));
1457
641
  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++;
642
+ const corrId = parsed && typeof parsed === 'object' && '_correlationId' in parsed ? String(parsed._correlationId) : undefined;
643
+ const h = this._acks.get(event)!;
644
+ const res = h({ ...ctx, data, _correlationId: corrId });
645
+ if (res !== undefined) {
646
+ r.socket.write(corrId ? encodeAckResFrame(event, { data: res, _correlationId: corrId }, this.maxFrame) : encodeAckResFrame(event, res, this.maxFrame));
647
+ this._totalSent++;
1463
648
  }
1464
- } catch (err) {
1465
- this.log.error('TCP ACK handler error', { event, error: String(err) });
1466
- }
649
+ } catch (e) { this.log.error('TCP ACK handler error', { event, error: String(e) }); }
1467
650
  }
1468
651
  return;
1469
652
  }
1470
653
 
1471
654
  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
- }
655
+ try {
656
+ const raw = JSON.parse(payload.toString('utf8'));
657
+ const data = raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
658
+ const corrId = raw && typeof raw === 'object' && '_correlationId' in raw ? String(raw._correlationId) : undefined;
659
+ const h = this._acks.get(corrId || event);
660
+ if (h) try { h({ ...ctx, data }); } catch {}
661
+ } catch {}
1479
662
  return;
1480
663
  }
1481
664
 
1482
665
  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;
666
+ const ectx: StelarContext = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
667
+ const h = this.events.get(event);
668
+ if (h) try { h(ectx); } catch {}
669
+ if (this._wild) try { this._wild({ event, data: ectx }); } catch {}
1492
670
  }
1493
671
  }
1494
672
 
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
- }
673
+ private _handleErr(r: ClientRecord, ctx: StelarContext, err: Error) {
674
+ const h = this.events.get('error');
675
+ if (h) try { h({ ...ctx, error: err, event: 'error' }); } catch {}
1504
676
  }
1505
677
 
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
- }
678
+ /* ── Private: health check ── */
1520
679
 
680
+ private _health(req: IncomingMessage, res: ServerResponse) {
681
+ if (this._healthFn) { try { this._healthFn(req, res, this.getStats()); } catch { if (!res.headersSent) { res.writeHead(500); res.end('{"status":"error"}'); } } return; }
1521
682
  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();
683
+ if (origin && (!this.origins || this.origins.includes(origin))) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Max-Age', '86400'); }
684
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
685
+ if (this.healthPath && req.url === this.healthPath && req.method === 'GET') {
686
+ const s = this.getStats();
1536
687
  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
- }));
688
+ res.end(JSON.stringify({ status: 'ok', ...s, uptimeSeconds: Math.floor(s.uptime / 1000), memoryMB: Math.round(s.memoryUsage.heapUsed / 1024 / 1024 * 100) / 100 }));
1543
689
  return;
1544
690
  }
1545
-
1546
- res.writeHead(200, { 'Content-Type': 'text/plain' });
1547
- res.end('Stelar Time Real v3 Server');
691
+ res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Stelar Time Real v3 Server');
1548
692
  }
1549
693
 
1550
- private _shutdownCallbacks: Array<(signal: string, force: boolean) => void> = [];
694
+ /* ── Private: graceful shutdown ── */
1551
695
 
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;
696
+ private _emitShutdown(sig: string, force: boolean) {
697
+ if (!this._shutdownCbs.length) { process.exit(force ? 1 : 0); return; }
698
+ for (const cb of this._shutdownCbs) try { cb(sig, force); } catch {}
1556
699
  }
1557
700
 
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
-
701
+ private _setupShutdown() {
702
+ if (!this.doGraceful) return;
703
+ let done = false;
704
+ const shutdown = (sig: string) => {
705
+ if (done) return; done = true; this._shutting = true;
706
+ this.log.info(`Received ${sig}, shutting down...`);
1579
707
  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();
708
+ if (!this.clients.size) { this.log.info('Shutdown complete'); this._emitShutdown(sig, false); return; }
709
+ this.log.info(`Waiting for ${this.clients.size} connections (timeout: ${this.shutdownMs}ms)`);
710
+ this.clients.forEach(r => { try { r.socket.write(r.protocol === 'ws' ? createWSCloseFrame(CLOSE_GOING_AWAY, 'Shutting down') : encodeDisconnectFrame()); r.socket.end(); } catch {} });
711
+ const forceT = setTimeout(() => { this.clients.forEach(r => { try { r.socket.destroy(); } catch {} }); this.clients.clear(); this.byId.clear(); this._emitShutdown(sig, true); }, this.shutdownMs);
712
+ forceT.unref();
713
+ const check = setInterval(() => { if (!this.clients.size) { clearInterval(check); clearTimeout(forceT); this._emitShutdown(sig, false); } }, 100);
714
+ check.unref();
1621
715
  };
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
- }
716
+ this._sigH.int = () => shutdown('SIGINT'); this._sigH.term = () => shutdown('SIGTERM');
717
+ process.on('SIGINT', this._sigH.int); process.on('SIGTERM', this._sigH.term);
718
+ }
719
+
720
+ private _removeSignals() {
721
+ if (this._sigH.int) { process.off('SIGINT', this._sigH.int); this._sigH.int = null; }
722
+ if (this._sigH.term) { process.off('SIGTERM', this._sigH.term); this._sigH.term = null; }
723
+ }
724
+
725
+ /* ── Start / Stop ── */
726
+
727
+ start(cb?: (port: number) => void): Promise<number> {
728
+ if (this._started) { const p = this.getPort(); cb?.(p); return Promise.resolve(p); }
729
+ this._started = true; this._startTime = Date.now();
730
+ return new Promise(resolve => {
731
+ const onHttp = (srv: HttpServer) => {
732
+ this.httpServer = srv;
733
+ this._reqH = (req, res) => this._health(req, res);
734
+ this._upgH = (req, socket, head) => this._wsUpgrade(req, socket, head);
735
+ srv.on('request', this._reqH); srv.on('upgrade', this._upgH);
736
+ this._startHeartbeat();
737
+ this._rc = setInterval(() => {
738
+ if (this._crl) this._crl.cleanup(); else this.rateLimiter?.cleanup();
739
+ (this._cit || this.ipTracker).cleanup();
740
+ for (const [id, l] of this.clientRates) { l.cleanup(); if (!this.byId.has(id)) this.clientRates.delete(id); }
741
+ for (const [, l] of this.evRateLimits) l.cleanup();
1682
742
  }, 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);
743
+ this._rc?.unref?.();
744
+ this._setupShutdown();
745
+ const p = this.getPort(); this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts }); cb?.(p); resolve(p);
1693
746
  };
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
- });
747
+ if (this.httpServer) { this._ext.add(this.httpServer); onHttp(this.httpServer); }
748
+ else {
749
+ const tryListen = (port: number) => {
750
+ const srv = createHttp();
751
+ srv.on('error', (e: NodeJS.ErrnoException) => { if (e.code === 'EADDRINUSE' && port < 65535) tryListen(port + 1); else this.log.error('HTTP error', { error: e.message }); });
752
+ srv.listen(port, () => { this.port = port; onHttp(srv); });
1716
753
  };
1717
754
  tryListen(this.port);
1718
755
  }
1719
-
1720
- if (this.tcpPort !== false) {
1721
- const tcpPortNum = typeof this.tcpPort === 'number' ? this.tcpPort : this.port + 1;
1722
- this._startTCPServer(tcpPortNum);
1723
- }
756
+ if (this.tcpPort !== false) { const p = typeof this.tcpPort === 'number' ? this.tcpPort : this.port + 1; this._startTCP(p); }
1724
757
  });
1725
758
  }
1726
759
 
1727
- private _startTCPServer(port: number, attempts = 0): void {
1728
- const tcpHandler = (socket: NetSocket) => this.handleTCPConnection(socket);
1729
-
1730
- if (this.tlsOptions) {
760
+ private _startTCP(port: number, attempts = 0) {
761
+ const handler = (s: NetSocket) => this._tcpConnect(s);
762
+ const startPlain = (p: number, a: number) => {
763
+ const srv = createTcp(handler);
764
+ srv.on('error', (e: NodeJS.ErrnoException) => { if (e.code === 'EADDRINUSE' && a < 10) { this.tcpServer = null; this._startTCP(p + 1, a + 1); } else this.log.error('TCP error', { error: e.message }); });
765
+ srv.listen(p, () => { this.tcpServer = srv; this.log.info('TCP started', { port: p }); });
766
+ };
767
+ if (this.tlsOpts) {
1731
768
  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
- });
769
+ const srv = createTls(this.tlsOpts, handler);
770
+ this.tcpServer = srv as unknown as TcpServer;
771
+ this.tcpServer.on('error', (e: NodeJS.ErrnoException) => { if (e.code === 'EADDRINUSE' && attempts < 10) { this.tcpServer = null; this._startTCP(port + 1, attempts + 1); } else this.log.error('TLS TCP error', { error: e.message }); });
772
+ this.tcpServer.listen(port, () => this.log.info('TLS TCP started', { port }));
773
+ } catch { startPlain(port, attempts); }
774
+ } else startPlain(port, attempts);
1773
775
  }
1774
776
 
1775
777
  stop(): this {
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
-
778
+ if (this._hb) { clearInterval(this._hb); this._hb = null; }
779
+ if (this._rc) { clearInterval(this._rc); this._rc = null; }
780
+ this.clients.forEach(r => { if (!r.socket.destroyed) r.socket.destroy(); });
781
+ this.clients.clear(); this.byId.clear(); this.rooms.clear(); this.clientRates.clear();
1789
782
  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;
783
+ if (this._upgH) this.httpServer.off('upgrade', this._upgH);
784
+ if (this._reqH) this.httpServer.off('request', this._reqH);
785
+ if (!this._ext.has(this.httpServer)) this.httpServer.close();
786
+ this.httpServer = null; this._upgH = null; this._reqH = null;
1802
787
  }
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');
788
+ if (this.tcpServer) { this.tcpServer.close(); this.tcpServer = null; }
789
+ this._started = false; this._removeSignals(); this.log.info('Server stopped');
1812
790
  return this;
1813
791
  }
1814
792
  }