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.
Files changed (48) hide show
  1. package/CLAUDE.md +50 -18
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/src/auth.ts +112 -2
  8. package/packages/core/src/cache.ts +2 -2
  9. package/packages/core/src/devAdmin.ts +30 -25
  10. package/packages/core/src/devMailbox.ts +4 -0
  11. package/packages/core/src/dotenv.ts +13 -4
  12. package/packages/core/src/events.ts +86 -4
  13. package/packages/core/src/graphql.ts +182 -128
  14. package/packages/core/src/htmlElement.ts +62 -3
  15. package/packages/core/src/index.ts +13 -7
  16. package/packages/core/src/logger.ts +1 -1
  17. package/packages/core/src/mcp.test.ts +1 -1
  18. package/packages/core/src/messenger.ts +111 -11
  19. package/packages/core/src/metrics.ts +232 -33
  20. package/packages/core/src/middleware.ts +129 -39
  21. package/packages/core/src/plan.ts +1 -1
  22. package/packages/core/src/queue.ts +1 -1
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  25. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  26. package/packages/core/src/rateLimiter.ts +1 -1
  27. package/packages/core/src/response.ts +90 -6
  28. package/packages/core/src/router.ts +2 -2
  29. package/packages/core/src/server.ts +26 -4
  30. package/packages/core/src/session.ts +130 -18
  31. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  32. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  33. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  34. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  35. package/packages/core/src/testClient.ts +1 -1
  36. package/packages/core/src/websocket.ts +247 -33
  37. package/packages/core/src/websocketBackplane.ts +210 -10
  38. package/packages/core/src/wsdl.ts +55 -21
  39. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  40. package/packages/orm/src/adapters/postgres.ts +26 -4
  41. package/packages/orm/src/adapters/sqlite.ts +112 -13
  42. package/packages/orm/src/baseModel.ts +8 -3
  43. package/packages/orm/src/cachedDatabase.ts +15 -6
  44. package/packages/orm/src/database.ts +257 -55
  45. package/packages/orm/src/index.ts +2 -1
  46. package/packages/orm/src/migration.ts +2 -2
  47. package/packages/orm/src/seeder.ts +443 -65
  48. 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
- const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
293
+ broadcast(message: string | Buffer, excludeIds?: string[], path?: string): void {
294
+ this.ensureBackplane();
247
295
  const exclude = new Set(excludeIds ?? []);
248
-
249
- for (const [id, client] of this.clients) {
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
- try {
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 || client.closed) return;
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
- for (const clientId of members) {
407
- if (exclude.has(clientId)) continue;
408
- const client = this.clients.get(clientId);
409
- if (!client || client.closed) continue;
410
- try {
411
- client.socket.write(frame);
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
- this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
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
- redis = await import("redis");
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: this.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 ${this.url}`);
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
- this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
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
- nats = await import("nats");
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: this.url });
132
- console.log(`[Tina4] NATSBackplane connected to ${this.url}`);
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 { StringCodec } = await import("nats");
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 { StringCodec } = await import("nats");
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
+ }