stelar-time-real 3.3.0 → 3.3.2
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 +10 -3
- package/src/client.d.ts.map +1 -1
- package/src/client.js +105 -58
- package/src/client.ts +92 -42
- package/src/index.d.ts +8 -6
- package/src/index.d.ts.map +1 -1
- package/src/index.js +161 -105
- package/src/index.ts +127 -79
- package/src/logger.d.ts +0 -1
- package/src/logger.d.ts.map +1 -1
- package/src/logger.js +5 -8
- package/src/logger.ts +6 -10
- package/src/protocol.d.ts +5 -5
- package/src/protocol.d.ts.map +1 -1
- package/src/protocol.js +39 -25
- package/src/protocol.ts +31 -41
- package/src/websocket.d.ts +14 -8
- package/src/websocket.d.ts.map +1 -1
- package/src/websocket.js +82 -33
- package/src/websocket.ts +69 -31
package/src/index.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP
|
|
3
|
-
*/
|
|
1
|
+
/** @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP */
|
|
4
2
|
|
|
5
3
|
import { createServer as createHttp, IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
|
|
6
4
|
import { createServer as createTcp, Server as TcpServer, Socket as NetSocket } from 'net';
|
|
@@ -10,7 +8,7 @@ import { createServer as createTls, TlsOptions } from 'tls';
|
|
|
10
8
|
import {
|
|
11
9
|
FrameParser, ParsedFrame, encodeJsonFrame, encodeBinaryFrame, encodePingFrame, encodePongFrame,
|
|
12
10
|
encodeAckResFrame, encodeConnectFrame, encodeDisconnectFrame, encodeJoinFrame, encodeLeaveFrame,
|
|
13
|
-
encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ,
|
|
11
|
+
encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ,
|
|
14
12
|
FRAME_JOIN, FRAME_LEAVE, FRAME_CONNECT, ProtocolError, DEFAULT_MAX_FRAME_SIZE,
|
|
15
13
|
} from './protocol.js';
|
|
16
14
|
|
|
@@ -19,7 +17,7 @@ import {
|
|
|
19
17
|
createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame,
|
|
20
18
|
OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError,
|
|
21
19
|
CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_NORMAL, CLOSE_GOING_AWAY,
|
|
22
|
-
DEFAULT_MAX_WS_FRAME_SIZE,
|
|
20
|
+
DEFAULT_MAX_WS_FRAME_SIZE, clientWantsCompression,
|
|
23
21
|
} from './websocket.js';
|
|
24
22
|
|
|
25
23
|
import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
|
|
@@ -56,6 +54,7 @@ export interface StelarOptions {
|
|
|
56
54
|
customRateLimiter?: IRateLimiter; customIPTracker?: IIPTracker;
|
|
57
55
|
generateClientId?: () => string; eventRateLimits?: EventRateLimits;
|
|
58
56
|
hooks?: StelarHooks; customHealthHandler?: (req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void;
|
|
57
|
+
compression?: boolean;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
export interface StelarClientInfo {
|
|
@@ -92,9 +91,35 @@ export interface StelarStats {
|
|
|
92
91
|
tcpConnections: number; memoryUsage: NodeJS.MemoryUsage; rateLimiterEntries: number;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
/* ── Internal
|
|
94
|
+
/* ── Internal ── */
|
|
96
95
|
|
|
97
|
-
interface ClientRecord {
|
|
96
|
+
interface ClientRecord {
|
|
97
|
+
info: StelarClientInfo; socket: NetSocket; parser: WSFrameParser | FrameParser;
|
|
98
|
+
protocol: 'ws' | 'tcp'; compress: boolean;
|
|
99
|
+
_hbTimer: ReturnType<typeof setInterval> | null;
|
|
100
|
+
_writePaused: boolean; _writeQueue: Buffer[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** WS binary framing: [4B headerLen BE][header JSON][binary payload] — length-prefixed, not null-delimited */
|
|
104
|
+
function encodeWSBinary(event: string, data: Uint8Array | Buffer): Buffer {
|
|
105
|
+
const hdr = Buffer.from(JSON.stringify({ event, _binary: true }), 'utf8');
|
|
106
|
+
const payload = Buffer.from(data);
|
|
107
|
+
const frame = Buffer.alloc(4 + hdr.length + payload.length);
|
|
108
|
+
frame.writeUInt32BE(hdr.length, 0);
|
|
109
|
+
hdr.copy(frame, 4);
|
|
110
|
+
payload.copy(frame, 4 + hdr.length);
|
|
111
|
+
return frame;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseWSBinary(payload: Buffer): { event: string; buffer: Buffer } | null {
|
|
115
|
+
if (payload.length < 4) return null;
|
|
116
|
+
const hdrLen = payload.readUInt32BE(0);
|
|
117
|
+
if (hdrLen > payload.length - 4) return null;
|
|
118
|
+
try {
|
|
119
|
+
const hdr = JSON.parse(payload.subarray(4, 4 + hdrLen).toString('utf8'));
|
|
120
|
+
return { event: hdr.event, buffer: payload.subarray(4 + hdrLen) };
|
|
121
|
+
} catch { return null; }
|
|
122
|
+
}
|
|
98
123
|
|
|
99
124
|
class RateLimiter implements IRateLimiter {
|
|
100
125
|
private limits = new Map<string, { count: number; resetTime: number }>();
|
|
@@ -149,13 +174,13 @@ class StelarServer {
|
|
|
149
174
|
private hooks: StelarHooks;
|
|
150
175
|
private evRateLimits = new Map<string, RateLimiter>();
|
|
151
176
|
private clientRates = new Map<string, RateLimiter>();
|
|
177
|
+
private doCompress: boolean;
|
|
152
178
|
|
|
153
179
|
private clients = new Map<NetSocket, ClientRecord>();
|
|
154
180
|
private byId = new Map<string, ClientRecord>();
|
|
155
181
|
private rooms = new Map<string, Set<string>>();
|
|
156
182
|
private events = new Map<string, StelarEventHandler>();
|
|
157
183
|
private mw: StelarMiddleware[] = [];
|
|
158
|
-
private _hb: ReturnType<typeof setInterval> | null = null;
|
|
159
184
|
private _rc: ReturnType<typeof setInterval> | null = null;
|
|
160
185
|
private _wild: StelarWildcardHandler | null = null;
|
|
161
186
|
private _connH: StelarEventHandler | null = null;
|
|
@@ -199,6 +224,7 @@ class StelarServer {
|
|
|
199
224
|
this._genId = o.generateClientId || null;
|
|
200
225
|
this._healthFn = o.customHealthHandler || null;
|
|
201
226
|
this.hooks = o.hooks || {};
|
|
227
|
+
this.doCompress = o.compression || false;
|
|
202
228
|
if (o.eventRateLimits) for (const [ev, c] of Object.entries(o.eventRateLimits)) this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs));
|
|
203
229
|
const rl = o.rateLimit && typeof o.rateLimit === 'object' ? o.rateLimit : {};
|
|
204
230
|
this.rateLimiter = o.rateLimit === false && !this._crl ? null : this._crl ? null : new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
|
|
@@ -219,6 +245,7 @@ class StelarServer {
|
|
|
219
245
|
if (o.heartbeatInterval !== undefined) this.hbInterval = o.heartbeatInterval;
|
|
220
246
|
if (o.heartbeatTimeout !== undefined) this.hbTimeout = o.heartbeatTimeout;
|
|
221
247
|
if (o.allowedOrigins !== undefined) this.origins = o.allowedOrigins;
|
|
248
|
+
if (o.compression !== undefined) this.doCompress = o.compression;
|
|
222
249
|
if (o.rateLimit === false) { this.rateLimiter = null; this._crl = null; }
|
|
223
250
|
else if (o.rateLimit && !this._crl) this.rateLimiter = new RateLimiter(o.rateLimit.maxPoints || 100, o.rateLimit.windowMs || 1000);
|
|
224
251
|
if (o.customRateLimiter !== undefined) { this._crl = o.customRateLimiter; this.rateLimiter = null; }
|
|
@@ -241,7 +268,8 @@ class StelarServer {
|
|
|
241
268
|
maxConnections: this.maxConns, maxConnectionsPerIP: this._cit ? -1 : 50,
|
|
242
269
|
maxRooms: this.maxRooms, maxRoomsPerClient: this.maxRoomsPerClient, maxPayloadSize: this.maxPayload,
|
|
243
270
|
heartbeatInterval: this.hbInterval, heartbeatTimeout: this.hbTimeout, connectTimeout: this.connTimeout,
|
|
244
|
-
shutdownTimeout: this.shutdownMs,
|
|
271
|
+
shutdownTimeout: this.shutdownMs, compression: this.doCompress,
|
|
272
|
+
hasCustomRateLimiter: this._crl !== null, hasCustomIPTracker: this._cit !== null,
|
|
245
273
|
hasCustomClientIdGenerator: this._genId !== null, hasCustomHealthHandler: this._healthFn !== null,
|
|
246
274
|
eventRateLimits: Array.from(this.evRateLimits.keys()), hooks: Object.keys(this.hooks), allowedOrigins: this.origins,
|
|
247
275
|
});
|
|
@@ -260,23 +288,30 @@ class StelarServer {
|
|
|
260
288
|
|
|
261
289
|
broadcast(event: string, data: unknown, excludeId?: string): this {
|
|
262
290
|
if (this.hooks.onBeforeBroadcast?.({ event, data, excludeId }) === false) return this;
|
|
263
|
-
const
|
|
291
|
+
const json = JSON.stringify({ event, data });
|
|
292
|
+
const wsF = createWSTextFrame(json);
|
|
293
|
+
const wsFC = this.doCompress ? createWSTextFrame(json, true) : wsF;
|
|
264
294
|
const tcpF = encodeJsonFrame(event, data, this.maxFrame);
|
|
265
295
|
let sent = 0;
|
|
266
|
-
this.clients.forEach(r => { if (excludeId && r.info.id === excludeId) return; if (this._write(r, wsF, tcpF)) sent++; });
|
|
296
|
+
this.clients.forEach(r => { if (excludeId && r.info.id === excludeId) return; if (this._write(r, r.compress ? wsFC : wsF, tcpF)) sent++; });
|
|
267
297
|
this._totalSent += sent;
|
|
268
298
|
return this;
|
|
269
299
|
}
|
|
270
300
|
|
|
271
|
-
broadcastBinary(event: string, buf: ArrayBuffer) {
|
|
301
|
+
broadcastBinary(event: string, buf: ArrayBuffer) {
|
|
302
|
+
const safeCopy = Buffer.from(new Uint8Array(buf));
|
|
303
|
+
this.clients.forEach(r => this._sendBin(r, event, safeCopy));
|
|
304
|
+
}
|
|
272
305
|
|
|
273
306
|
to(room: string, event: string, data: unknown, excludeId?: string): this {
|
|
274
307
|
const ids = this.rooms.get(room);
|
|
275
308
|
if (!ids) return this;
|
|
276
|
-
const
|
|
309
|
+
const json = JSON.stringify({ event, data });
|
|
310
|
+
const wsF = createWSTextFrame(json);
|
|
311
|
+
const wsFC = this.doCompress ? createWSTextFrame(json, true) : wsF;
|
|
277
312
|
const tcpF = encodeJsonFrame(event, data, this.maxFrame);
|
|
278
313
|
let sent = 0;
|
|
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++; }
|
|
314
|
+
for (const id of ids) { if (excludeId && id === excludeId) continue; const r = this.byId.get(id); if (r && this._write(r, r.compress ? wsFC : wsF, tcpF)) sent++; }
|
|
280
315
|
this._totalSent += sent;
|
|
281
316
|
return this;
|
|
282
317
|
}
|
|
@@ -311,36 +346,60 @@ class StelarServer {
|
|
|
311
346
|
|
|
312
347
|
onShutdown(cb: (sig: string, force: boolean) => void) { this._shutdownCbs.push(cb); return this; }
|
|
313
348
|
|
|
314
|
-
/* ── Private:
|
|
349
|
+
/* ── Private: backpressure-aware write ── */
|
|
315
350
|
|
|
316
|
-
private
|
|
351
|
+
private _write(r: ClientRecord, wsF: Buffer, tcpF: Buffer): boolean {
|
|
317
352
|
if (r.socket.destroyed || r.socket.writableEnded) return false;
|
|
353
|
+
if (r._writePaused) { r._writeQueue.push(r.protocol === 'ws' ? wsF : tcpF); return true; }
|
|
318
354
|
try {
|
|
319
|
-
r.socket.write(r.protocol === 'ws' ?
|
|
355
|
+
const ok = r.socket.write(r.protocol === 'ws' ? wsF : tcpF);
|
|
356
|
+
if (!ok) r._writePaused = true;
|
|
320
357
|
r.info.messagesSent++; return true;
|
|
321
358
|
} catch { return false; }
|
|
322
359
|
}
|
|
323
360
|
|
|
324
|
-
private
|
|
361
|
+
private _sendJson(r: ClientRecord, event: string, data: unknown): boolean {
|
|
325
362
|
if (r.socket.destroyed || r.socket.writableEnded) return false;
|
|
326
|
-
try {
|
|
363
|
+
try {
|
|
364
|
+
const frame = r.protocol === 'ws' ? createWSTextFrame(JSON.stringify({ event, data }), r.compress) : encodeJsonFrame(event, data, this.maxFrame);
|
|
365
|
+
if (r._writePaused) { r._writeQueue.push(frame); r.info.messagesSent++; return true; }
|
|
366
|
+
const ok = r.socket.write(frame);
|
|
367
|
+
if (!ok) r._writePaused = true;
|
|
368
|
+
r.info.messagesSent++; return true;
|
|
369
|
+
} catch { return false; }
|
|
327
370
|
}
|
|
328
371
|
|
|
329
|
-
private _sendBin(r: ClientRecord, event: string, buf:
|
|
372
|
+
private _sendBin(r: ClientRecord, event: string, buf: Buffer): boolean {
|
|
330
373
|
if (r.socket.destroyed || r.socket.writableEnded) return false;
|
|
331
374
|
try {
|
|
332
375
|
if (r.protocol === 'ws') {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
r.
|
|
376
|
+
const frame = encodeWSBinary(event, buf);
|
|
377
|
+
if (r._writePaused) { r._writeQueue.push(createWSBinaryFrame(frame)); r.info.messagesSent++; return true; }
|
|
378
|
+
const ok = r.socket.write(createWSBinaryFrame(frame));
|
|
379
|
+
if (!ok) r._writePaused = true;
|
|
337
380
|
} else {
|
|
338
|
-
|
|
381
|
+
const frame = encodeBinaryFrame(event, buf, this.maxFrame);
|
|
382
|
+
if (r._writePaused) { r._writeQueue.push(frame); r.info.messagesSent++; return true; }
|
|
383
|
+
const ok = r.socket.write(frame);
|
|
384
|
+
if (!ok) r._writePaused = true;
|
|
339
385
|
}
|
|
340
386
|
r.info.messagesSent++; return true;
|
|
341
387
|
} catch { return false; }
|
|
342
388
|
}
|
|
343
389
|
|
|
390
|
+
private _flushQueue(r: ClientRecord) {
|
|
391
|
+
r._writePaused = false;
|
|
392
|
+
while (r._writeQueue.length) {
|
|
393
|
+
const buf = r._writeQueue.shift()!;
|
|
394
|
+
if (!r.socket.destroyed && !r.socket.writableEnded) {
|
|
395
|
+
try {
|
|
396
|
+
const ok = r.socket.write(buf);
|
|
397
|
+
if (!ok) { r._writePaused = true; break; }
|
|
398
|
+
} catch { break; }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
344
403
|
private _checkRate(cid: string, event?: string): boolean {
|
|
345
404
|
const co = this.clientRates.get(cid);
|
|
346
405
|
if (co) return co.check(cid);
|
|
@@ -355,9 +414,23 @@ class StelarServer {
|
|
|
355
414
|
return socket.remoteAddress || 'unknown';
|
|
356
415
|
}
|
|
357
416
|
|
|
417
|
+
/* ── Private: per-client heartbeat ── */
|
|
418
|
+
|
|
419
|
+
private _startClientHB(r: ClientRecord) {
|
|
420
|
+
r._hbTimer = setInterval(() => {
|
|
421
|
+
if (r.socket.destroyed) { this._stopClientHB(r); return; }
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
if (now - r.info.lastPing > this.hbTimeout) { r.socket.destroy(); return; }
|
|
424
|
+
try { r.socket.write(r.protocol === 'ws' ? createWSPingFrame() : encodePingFrame()); } catch {}
|
|
425
|
+
}, this.hbInterval);
|
|
426
|
+
r._hbTimer.unref();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private _stopClientHB(r: ClientRecord) { if (r._hbTimer) { clearInterval(r._hbTimer); r._hbTimer = null; } }
|
|
430
|
+
|
|
358
431
|
/* ── Private: client lifecycle ── */
|
|
359
432
|
|
|
360
|
-
private _register(socket: NetSocket, proto: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser): ClientRecord | null {
|
|
433
|
+
private _register(socket: NetSocket, proto: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser, compress = false): ClientRecord | null {
|
|
361
434
|
const ip = this._getIP(socket, req);
|
|
362
435
|
if (this.clients.size >= this.maxConns) {
|
|
363
436
|
this.hooks.onMaxConnectionsReached?.({ activeConnections: this.clients.size, max: this.maxConns, ip });
|
|
@@ -373,12 +446,13 @@ class StelarServer {
|
|
|
373
446
|
}
|
|
374
447
|
const id = this._genId ? this._genId() : randomUUID();
|
|
375
448
|
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 };
|
|
449
|
+
const record: ClientRecord = { info, socket, parser, protocol: proto, compress, _hbTimer: null, _writePaused: false, _writeQueue: [] };
|
|
377
450
|
this.clients.set(socket, record); this.byId.set(id, record); tracker.add(ip); this._totalConns++;
|
|
378
451
|
return record;
|
|
379
452
|
}
|
|
380
453
|
|
|
381
454
|
private _unregister(r: ClientRecord, ctx: StelarContext) {
|
|
455
|
+
this._stopClientHB(r);
|
|
382
456
|
this.hooks.onClientDisconnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: r.info.protocol, rooms: new Set(r.info.rooms) });
|
|
383
457
|
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
458
|
r.info.rooms.clear();
|
|
@@ -415,8 +489,8 @@ class StelarServer {
|
|
|
415
489
|
const ctx: StelarContext = {
|
|
416
490
|
id: r.info.id, socket: r.socket, req, clientInfo: r.info,
|
|
417
491
|
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++; },
|
|
492
|
+
send: (rid, d) => { if (s._sendJson(r, rid, { data: d, _isAck: true, _correlationId: ctx._correlationId })) s._totalSent++; },
|
|
493
|
+
emitBinary: (ev, buf) => { if (s._sendBin(r, ev, Buffer.from(new Uint8Array(buf)))) s._totalSent++; },
|
|
420
494
|
broadcast: (ev, d) => s.broadcast(ev, d, r.info.id),
|
|
421
495
|
broadcastBinary: (ev, buf) => s.broadcastBinary(ev, buf),
|
|
422
496
|
to: (room, ev, d) => s.to(room, ev, d, r.info.id),
|
|
@@ -436,7 +510,7 @@ class StelarServer {
|
|
|
436
510
|
if (r.protocol === 'ws') {
|
|
437
511
|
const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
|
|
438
512
|
if (ctx._correlationId) p._correlationId = ctx._correlationId;
|
|
439
|
-
r.socket.write(createWSTextFrame(JSON.stringify(p)));
|
|
513
|
+
r.socket.write(createWSTextFrame(JSON.stringify(p), r.compress));
|
|
440
514
|
} else {
|
|
441
515
|
r.socket.write(ctx._correlationId
|
|
442
516
|
? encodeAckResFrame(name, { data: res, _correlationId: ctx._correlationId }, s.maxFrame)
|
|
@@ -455,7 +529,7 @@ class StelarServer {
|
|
|
455
529
|
run(0);
|
|
456
530
|
}
|
|
457
531
|
|
|
458
|
-
/* ── Private: event dispatch
|
|
532
|
+
/* ── Private: event dispatch ── */
|
|
459
533
|
|
|
460
534
|
private _dispatch(r: ClientRecord, ctx: StelarContext, event: string, data: unknown, correlationId?: string) {
|
|
461
535
|
if (event === 'pong') { r.info.lastPing = Date.now(); return; }
|
|
@@ -467,19 +541,6 @@ class StelarServer {
|
|
|
467
541
|
if (this._wild) try { this._wild({ event, data: ectx }); } catch (e) { this.log.error('Wildcard error', { error: String(e) }); }
|
|
468
542
|
}
|
|
469
543
|
|
|
470
|
-
/* ── Private: heartbeat ── */
|
|
471
|
-
|
|
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 {}
|
|
478
|
-
});
|
|
479
|
-
}, this.hbInterval);
|
|
480
|
-
this._hb?.unref?.();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
544
|
/* ── Private: WS upgrade ── */
|
|
484
545
|
|
|
485
546
|
private _wsUpgrade(req: IncomingMessage, socket: NetSocket, head: Buffer) {
|
|
@@ -489,29 +550,31 @@ class StelarServer {
|
|
|
489
550
|
if (this.origins && !this.origins.includes(req.headers['origin'] || '')) { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; }
|
|
490
551
|
const key = req.headers['sec-websocket-key'] as string;
|
|
491
552
|
if (!key || !validateWSKey(key)) { socket.destroy(); return; }
|
|
553
|
+
const clientCompress = this.doCompress && clientWantsCompression(req.headers['sec-websocket-extensions'] as string);
|
|
492
554
|
try {
|
|
493
555
|
const extra: Record<string, string> = {};
|
|
494
556
|
const origin = req.headers['origin'];
|
|
495
557
|
if (origin && this.origins?.includes(origin)) extra['Access-Control-Allow-Origin'] = origin;
|
|
496
|
-
socket.write(buildUpgradeResponse(key, extra));
|
|
558
|
+
socket.write(buildUpgradeResponse(key, extra, clientCompress));
|
|
497
559
|
} catch { socket.destroy(); return; }
|
|
498
560
|
|
|
499
561
|
const timer = setTimeout(() => { if (!this.clients.has(socket)) socket.destroy(); }, this.connTimeout);
|
|
500
562
|
timer.unref();
|
|
501
563
|
|
|
502
|
-
const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame));
|
|
564
|
+
const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame), clientCompress);
|
|
503
565
|
if (!r) { clearTimeout(timer); return; }
|
|
504
566
|
const ctx = this._buildCtx(r, req);
|
|
505
567
|
|
|
506
568
|
this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'ws', metadata: r.info.metadata });
|
|
507
569
|
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 });
|
|
570
|
+
this.log.info('WS connected', { clientId: r.info.id, ip: r.info.remoteAddress, compressed: clientCompress });
|
|
571
|
+
this._startClientHB(r);
|
|
509
572
|
|
|
510
573
|
if (head.length > 0) this._processWS(r, head, ctx);
|
|
511
574
|
socket.on('data', (d: Buffer) => { clearTimeout(timer); this._processWS(r, d, ctx); });
|
|
512
575
|
socket.on('close', () => { clearTimeout(timer); this._unregister(r, ctx); });
|
|
513
576
|
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', () =>
|
|
577
|
+
socket.on('drain', () => this._flushQueue(r));
|
|
515
578
|
}
|
|
516
579
|
|
|
517
580
|
private _processWS(r: ClientRecord, data: Buffer, ctx: StelarContext) {
|
|
@@ -556,7 +619,7 @@ class StelarServer {
|
|
|
556
619
|
if (res !== undefined) {
|
|
557
620
|
const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
|
|
558
621
|
if (corrId) p._correlationId = corrId;
|
|
559
|
-
try { r.socket.write(createWSTextFrame(JSON.stringify(p))); this._totalSent++; } catch {}
|
|
622
|
+
try { r.socket.write(createWSTextFrame(JSON.stringify(p), r.compress)); this._totalSent++; } catch {}
|
|
560
623
|
}
|
|
561
624
|
return;
|
|
562
625
|
}
|
|
@@ -566,21 +629,17 @@ class StelarServer {
|
|
|
566
629
|
if (opcode === OP_BINARY) {
|
|
567
630
|
r.info.messagesReceived++; this._totalRecv++;
|
|
568
631
|
if (payload.length > this.maxPayload) { this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload }); return; }
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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' }); }
|
|
632
|
+
const parsed = parseWSBinary(payload);
|
|
633
|
+
if (!parsed) { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid binary frame', protocol: 'ws' }); return; }
|
|
634
|
+
if (parsed.event && !this._checkRate(r.info.id, parsed.event)) {
|
|
635
|
+
this.log.warn('Binary rate limit', { clientId: r.info.id, event: parsed.event });
|
|
636
|
+
if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: parsed.event, protocol: 'ws' }) === false) return;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const ectx: StelarContext = { ...ctx, data: parsed.buffer, buffer: parsed.buffer, isBinary: true, event: parsed.event };
|
|
640
|
+
const h = this.events.get(parsed.event);
|
|
641
|
+
if (h) try { h(ectx); } catch {}
|
|
642
|
+
if (this._wild) try { this._wild({ event: parsed.event, data: ectx }); } catch {}
|
|
584
643
|
}
|
|
585
644
|
}
|
|
586
645
|
|
|
@@ -594,10 +653,11 @@ class StelarServer {
|
|
|
594
653
|
this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'tcp', metadata: r.info.metadata });
|
|
595
654
|
this._runMw(ctx, () => { if (this._connH) try { this._connH(ctx); } catch (e) { this.log.error('TCP connection handler error', { error: String(e) }); } });
|
|
596
655
|
this.log.info('TCP connected', { clientId: r.info.id, ip: r.info.remoteAddress });
|
|
656
|
+
this._startClientHB(r);
|
|
597
657
|
socket.on('data', (d: Buffer) => this._processTCP(r, d, ctx));
|
|
598
658
|
socket.on('close', () => this._unregister(r, ctx));
|
|
599
659
|
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', () =>
|
|
660
|
+
socket.on('drain', () => this._flushQueue(r));
|
|
601
661
|
}
|
|
602
662
|
|
|
603
663
|
private _processTCP(r: ClientRecord, data: Buffer, ctx: StelarContext) {
|
|
@@ -651,17 +711,6 @@ class StelarServer {
|
|
|
651
711
|
return;
|
|
652
712
|
}
|
|
653
713
|
|
|
654
|
-
if (type === FRAME_ACK_RES) {
|
|
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 {}
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
714
|
if (type === FRAME_BINARY) {
|
|
666
715
|
const ectx: StelarContext = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
|
|
667
716
|
const h = this.events.get(event);
|
|
@@ -733,7 +782,6 @@ class StelarServer {
|
|
|
733
782
|
this._reqH = (req, res) => this._health(req, res);
|
|
734
783
|
this._upgH = (req, socket, head) => this._wsUpgrade(req, socket, head);
|
|
735
784
|
srv.on('request', this._reqH); srv.on('upgrade', this._upgH);
|
|
736
|
-
this._startHeartbeat();
|
|
737
785
|
this._rc = setInterval(() => {
|
|
738
786
|
if (this._crl) this._crl.cleanup(); else this.rateLimiter?.cleanup();
|
|
739
787
|
(this._cit || this.ipTracker).cleanup();
|
|
@@ -742,7 +790,7 @@ class StelarServer {
|
|
|
742
790
|
}, 30000);
|
|
743
791
|
this._rc?.unref?.();
|
|
744
792
|
this._setupShutdown();
|
|
745
|
-
const p = this.getPort(); this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts }); cb?.(p); resolve(p);
|
|
793
|
+
const p = this.getPort(); this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts, compression: this.doCompress }); cb?.(p); resolve(p);
|
|
746
794
|
};
|
|
747
795
|
if (this.httpServer) { this._ext.add(this.httpServer); onHttp(this.httpServer); }
|
|
748
796
|
else {
|
|
@@ -775,7 +823,7 @@ class StelarServer {
|
|
|
775
823
|
}
|
|
776
824
|
|
|
777
825
|
stop(): this {
|
|
778
|
-
|
|
826
|
+
this.clients.forEach(r => this._stopClientHB(r));
|
|
779
827
|
if (this._rc) { clearInterval(this._rc); this._rc = null; }
|
|
780
828
|
this.clients.forEach(r => { if (!r.socket.destroyed) r.socket.destroy(); });
|
|
781
829
|
this.clients.clear(); this.byId.clear(); this.rooms.clear(); this.clientRates.clear();
|
package/src/logger.d.ts
CHANGED
package/src/logger.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMtE,MAAM,WAAW,aAAa;IAAG,KAAK,CAAC,EAAE,QAAQ,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAAE;AAE9G,qBAAa,MAAM;IACjB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,EAAE,CAAU;IACpB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAU;gBAEX,CAAC,GAAE,aAAkB;IAOjC,QAAQ,CAAC,CAAC,EAAE,QAAQ;IAEpB,OAAO,CAAC,
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMtE,MAAM,WAAW,aAAa;IAAG,KAAK,CAAC,EAAE,QAAQ,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAAE;AAE9G,qBAAa,MAAM;IACjB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,EAAE,CAAU;IACpB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAU;gBAEX,CAAC,GAAE,aAAkB;IAOjC,QAAQ,CAAC,CAAC,EAAE,QAAQ;IAEpB,OAAO,CAAC,CAAC;IAYT,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC/C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAChD;AAED,eAAO,MAAM,WAAW,QAAkC,CAAC"}
|
package/src/logger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @stelar-time-real Logger */
|
|
2
|
-
const
|
|
2
|
+
const P = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
3
3
|
const C = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', reset: '\x1b[0m' };
|
|
4
4
|
const isBrowser = typeof window !== 'undefined' && typeof process === 'undefined';
|
|
5
5
|
export class Logger {
|
|
@@ -10,7 +10,9 @@ export class Logger {
|
|
|
10
10
|
this.color = isBrowser ? false : o.colorize !== false;
|
|
11
11
|
}
|
|
12
12
|
setLevel(l) { this.level = l; return this; }
|
|
13
|
-
|
|
13
|
+
w(lvl, err, msg, meta) {
|
|
14
|
+
if (P[lvl] < P[this.level])
|
|
15
|
+
return;
|
|
14
16
|
const p = [];
|
|
15
17
|
if (this.ts)
|
|
16
18
|
p.push(new Date().toISOString());
|
|
@@ -23,12 +25,7 @@ export class Logger {
|
|
|
23
25
|
catch {
|
|
24
26
|
p.push('[circular]');
|
|
25
27
|
}
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
w(lvl, err, msg, meta) {
|
|
29
|
-
if (PRIORITY[lvl] < PRIORITY[this.level])
|
|
30
|
-
return;
|
|
31
|
-
const f = this.fmt(lvl, msg, meta);
|
|
28
|
+
const f = p.join(' ');
|
|
32
29
|
if (isBrowser)
|
|
33
30
|
console[lvl]?.(f);
|
|
34
31
|
else
|
package/src/logger.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
const C
|
|
5
|
+
const P: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
6
|
+
const C = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', reset: '\x1b[0m' };
|
|
7
7
|
const isBrowser = typeof window !== 'undefined' && typeof process === 'undefined';
|
|
8
8
|
|
|
9
9
|
export interface LoggerOptions { level?: LogLevel; timestamp?: boolean; prefix?: string; colorize?: boolean; }
|
|
@@ -23,18 +23,14 @@ export class Logger {
|
|
|
23
23
|
|
|
24
24
|
setLevel(l: LogLevel) { this.level = l; return this; }
|
|
25
25
|
|
|
26
|
-
private
|
|
26
|
+
private w(lvl: string, err: boolean, msg: string, meta?: Record<string, unknown>) {
|
|
27
|
+
if (P[lvl as LogLevel] < P[this.level]) return;
|
|
27
28
|
const p: string[] = [];
|
|
28
29
|
if (this.ts) p.push(new Date().toISOString());
|
|
29
|
-
p.push(this.color ? `${C[lvl] || ''}[${this.pfx}:${lvl}]${C.reset}` : `[${this.pfx}:${lvl}]`);
|
|
30
|
+
p.push(this.color ? `${C[lvl as keyof typeof C] || ''}[${this.pfx}:${lvl}]${C.reset}` : `[${this.pfx}:${lvl}]`);
|
|
30
31
|
p.push(msg);
|
|
31
32
|
if (meta && Object.keys(meta).length) try { p.push(JSON.stringify(meta)); } catch { p.push('[circular]'); }
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private w(lvl: string, err: boolean, msg: string, meta?: Record<string, unknown>) {
|
|
36
|
-
if (PRIORITY[lvl as LogLevel] < PRIORITY[this.level]) return;
|
|
37
|
-
const f = this.fmt(lvl, msg, meta);
|
|
33
|
+
const f = p.join(' ');
|
|
38
34
|
if (isBrowser) (console as any)[lvl]?.(f);
|
|
39
35
|
else (err ? process.stderr : process.stdout).write(f + '\n');
|
|
40
36
|
}
|
package/src/protocol.d.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stelar-time-real Binary Protocol
|
|
3
|
-
* Frame: [4B totalLen BE][1B type][2B eventLen BE][event][payload]
|
|
4
|
-
*/
|
|
1
|
+
/** @stelar-time-real Binary Protocol — Frame: [4B totalLen BE][1B type][2B eventLen BE][event][payload] */
|
|
5
2
|
export declare const FRAME_JSON = 1, FRAME_BINARY = 2, FRAME_PING = 3, FRAME_PONG = 4, FRAME_ACK_REQ = 5, FRAME_ACK_RES = 6, FRAME_CONNECT = 7, FRAME_DISCONNECT = 8, FRAME_JOIN = 9, FRAME_LEAVE = 10, FRAME_ERROR = 11;
|
|
6
3
|
export declare const MAX_EVENT_LENGTH = 256;
|
|
7
4
|
export declare const DEFAULT_MAX_FRAME_SIZE: number;
|
|
@@ -27,11 +24,14 @@ export declare const encodeDisconnectFrame: () => Buffer<ArrayBufferLike>;
|
|
|
27
24
|
export declare const encodeJoinFrame: (room: string, max?: number) => Buffer<ArrayBufferLike>;
|
|
28
25
|
export declare const encodeLeaveFrame: (room: string) => Buffer<ArrayBufferLike>;
|
|
29
26
|
export declare const encodeErrorFrame: (msg: string) => Buffer<ArrayBufferLike>;
|
|
27
|
+
/** O(1) append streaming parser — avoids Buffer.concat O(n²) on many small chunks */
|
|
30
28
|
export declare class FrameParser {
|
|
31
|
-
private
|
|
29
|
+
private chunks;
|
|
30
|
+
private len;
|
|
32
31
|
private max;
|
|
33
32
|
private received;
|
|
34
33
|
constructor(max?: number);
|
|
34
|
+
private _compact;
|
|
35
35
|
feed(data: Buffer): ParsedFrame[];
|
|
36
36
|
reset(): void;
|
|
37
37
|
getBytesReceived(): number;
|
package/src/protocol.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["protocol.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["protocol.ts"],"names":[],"mappings":"AAAA,2GAA2G;AAE3G,eAAO,MAAM,UAAU,IAAO,EAAE,YAAY,IAAO,EAAE,UAAU,IAAO,EACpE,UAAU,IAAO,EAAE,aAAa,IAAO,EAAE,aAAa,IAAO,EAC7D,aAAa,IAAO,EAAE,gBAAgB,IAAO,EAAE,UAAU,IAAO,EAChE,WAAW,KAAO,EAAE,WAAW,KAAO,CAAC;AAEzC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AACvD,eAAO,MAAM,WAAW,IAAI,CAAC;AAE7B,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;CAAE;AAE9E,qBAAa,aAAc,SAAQ,KAAK;IACtC,IAAI,EAAE,MAAM,CAAC;gBACD,OAAO,EAAE,MAAM,EAAE,IAAI,SAAmB;CACrD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAMrD;AAoBD,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BAC6B,CAAC;AAExG,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,EAAE,MAAM,UAAU,GAAG,MAAM,EAAE,MAAM,MAAM,4BACP,CAAC;AAElF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAC9E,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAC9E,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,4BAA8D,CAAC;AAC5G,eAAO,MAAM,qBAAqB,+BAAqC,CAAC;AACxE,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,4BAAoE,CAAC;AAC/H,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,4BAA0F,CAAC;AACxI,eAAO,MAAM,gBAAgB,GAAI,KAAK,MAAM,4BAA2D,CAAC;AAExG,qFAAqF;AACrF,qBAAa,WAAW;IACtB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,QAAQ,CAAK;gBAET,GAAG,SAAyB;IAExC,OAAO,CAAC,QAAQ;IAOhB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE;IAwBjC,KAAK;IACL,gBAAgB;CACjB"}
|