tina4-nodejs 3.13.37 → 3.13.38
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/CLAUDE.md +50 -18
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +30 -25
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +13 -7
- package/packages/core/src/logger.ts +1 -1
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +232 -33
- package/packages/core/src/middleware.ts +129 -39
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +2 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/websocket.ts +247 -33
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +8 -3
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +2 -1
- package/packages/orm/src/migration.ts +2 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -29,6 +29,8 @@ import type { Server } from "node:http";
|
|
|
29
29
|
import type { WebSocketConnection } from "./websocketConnection.js";
|
|
30
30
|
import type { WebSocketRouteHandler } from "./types.js";
|
|
31
31
|
import { Router } from "./router.js";
|
|
32
|
+
import { Log } from "./logger.js";
|
|
33
|
+
import { WsBackplaneManager, type WsEnvelope } from "./websocketBackplane.js";
|
|
32
34
|
|
|
33
35
|
// ── Constants ────────────────────────────────────────────────
|
|
34
36
|
|
|
@@ -46,6 +48,7 @@ export const OP_PONG = 0xa;
|
|
|
46
48
|
export const CLOSE_NORMAL = 1000;
|
|
47
49
|
export const CLOSE_GOING_AWAY = 1001;
|
|
48
50
|
export const CLOSE_PROTOCOL_ERROR = 1002;
|
|
51
|
+
export const CLOSE_POLICY_VIOLATION = 1008;
|
|
49
52
|
|
|
50
53
|
// ── Types ────────────────────────────────────────────────────
|
|
51
54
|
|
|
@@ -57,6 +60,12 @@ export interface WebSocketClient {
|
|
|
57
60
|
closed: boolean;
|
|
58
61
|
/** The URL path this client connected on (e.g. "/chat", "/notifications"). */
|
|
59
62
|
path: string;
|
|
63
|
+
/**
|
|
64
|
+
* Epoch ms of the last inbound frame. Updated on every frame; the idle
|
|
65
|
+
* reaper closes connections silent longer than `TINA4_WS_IDLE_TIMEOUT`
|
|
66
|
+
* (opt-in). Optional so externally-injected/legacy client objects still fit.
|
|
67
|
+
*/
|
|
68
|
+
lastActivity?: number;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
type EventHandler = (...args: unknown[]) => void;
|
|
@@ -72,6 +81,30 @@ export function computeAcceptKey(key: string): string {
|
|
|
72
81
|
.digest("base64");
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Return true if the upgrade request's `Origin` is permitted.
|
|
86
|
+
*
|
|
87
|
+
* Controlled by `TINA4_WS_ALLOWED_ORIGINS` (comma-separated list of exact
|
|
88
|
+
* origins, e.g. `https://app.example.com,https://admin.example.com`).
|
|
89
|
+
*
|
|
90
|
+
* Empty/unset = allow ALL origins (current behaviour, non-breaking). When set,
|
|
91
|
+
* only requests whose `Origin` header exactly matches a listed value are
|
|
92
|
+
* allowed; a missing `Origin` header is rejected once the allow-list is active.
|
|
93
|
+
* Header lookup is case-insensitive on the key (Node lowercases header keys,
|
|
94
|
+
* but the helper also checks an exact `Origin` so it works with raw maps too).
|
|
95
|
+
*/
|
|
96
|
+
export function originAllowed(headers: Record<string, string | string[] | undefined>): boolean {
|
|
97
|
+
const raw = (process.env.TINA4_WS_ALLOWED_ORIGINS ?? "").trim();
|
|
98
|
+
if (!raw) return true; // No allow-list configured — permit everything.
|
|
99
|
+
const allowed = new Set(
|
|
100
|
+
raw.split(",").map((o) => o.trim()).filter((o) => o.length > 0),
|
|
101
|
+
);
|
|
102
|
+
if (allowed.size === 0) return true;
|
|
103
|
+
const rawOrigin = headers["origin"] ?? headers["Origin"];
|
|
104
|
+
const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
|
|
105
|
+
return origin !== undefined && allowed.has(origin);
|
|
106
|
+
}
|
|
107
|
+
|
|
75
108
|
/**
|
|
76
109
|
* Parse HTTP headers from raw upgrade request data.
|
|
77
110
|
*/
|
|
@@ -179,11 +212,26 @@ export class WebSocketServer {
|
|
|
179
212
|
private clientRooms: Map<string, Set<string>> = new Map();
|
|
180
213
|
/** Route-style handlers registered via route(), keyed by path */
|
|
181
214
|
private _routeHandlers: Map<string, (conn: WebSocketConnection) => void | Promise<void>> = new Map();
|
|
215
|
+
/**
|
|
216
|
+
* Backplane (multi-instance scaling). Lazily wired on the first broadcast
|
|
217
|
+
* via {@link ensureBackplane}. Each instance owns a stable id so it can
|
|
218
|
+
* ignore its own echoes coming back over the shared pub/sub channel.
|
|
219
|
+
*/
|
|
220
|
+
private backplane: WsBackplaneManager = new WsBackplaneManager();
|
|
221
|
+
/** Set once ensureBackplane() has fired so we only attempt the wiring once. */
|
|
222
|
+
private backplaneStarted = false;
|
|
223
|
+
/** Idle-connection reaper timer (opt-in via TINA4_WS_IDLE_TIMEOUT). */
|
|
224
|
+
private reaperTimer: ReturnType<typeof setInterval> | null = null;
|
|
182
225
|
|
|
183
226
|
constructor(options?: { port?: number }) {
|
|
184
227
|
this.port = options?.port ?? parseInt(process.env.TINA4_WS_PORT ?? "8080", 10);
|
|
185
228
|
}
|
|
186
229
|
|
|
230
|
+
/** Test/diagnostic helper: this instance's stable backplane id. */
|
|
231
|
+
get instanceId(): string {
|
|
232
|
+
return this.backplane.instanceId;
|
|
233
|
+
}
|
|
234
|
+
|
|
187
235
|
/**
|
|
188
236
|
* Register an event handler.
|
|
189
237
|
*/
|
|
@@ -242,35 +290,34 @@ export class WebSocketServer {
|
|
|
242
290
|
* When `path` is omitted/undefined, all clients receive the message
|
|
243
291
|
* (backward compatible).
|
|
244
292
|
*/
|
|
245
|
-
broadcast(message: string, excludeIds?: string[], path?: string): void {
|
|
246
|
-
|
|
293
|
+
broadcast(message: string | Buffer, excludeIds?: string[], path?: string): void {
|
|
294
|
+
this.ensureBackplane();
|
|
247
295
|
const exclude = new Set(excludeIds ?? []);
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
// Deliver to LOCAL connections first (resilient — a dead client is pruned,
|
|
297
|
+
// never aborts the loop), then fan out to sibling instances.
|
|
298
|
+
for (const [id, client] of Array.from(this.clients)) {
|
|
250
299
|
if (exclude.has(id)) continue;
|
|
251
|
-
if (client.closed) continue;
|
|
252
300
|
if (path !== undefined && client.path !== path) continue;
|
|
253
|
-
|
|
254
|
-
client.socket.write(frame);
|
|
255
|
-
} catch {
|
|
256
|
-
// client disconnected
|
|
257
|
-
}
|
|
301
|
+
this.safeSend(client, message);
|
|
258
302
|
}
|
|
303
|
+
this.backplane.publish(
|
|
304
|
+
path !== undefined ? "path" : "all",
|
|
305
|
+
message,
|
|
306
|
+
{ path: path ?? null, exclude: excludeIds?.[0] ?? null },
|
|
307
|
+
Log,
|
|
308
|
+
);
|
|
259
309
|
}
|
|
260
310
|
|
|
261
311
|
/**
|
|
262
312
|
* Send a message to a specific client by ID.
|
|
313
|
+
*
|
|
314
|
+
* Local-only: a connection lives on exactly one instance, so there is
|
|
315
|
+
* nothing to fan out over the backplane.
|
|
263
316
|
*/
|
|
264
|
-
sendTo(clientId: string, message: string): void {
|
|
317
|
+
sendTo(clientId: string, message: string | Buffer): void {
|
|
265
318
|
const client = this.clients.get(clientId);
|
|
266
|
-
if (!client
|
|
267
|
-
|
|
268
|
-
const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
|
|
269
|
-
try {
|
|
270
|
-
client.socket.write(frame);
|
|
271
|
-
} catch {
|
|
272
|
-
// client disconnected
|
|
273
|
-
}
|
|
319
|
+
if (!client) return;
|
|
320
|
+
this.safeSend(client, message);
|
|
274
321
|
}
|
|
275
322
|
|
|
276
323
|
/**
|
|
@@ -288,6 +335,7 @@ export class WebSocketServer {
|
|
|
288
335
|
});
|
|
289
336
|
|
|
290
337
|
this.server.listen(this.port, () => {
|
|
338
|
+
this.startIdleReaper();
|
|
291
339
|
resolve();
|
|
292
340
|
});
|
|
293
341
|
|
|
@@ -302,6 +350,11 @@ export class WebSocketServer {
|
|
|
302
350
|
* Stop the server and disconnect all clients.
|
|
303
351
|
*/
|
|
304
352
|
stop(): void {
|
|
353
|
+
// Cancel the idle reaper if it was started.
|
|
354
|
+
if (this.reaperTimer !== null) {
|
|
355
|
+
clearInterval(this.reaperTimer);
|
|
356
|
+
this.reaperTimer = null;
|
|
357
|
+
}
|
|
305
358
|
// Close all client connections
|
|
306
359
|
for (const [id, client] of this.clients) {
|
|
307
360
|
if (!client.closed) {
|
|
@@ -395,24 +448,26 @@ export class WebSocketServer {
|
|
|
395
448
|
|
|
396
449
|
/**
|
|
397
450
|
* Broadcast a message to all clients in a room.
|
|
451
|
+
*
|
|
452
|
+
* Resilient to dead clients (a failed send prunes the client, never aborts
|
|
453
|
+
* the loop) and fans out to sibling instances over the backplane when
|
|
454
|
+
* configured — a room can span instances, so each one delivers to its own
|
|
455
|
+
* members.
|
|
398
456
|
*/
|
|
399
|
-
broadcastToRoom(roomName: string, message: string, excludeIds?: string[]): void {
|
|
457
|
+
broadcastToRoom(roomName: string, message: string | Buffer, excludeIds?: string[]): void {
|
|
458
|
+
this.ensureBackplane();
|
|
400
459
|
const members = this.rooms.get(roomName);
|
|
401
|
-
if (!members) return;
|
|
402
|
-
|
|
403
|
-
const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
|
|
404
460
|
const exclude = new Set(excludeIds ?? []);
|
|
405
461
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
} catch {
|
|
413
|
-
// client disconnected
|
|
462
|
+
if (members) {
|
|
463
|
+
for (const clientId of Array.from(members)) {
|
|
464
|
+
if (exclude.has(clientId)) continue;
|
|
465
|
+
const client = this.clients.get(clientId);
|
|
466
|
+
if (!client) continue;
|
|
467
|
+
this.safeSend(client, message);
|
|
414
468
|
}
|
|
415
469
|
}
|
|
470
|
+
this.backplane.publish("room", message, { room: roomName, exclude: excludeIds?.[0] ?? null }, Log);
|
|
416
471
|
}
|
|
417
472
|
|
|
418
473
|
// ── Private ────────────────────────────────────────────────
|
|
@@ -424,6 +479,155 @@ export class WebSocketServer {
|
|
|
424
479
|
}
|
|
425
480
|
}
|
|
426
481
|
|
|
482
|
+
// ── Backplane (multi-instance scaling) ─────────────────────
|
|
483
|
+
//
|
|
484
|
+
// When TINA4_WS_BACKPLANE is configured, every broadcast is ALSO published
|
|
485
|
+
// to a shared pub/sub channel so sibling server instances can relay it to
|
|
486
|
+
// their own local connections. Node is single-threaded async, so the
|
|
487
|
+
// subscribe callback relays directly on the event loop (no thread bridge) —
|
|
488
|
+
// it still applies the origin guard (drop our own echo) and never
|
|
489
|
+
// re-publishes (which would loop the cluster). The wiring is best-effort: a
|
|
490
|
+
// backplane failure logs and degrades to local-only, never crashing a
|
|
491
|
+
// broadcast.
|
|
492
|
+
|
|
493
|
+
/** Lazily wire the backplane on the first broadcast. Idempotent. */
|
|
494
|
+
private ensureBackplane(): void {
|
|
495
|
+
if (this.backplaneStarted) return;
|
|
496
|
+
this.backplaneStarted = true;
|
|
497
|
+
// Fire-and-forget: ensure() is async (connecting to the bus), but the
|
|
498
|
+
// local delivery that just happened must not wait on it. Any failure is
|
|
499
|
+
// logged inside ensure() and degrades to local-only.
|
|
500
|
+
void this.backplane.ensure((env) => this.relayLocal(env), Log);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Deliver a remote-originated envelope to LOCAL connections only. NEVER
|
|
505
|
+
* re-publishes (that would loop the message around the cluster). Dispatches
|
|
506
|
+
* by `kind`: room / path / all.
|
|
507
|
+
*/
|
|
508
|
+
private relayLocal(env: WsEnvelope): void {
|
|
509
|
+
const message = WsBackplaneManager.decodeMessage(env);
|
|
510
|
+
if (message === null) return;
|
|
511
|
+
const exclude = env.exclude ?? null;
|
|
512
|
+
|
|
513
|
+
let targets: WebSocketClient[];
|
|
514
|
+
if (env.kind === "room") {
|
|
515
|
+
const members = env.room ? this.rooms.get(env.room) : undefined;
|
|
516
|
+
targets = members
|
|
517
|
+
? Array.from(members).map((id) => this.clients.get(id)).filter((c): c is WebSocketClient => !!c)
|
|
518
|
+
: [];
|
|
519
|
+
} else if (env.kind === "path") {
|
|
520
|
+
targets = env.path
|
|
521
|
+
? Array.from(this.clients.values()).filter((c) => c.path === env.path)
|
|
522
|
+
: [];
|
|
523
|
+
} else {
|
|
524
|
+
// "all" (and anything unknown) → every local connection
|
|
525
|
+
targets = Array.from(this.clients.values());
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const client of targets) {
|
|
529
|
+
if (exclude && client.id === exclude) continue;
|
|
530
|
+
this.safeSend(client, message);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Send to ONE client without letting a single dead/slow client abort a
|
|
536
|
+
* broadcast loop. A write failure (or an already-closed client) is logged
|
|
537
|
+
* and the client is pruned. Returns true if the frame was handed to the
|
|
538
|
+
* socket.
|
|
539
|
+
*
|
|
540
|
+
* Slow-client backpressure: `socket.write()` returns false when the kernel
|
|
541
|
+
* send buffer is full. We don't unboundedly buffer — a client whose backlog
|
|
542
|
+
* has blown past TINA4_WS_MAX_BACKLOG bytes is hopelessly behind, so we drop
|
|
543
|
+
* and close it rather than let it grow the process heap without bound.
|
|
544
|
+
*/
|
|
545
|
+
private safeSend(client: WebSocketClient, message: string | Buffer): boolean {
|
|
546
|
+
if (client.closed) {
|
|
547
|
+
this.pruneClient(client);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
const payload = typeof message === "string" ? Buffer.from(message, "utf-8") : message;
|
|
551
|
+
const opcode = typeof message === "string" ? OP_TEXT : OP_BINARY;
|
|
552
|
+
const frame = buildFrame(opcode, payload);
|
|
553
|
+
try {
|
|
554
|
+
// A saturated socket buffer means the client can't keep up. write()
|
|
555
|
+
// still queues the frame and returns false; if the queued backlog is
|
|
556
|
+
// hopeless, close the client rather than buffer without bound.
|
|
557
|
+
client.socket.write(frame);
|
|
558
|
+
const backlog = client.socket.writableLength ?? 0;
|
|
559
|
+
const maxBacklog = parseInt(process.env.TINA4_WS_MAX_BACKLOG ?? "1048576", 10);
|
|
560
|
+
if (maxBacklog > 0 && backlog > maxBacklog) {
|
|
561
|
+
Log.warn(`WebSocket client ${client.id} backlog ${backlog}B exceeds limit, dropping slow client`);
|
|
562
|
+
this.pruneClient(client);
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
} catch (err) {
|
|
567
|
+
Log.warn(`WebSocket send to ${client.id} failed, pruning: ${(err as Error).message}`);
|
|
568
|
+
this.pruneClient(client);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Remove a client from the manager, rooms, and close its socket. */
|
|
574
|
+
private pruneClient(client: WebSocketClient): void {
|
|
575
|
+
client.closed = true;
|
|
576
|
+
this.clients.delete(client.id);
|
|
577
|
+
this.removeClientFromAllRooms(client.id);
|
|
578
|
+
try {
|
|
579
|
+
client.socket.destroy();
|
|
580
|
+
} catch {
|
|
581
|
+
/* already gone */
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Idle reaper (opt-in via TINA4_WS_IDLE_TIMEOUT) ──────────
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Close connections whose last inbound frame is older than `timeoutSeconds`.
|
|
589
|
+
* Returns the number reaped. `timeoutSeconds <= 0` is a no-op (the reaper is
|
|
590
|
+
* opt-in via TINA4_WS_IDLE_TIMEOUT).
|
|
591
|
+
*/
|
|
592
|
+
reapIdle(timeoutSeconds: number): number {
|
|
593
|
+
if (timeoutSeconds <= 0) return 0;
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
const cutoff = timeoutSeconds * 1000;
|
|
596
|
+
const stale = Array.from(this.clients.values()).filter(
|
|
597
|
+
(c) => now - (c.lastActivity ?? c.connectedAt ?? now) > cutoff,
|
|
598
|
+
);
|
|
599
|
+
for (const client of stale) {
|
|
600
|
+
this.close(client.id, CLOSE_GOING_AWAY, "idle timeout");
|
|
601
|
+
}
|
|
602
|
+
if (stale.length > 0) {
|
|
603
|
+
Log.info(`WebSocket idle reaper closed ${stale.length} connection(s)`);
|
|
604
|
+
}
|
|
605
|
+
return stale.length;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Spin up the idle-connection reaper when TINA4_WS_IDLE_TIMEOUT is a
|
|
610
|
+
* positive number of seconds. Opt-in and non-breaking — unset/0 means no
|
|
611
|
+
* timer is created at all (current behaviour). Called from start().
|
|
612
|
+
*/
|
|
613
|
+
private startIdleReaper(): void {
|
|
614
|
+
const raw = process.env.TINA4_WS_IDLE_TIMEOUT ?? "0";
|
|
615
|
+
const timeout = parseFloat(raw);
|
|
616
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || this.reaperTimer !== null) return;
|
|
617
|
+
// Sweep at a fraction of the timeout (min 1s) so an idle conn is closed
|
|
618
|
+
// within roughly one timeout window of going silent.
|
|
619
|
+
const intervalMs = Math.max(1000, (timeout / 2) * 1000);
|
|
620
|
+
this.reaperTimer = setInterval(() => {
|
|
621
|
+
try {
|
|
622
|
+
this.reapIdle(timeout);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
Log.error(`WebSocket idle reaper sweep failed: ${(err as Error).message}`);
|
|
625
|
+
}
|
|
626
|
+
}, intervalMs);
|
|
627
|
+
// Don't keep the event loop alive just for the reaper.
|
|
628
|
+
this.reaperTimer.unref?.();
|
|
629
|
+
}
|
|
630
|
+
|
|
427
631
|
private handleUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
|
|
428
632
|
const wsKey = req.headers["sec-websocket-key"];
|
|
429
633
|
if (!wsKey) {
|
|
@@ -439,8 +643,16 @@ export class WebSocketServer {
|
|
|
439
643
|
return;
|
|
440
644
|
}
|
|
441
645
|
|
|
646
|
+
// Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow
|
|
647
|
+
// all, so this never breaks an existing deployment.
|
|
648
|
+
if (!originAllowed(req.headers)) {
|
|
649
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
650
|
+
socket.destroy();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
442
654
|
// Compute accept key and send upgrade response
|
|
443
|
-
const acceptKey = computeAcceptKey(wsKey);
|
|
655
|
+
const acceptKey = computeAcceptKey(Array.isArray(wsKey) ? wsKey[0] : wsKey);
|
|
444
656
|
const response = [
|
|
445
657
|
"HTTP/1.1 101 Switching Protocols",
|
|
446
658
|
"Upgrade: websocket",
|
|
@@ -461,13 +673,14 @@ export class WebSocketServer {
|
|
|
461
673
|
connectedAt: Date.now(),
|
|
462
674
|
closed: false,
|
|
463
675
|
path: req.url ?? "/",
|
|
676
|
+
lastActivity: Date.now(),
|
|
464
677
|
};
|
|
465
678
|
|
|
466
679
|
this.clients.set(clientId, client);
|
|
467
680
|
this.emit("open", client);
|
|
468
681
|
|
|
469
682
|
// Handle incoming data
|
|
470
|
-
let buffer = Buffer.alloc(0);
|
|
683
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
471
684
|
if (head.length > 0) {
|
|
472
685
|
buffer = Buffer.concat([buffer, head]);
|
|
473
686
|
}
|
|
@@ -506,6 +719,7 @@ export class WebSocketServer {
|
|
|
506
719
|
if (!frame) break;
|
|
507
720
|
|
|
508
721
|
remaining = remaining.subarray(frame.bytesConsumed);
|
|
722
|
+
client.lastActivity = Date.now(); // mark activity for the idle reaper
|
|
509
723
|
|
|
510
724
|
switch (frame.opcode) {
|
|
511
725
|
case OP_TEXT:
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* backplane.publish("chat", '{"user":"A","text":"hello"}');
|
|
17
17
|
* }
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Base interface for scaling WebSocket broadcast across instances.
|
|
@@ -51,12 +52,16 @@ export class RedisBackplane implements WebSocketBackplane {
|
|
|
51
52
|
private ready: Promise<void>;
|
|
52
53
|
|
|
53
54
|
constructor(url?: string) {
|
|
54
|
-
|
|
55
|
+
const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
|
|
56
|
+
this.url = resolvedUrl;
|
|
55
57
|
|
|
56
58
|
this.ready = (async () => {
|
|
57
59
|
let redis: any;
|
|
58
60
|
try {
|
|
59
|
-
|
|
61
|
+
// Optional peer dependency — resolved via a string specifier so the
|
|
62
|
+
// module isn't required at type-check time when it isn't installed.
|
|
63
|
+
const redisModule: string = "redis";
|
|
64
|
+
redis = await import(redisModule);
|
|
60
65
|
} catch {
|
|
61
66
|
throw new Error(
|
|
62
67
|
"The 'redis' package is required for RedisBackplane. " +
|
|
@@ -64,14 +69,14 @@ export class RedisBackplane implements WebSocketBackplane {
|
|
|
64
69
|
);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
this.publisher = redis.createClient({ url:
|
|
72
|
+
this.publisher = redis.createClient({ url: resolvedUrl });
|
|
68
73
|
this.subscriber = this.publisher.duplicate();
|
|
69
74
|
|
|
70
75
|
await Promise.all([
|
|
71
76
|
this.publisher.connect(),
|
|
72
77
|
this.subscriber.connect(),
|
|
73
78
|
]);
|
|
74
|
-
console.log(`[Tina4] RedisBackplane connected to ${
|
|
79
|
+
console.log(`[Tina4] RedisBackplane connected to ${resolvedUrl}`);
|
|
75
80
|
})();
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -115,12 +120,16 @@ export class NATSBackplane implements WebSocketBackplane {
|
|
|
115
120
|
private ready: Promise<void>;
|
|
116
121
|
|
|
117
122
|
constructor(url?: string) {
|
|
118
|
-
|
|
123
|
+
const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
|
|
124
|
+
this.url = resolvedUrl;
|
|
119
125
|
|
|
120
126
|
this.ready = (async () => {
|
|
121
127
|
let nats: any;
|
|
122
128
|
try {
|
|
123
|
-
|
|
129
|
+
// Optional peer dependency — resolved via a string specifier so the
|
|
130
|
+
// module isn't required at type-check time when it isn't installed.
|
|
131
|
+
const natsModule: string = "nats";
|
|
132
|
+
nats = await import(natsModule);
|
|
124
133
|
} catch {
|
|
125
134
|
throw new Error(
|
|
126
135
|
"The 'nats' package is required for NATSBackplane. " +
|
|
@@ -128,21 +137,23 @@ export class NATSBackplane implements WebSocketBackplane {
|
|
|
128
137
|
);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
this.nc = await nats.connect({ servers:
|
|
132
|
-
console.log(`[Tina4] NATSBackplane connected to ${
|
|
140
|
+
this.nc = await nats.connect({ servers: resolvedUrl });
|
|
141
|
+
console.log(`[Tina4] NATSBackplane connected to ${resolvedUrl}`);
|
|
133
142
|
})();
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
async publish(channel: string, message: string): Promise<void> {
|
|
137
146
|
await this.ready;
|
|
138
|
-
const
|
|
147
|
+
const natsModule: string = "nats";
|
|
148
|
+
const { StringCodec } = await import(natsModule);
|
|
139
149
|
const sc = StringCodec();
|
|
140
150
|
this.nc.publish(channel, sc.encode(message));
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
|
144
154
|
await this.ready;
|
|
145
|
-
const
|
|
155
|
+
const natsModule: string = "nats";
|
|
156
|
+
const { StringCodec } = await import(natsModule);
|
|
146
157
|
const sc = StringCodec();
|
|
147
158
|
const sub = this.nc.subscribe(channel);
|
|
148
159
|
this.subs.set(channel, sub);
|
|
@@ -197,3 +208,192 @@ export function createBackplane(url?: string): WebSocketBackplane | null {
|
|
|
197
208
|
throw new Error(`Unknown TINA4_WS_BACKPLANE value: '${backend}'`);
|
|
198
209
|
}
|
|
199
210
|
}
|
|
211
|
+
|
|
212
|
+
// ── Multi-instance scaling (backplane manager) ───────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The shared pub/sub channel name. Identical across all four Tina4 frameworks
|
|
216
|
+
* (cross-framework constant parity) so a Python, PHP, Ruby and Node instance
|
|
217
|
+
* can all relay each other's broadcasts over the same bus.
|
|
218
|
+
*/
|
|
219
|
+
export const WS_BACKPLANE_CHANNEL = "tina4:ws";
|
|
220
|
+
|
|
221
|
+
/** What `kind` a broadcast envelope carries — mirrors the master design. */
|
|
222
|
+
export type WsEnvelopeKind = "all" | "path" | "room";
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The JSON envelope published to the backplane channel. The wire shape is
|
|
226
|
+
* identical across all four frameworks. JSON can't carry bytes, so a string
|
|
227
|
+
* message rides under `text` and a binary message rides under `b64`
|
|
228
|
+
* (base64 of the bytes).
|
|
229
|
+
*/
|
|
230
|
+
export interface WsEnvelope {
|
|
231
|
+
/** Stable per-process instance id of the publisher (for the origin guard). */
|
|
232
|
+
src: string;
|
|
233
|
+
/** Delivery kind: every local conn / a path / a room. */
|
|
234
|
+
kind: WsEnvelopeKind;
|
|
235
|
+
/** Optional connection id to skip on delivery. */
|
|
236
|
+
exclude?: string | null;
|
|
237
|
+
/** Room name (only when kind === "room"). */
|
|
238
|
+
room?: string | null;
|
|
239
|
+
/** Path (only when kind === "path"). */
|
|
240
|
+
path?: string | null;
|
|
241
|
+
/** Text payload (str messages). */
|
|
242
|
+
text?: string;
|
|
243
|
+
/** Base64 payload (binary messages). */
|
|
244
|
+
b64?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Wires a {@link WebSocketBackplane} into a local connection manager so a
|
|
249
|
+
* broadcast on one server instance reaches the local connections of every
|
|
250
|
+
* sibling instance.
|
|
251
|
+
*
|
|
252
|
+
* Node is single-threaded async, so — unlike Python's bg-thread →
|
|
253
|
+
* `run_coroutine_threadsafe` bridge — the subscribe callback can relay
|
|
254
|
+
* directly on the event loop. We still apply the two invariants that keep a
|
|
255
|
+
* cluster correct:
|
|
256
|
+
*
|
|
257
|
+
* 1. **Origin guard** — drop any envelope whose `src` is *this* instance's
|
|
258
|
+
* id. We already delivered it locally on broadcast; relaying it again
|
|
259
|
+
* would double-send.
|
|
260
|
+
* 2. **No re-publish** — the relay path only delivers to LOCAL connections;
|
|
261
|
+
* it never publishes, so a message can't loop around the cluster.
|
|
262
|
+
*
|
|
263
|
+
* The manager is generic over the local-delivery callback (`relay`) so it can
|
|
264
|
+
* sit beside the WebSocketServer without importing it (no module cycle).
|
|
265
|
+
*/
|
|
266
|
+
export class WsBackplaneManager {
|
|
267
|
+
/** Stable per-process id so we can ignore our own echoes. */
|
|
268
|
+
readonly instanceId: string;
|
|
269
|
+
readonly channel: string;
|
|
270
|
+
private backplane: WebSocketBackplane | null = null;
|
|
271
|
+
private started = false;
|
|
272
|
+
/** Local-delivery callback, installed by the owner (WebSocketServer). */
|
|
273
|
+
private relay: ((env: WsEnvelope) => void) | null = null;
|
|
274
|
+
|
|
275
|
+
constructor(channel: string = WS_BACKPLANE_CHANNEL) {
|
|
276
|
+
this.instanceId = randomInstanceId();
|
|
277
|
+
this.channel = channel;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** True once a backplane is actually attached (a network bus is configured). */
|
|
281
|
+
get active(): boolean {
|
|
282
|
+
return this.backplane !== null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Lazily wire the configured backplane and subscribe. Idempotent and
|
|
287
|
+
* best-effort — a failure here logs and leaves the manager in local-only
|
|
288
|
+
* mode; it must NEVER crash a broadcast. The `relay` callback is invoked
|
|
289
|
+
* (on this same event loop) for every *remote* envelope that survives the
|
|
290
|
+
* origin guard.
|
|
291
|
+
*/
|
|
292
|
+
async ensure(relay: (env: WsEnvelope) => void, log?: WsBackplaneLogger): Promise<void> {
|
|
293
|
+
if (this.started) return;
|
|
294
|
+
// Set immediately so we only ever attempt the wiring once, even if it
|
|
295
|
+
// fails (no retry storm on every broadcast).
|
|
296
|
+
this.started = true;
|
|
297
|
+
this.relay = relay;
|
|
298
|
+
try {
|
|
299
|
+
const backplane = createBackplane();
|
|
300
|
+
if (backplane === null) return; // No backplane configured — stay local-only.
|
|
301
|
+
this.backplane = backplane;
|
|
302
|
+
await backplane.subscribe(this.channel, (raw) => this.onMessage(raw));
|
|
303
|
+
log?.info(
|
|
304
|
+
`WebSocket backplane active (instance ${this.instanceId}, channel '${this.channel}')`,
|
|
305
|
+
);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this.backplane = null;
|
|
308
|
+
log?.error(
|
|
309
|
+
`WebSocket backplane wiring failed, continuing local-only: ${(err as Error).message}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Handle a raw envelope arriving on the channel. Applies the origin guard
|
|
316
|
+
* then hands a *remote* envelope to the local relay. Never throws (a
|
|
317
|
+
* malformed envelope is dropped silently).
|
|
318
|
+
*/
|
|
319
|
+
onMessage(raw: string): void {
|
|
320
|
+
let env: unknown;
|
|
321
|
+
try {
|
|
322
|
+
env = JSON.parse(raw);
|
|
323
|
+
} catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (typeof env !== "object" || env === null) return;
|
|
327
|
+
const envelope = env as WsEnvelope;
|
|
328
|
+
// Origin guard: ignore our own broadcasts echoed back over the channel.
|
|
329
|
+
// We already delivered them locally; relaying again would double-send.
|
|
330
|
+
if (envelope.src === this.instanceId) return;
|
|
331
|
+
this.relay?.(envelope);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Publish a broadcast to the shared channel for sibling instances. No-op
|
|
336
|
+
* when no backplane is configured. Best-effort — a publish failure logs and
|
|
337
|
+
* is swallowed so the local broadcast that already happened is never undone
|
|
338
|
+
* by a flaky message bus.
|
|
339
|
+
*/
|
|
340
|
+
publish(
|
|
341
|
+
kind: WsEnvelopeKind,
|
|
342
|
+
message: string | Buffer,
|
|
343
|
+
opts: { room?: string | null; path?: string | null; exclude?: string | null } = {},
|
|
344
|
+
log?: WsBackplaneLogger,
|
|
345
|
+
): void {
|
|
346
|
+
if (!this.backplane) return;
|
|
347
|
+
const envelope = buildEnvelope(this.instanceId, kind, message, opts);
|
|
348
|
+
// publish() is async; we fire-and-forget but still catch a rejection so a
|
|
349
|
+
// dead bus can't produce an unhandled rejection that crashes the worker.
|
|
350
|
+
Promise.resolve(this.backplane.publish(this.channel, JSON.stringify(envelope))).catch(
|
|
351
|
+
(err) => log?.warn(`WebSocket backplane publish failed: ${(err as Error).message}`),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reconstruct the original str/Buffer message from an envelope. JSON can't
|
|
357
|
+
* carry bytes, so `text` → string and `b64` → Buffer.
|
|
358
|
+
*/
|
|
359
|
+
static decodeMessage(env: WsEnvelope): string | Buffer | null {
|
|
360
|
+
if (typeof env.text === "string") return env.text;
|
|
361
|
+
if (typeof env.b64 === "string") return Buffer.from(env.b64, "base64");
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Minimal logger shape so the manager doesn't import the logger module. */
|
|
367
|
+
export interface WsBackplaneLogger {
|
|
368
|
+
info(message: string): void;
|
|
369
|
+
warn(message: string): void;
|
|
370
|
+
error(message: string): void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Build the cross-framework envelope. Exported for tests. */
|
|
374
|
+
export function buildEnvelope(
|
|
375
|
+
src: string,
|
|
376
|
+
kind: WsEnvelopeKind,
|
|
377
|
+
message: string | Buffer,
|
|
378
|
+
opts: { room?: string | null; path?: string | null; exclude?: string | null } = {},
|
|
379
|
+
): WsEnvelope {
|
|
380
|
+
const envelope: WsEnvelope = {
|
|
381
|
+
src,
|
|
382
|
+
kind,
|
|
383
|
+
exclude: opts.exclude ?? null,
|
|
384
|
+
room: opts.room ?? null,
|
|
385
|
+
path: opts.path ?? null,
|
|
386
|
+
};
|
|
387
|
+
// JSON can't carry bytes — encode a string as text, bytes as base64.
|
|
388
|
+
if (Buffer.isBuffer(message)) {
|
|
389
|
+
envelope.b64 = message.toString("base64");
|
|
390
|
+
} else {
|
|
391
|
+
envelope.text = message;
|
|
392
|
+
}
|
|
393
|
+
return envelope;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A stable, short per-process id (16 hex chars), matching the master. */
|
|
397
|
+
function randomInstanceId(): string {
|
|
398
|
+
return randomUUID().replace(/-/g, "").slice(0, 16);
|
|
399
|
+
}
|