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/package.json +1 -1
- package/src/client.d.ts +47 -59
- package/src/client.d.ts.map +1 -1
- package/src/client.js +406 -728
- package/src/client.ts +317 -908
- package/src/index.d.ts +84 -124
- package/src/index.d.ts.map +1 -1
- package/src/index.js +740 -1165
- package/src/index.ts +552 -1574
- package/src/logger.d.ts +12 -17
- package/src/logger.d.ts.map +1 -1
- package/src/logger.js +34 -90
- package/src/logger.ts +31 -98
- package/src/protocol.d.ts +16 -34
- package/src/protocol.d.ts.map +1 -1
- package/src/protocol.js +56 -148
- package/src/protocol.ts +66 -188
- package/src/websocket.d.ts +21 -43
- package/src/websocket.d.ts.map +1 -1
- package/src/websocket.js +106 -216
- package/src/websocket.ts +78 -279
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
|
|
9
|
-
import { createServer as
|
|
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
|
|
12
|
-
import { createServer as createTlsServer } from 'tls';
|
|
8
|
+
import { createServer as createTls, TlsOptions } from 'tls';
|
|
13
9
|
|
|
14
10
|
import {
|
|
15
|
-
FrameParser,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
309
|
-
private
|
|
310
|
-
private
|
|
129
|
+
private ns: string;
|
|
130
|
+
private hbInterval: number;
|
|
131
|
+
private hbTimeout: number;
|
|
311
132
|
private tcpPort: number | false;
|
|
312
|
-
private
|
|
133
|
+
private maxConns: number;
|
|
313
134
|
private maxRooms: number;
|
|
314
135
|
private maxRoomsPerClient: number;
|
|
315
|
-
private
|
|
316
|
-
private
|
|
317
|
-
private
|
|
318
|
-
private
|
|
319
|
-
private
|
|
320
|
-
private
|
|
321
|
-
private
|
|
322
|
-
private
|
|
323
|
-
private
|
|
324
|
-
|
|
325
|
-
private
|
|
326
|
-
private
|
|
327
|
-
private
|
|
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
|
|
331
|
-
private
|
|
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
|
|
335
|
-
private rooms = new Map<string, Set<string>>();
|
|
336
|
-
private events
|
|
337
|
-
private
|
|
338
|
-
private
|
|
339
|
-
private
|
|
340
|
-
private
|
|
341
|
-
private
|
|
342
|
-
private _acks
|
|
343
|
-
private
|
|
344
|
-
private
|
|
345
|
-
private
|
|
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
|
|
349
|
-
private
|
|
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
|
|
355
|
-
|
|
356
|
-
private
|
|
357
|
-
private
|
|
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(
|
|
363
|
-
this.port =
|
|
364
|
-
this.httpServer =
|
|
365
|
-
this.
|
|
366
|
-
this.
|
|
367
|
-
this.
|
|
368
|
-
this.tcpPort =
|
|
369
|
-
this.
|
|
370
|
-
this.maxRooms =
|
|
371
|
-
this.maxRoomsPerClient =
|
|
372
|
-
this.
|
|
373
|
-
this.
|
|
374
|
-
this.
|
|
375
|
-
this.
|
|
376
|
-
this.
|
|
377
|
-
this.
|
|
378
|
-
this.
|
|
379
|
-
this.
|
|
380
|
-
this.
|
|
381
|
-
|
|
382
|
-
this.
|
|
383
|
-
this.
|
|
384
|
-
this.
|
|
385
|
-
this.
|
|
386
|
-
this.
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
482
|
-
removeClientRateLimit(
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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((
|
|
576
|
-
|
|
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,
|
|
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
|
|
591
|
-
if (!
|
|
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
|
|
595
|
-
|
|
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
|
|
605
|
-
if (
|
|
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)
|
|
290
|
+
getClients(room?: string) {
|
|
612
291
|
const list: { id: string; rooms: string[] }[] = [];
|
|
613
|
-
this.clients.forEach((
|
|
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):
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
640
|
-
|
|
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.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return
|
|
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
|
-
|
|
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
|
|
687
|
-
if (
|
|
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 (
|
|
690
|
-
const
|
|
691
|
-
|
|
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
|
-
|
|
338
|
+
r.socket.write(encodeBinaryFrame(event, new Uint8Array(buf), this.maxFrame));
|
|
694
339
|
}
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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.
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
if (
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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:
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
if (
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
852
|
-
const run = (i: number)
|
|
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
|
-
|
|
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
|
|
890
|
-
if (
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
}
|
|
896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
this.
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
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
|
|
493
|
+
const extra: Record<string, string> = {};
|
|
1019
494
|
const origin = req.headers['origin'];
|
|
1020
|
-
if (origin && this.
|
|
1021
|
-
|
|
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
|
|
499
|
+
const timer = setTimeout(() => { if (!this.clients.has(socket)) socket.destroy(); }, this.connTimeout);
|
|
500
|
+
timer.unref();
|
|
1044
501
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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.
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
517
|
+
private _processWS(r: ClientRecord, data: Buffer, ctx: StelarContext) {
|
|
1092
518
|
let frames: WSFrame[];
|
|
1093
|
-
try {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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(
|
|
527
|
+
private _handleWSFrame(r: ClientRecord, frame: WSFrame, ctx: StelarContext) {
|
|
1115
528
|
const { opcode, payload } = frame;
|
|
1116
529
|
|
|
1117
|
-
if (opcode === OP_PING) {
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1153
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
1184
|
-
this.
|
|
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
|
|
1221
|
-
|
|
1222
|
-
try {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1255
|
-
this.
|
|
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
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
if (
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
|
1286
|
-
|
|
1287
|
-
if (
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
603
|
+
private _processTCP(r: ClientRecord, data: Buffer, ctx: StelarContext) {
|
|
1352
604
|
let frames: ParsedFrame[];
|
|
1353
|
-
try {
|
|
1354
|
-
|
|
1355
|
-
|
|
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(
|
|
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 (
|
|
1376
|
-
|
|
1377
|
-
|
|
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 (
|
|
1387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1459
|
-
const
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
this.
|
|
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 (
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
|
1484
|
-
const
|
|
1485
|
-
if (
|
|
1486
|
-
|
|
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
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
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.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
694
|
+
/* ── Private: graceful shutdown ── */
|
|
1551
695
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
this.
|
|
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
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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.
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
if (this.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
|
1733
|
-
this.tcpServer =
|
|
1734
|
-
|
|
1735
|
-
this.tcpServer.
|
|
1736
|
-
|
|
1737
|
-
|
|
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.
|
|
1777
|
-
if (this.
|
|
1778
|
-
|
|
1779
|
-
this.clients.
|
|
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.
|
|
1791
|
-
|
|
1792
|
-
|
|
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
|
-
|
|
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
|
}
|