tina4-nodejs 3.13.37 → 3.13.39

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 (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -29,6 +29,9 @@ 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 { validToken } from "./auth.js";
34
+ import { WsBackplaneManager, type WsEnvelope } from "./websocketBackplane.js";
32
35
 
33
36
  // ── Constants ────────────────────────────────────────────────
34
37
 
@@ -46,6 +49,7 @@ export const OP_PONG = 0xa;
46
49
  export const CLOSE_NORMAL = 1000;
47
50
  export const CLOSE_GOING_AWAY = 1001;
48
51
  export const CLOSE_PROTOCOL_ERROR = 1002;
52
+ export const CLOSE_POLICY_VIOLATION = 1008;
49
53
 
50
54
  // ── Types ────────────────────────────────────────────────────
51
55
 
@@ -57,6 +61,18 @@ export interface WebSocketClient {
57
61
  closed: boolean;
58
62
  /** The URL path this client connected on (e.g. "/chat", "/notifications"). */
59
63
  path: string;
64
+ /**
65
+ * Epoch ms of the last inbound frame. Updated on every frame; the idle
66
+ * reaper closes connections silent longer than `TINA4_WS_IDLE_TIMEOUT`
67
+ * (opt-in). Optional so externally-injected/legacy client objects still fit.
68
+ */
69
+ lastActivity?: number;
70
+ /**
71
+ * Verified JWT payload when this client connected on a secured WS route, or
72
+ * `null` on a public route. Mirrors Python's `connection.auth`. Optional so
73
+ * externally-injected/legacy client objects still fit.
74
+ */
75
+ auth?: Record<string, unknown> | null;
60
76
  }
61
77
 
62
78
  type EventHandler = (...args: unknown[]) => void;
@@ -72,6 +88,121 @@ export function computeAcceptKey(key: string): string {
72
88
  .digest("base64");
73
89
  }
74
90
 
91
+ /**
92
+ * Return true if the upgrade request's `Origin` is permitted.
93
+ *
94
+ * Controlled by `TINA4_WS_ALLOWED_ORIGINS` (comma-separated list of exact
95
+ * origins, e.g. `https://app.example.com,https://admin.example.com`).
96
+ *
97
+ * Empty/unset = allow ALL origins (current behaviour, non-breaking). When set,
98
+ * only requests whose `Origin` header exactly matches a listed value are
99
+ * allowed; a missing `Origin` header is rejected once the allow-list is active.
100
+ * Header lookup is case-insensitive on the key (Node lowercases header keys,
101
+ * but the helper also checks an exact `Origin` so it works with raw maps too).
102
+ */
103
+ export function originAllowed(headers: Record<string, string | string[] | undefined>): boolean {
104
+ const raw = (process.env.TINA4_WS_ALLOWED_ORIGINS ?? "").trim();
105
+ if (!raw) return true; // No allow-list configured — permit everything.
106
+ const allowed = new Set(
107
+ raw.split(",").map((o) => o.trim()).filter((o) => o.length > 0),
108
+ );
109
+ if (allowed.size === 0) return true;
110
+ const rawOrigin = headers["origin"] ?? headers["Origin"];
111
+ const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
112
+ return origin !== undefined && allowed.has(origin);
113
+ }
114
+
115
+ /** Read a (possibly array-valued) header case-insensitively, return a single string. */
116
+ function headerValue(
117
+ headers: Record<string, string | string[] | undefined>,
118
+ name: string,
119
+ ): string {
120
+ const lower = name.toLowerCase();
121
+ // Node lowercases header keys, but a raw map (or a test) may carry any case —
122
+ // match case-insensitively on the key, like Python's helper.
123
+ let raw = headers[lower] ?? headers[name];
124
+ if (raw === undefined) {
125
+ for (const [k, v] of Object.entries(headers)) {
126
+ if (k.toLowerCase() === lower) {
127
+ raw = v;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (raw === undefined) return "";
133
+ return Array.isArray(raw) ? (raw[0] ?? "") : raw;
134
+ }
135
+
136
+ /**
137
+ * Extract a bearer token from a WebSocket upgrade handshake.
138
+ *
139
+ * Order (mirrors Python's `ws_token`): the `Authorization: Bearer` header (set
140
+ * by server/CLI/mobile clients), then the `Sec-WebSocket-Protocol` subprotocol
141
+ * in the form `"bearer, <token>"` (the only way a *browser* can pass a token,
142
+ * since `new WebSocket()` cannot set headers), then a `?token=` query param.
143
+ * Returns the token string or `null`.
144
+ *
145
+ * @param headers - Upgrade-request headers (case-insensitive lookup).
146
+ * @param queryString - Raw query string (without the leading `?`), e.g. `token=abc`.
147
+ * @param subprotocol - The offered `Sec-WebSocket-Protocol` value, if already parsed out.
148
+ */
149
+ export function wsToken(
150
+ headers: Record<string, string | string[] | undefined>,
151
+ queryString = "",
152
+ subprotocol = "",
153
+ ): string | null {
154
+ const auth = headerValue(headers, "authorization");
155
+ if (auth.slice(0, 7).toLowerCase() === "bearer ") {
156
+ return auth.slice(7).trim() || null;
157
+ }
158
+ const proto = subprotocol || headerValue(headers, "sec-websocket-protocol");
159
+ const parts = proto.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
160
+ if (parts.length >= 2 && parts[0].toLowerCase() === "bearer") {
161
+ return parts[1] || null;
162
+ }
163
+ if (queryString) {
164
+ // WHATWG URLSearchParams handles decoding; a leading "?" is tolerated.
165
+ const tok = new URLSearchParams(queryString.replace(/^\?/, "")).get("token");
166
+ if (tok) return tok;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Per-route WebSocket authentication, checked on the upgrade.
173
+ *
174
+ * A route is secured when `authRequired` is truthy (set by `.secure()` /
175
+ * `{ secured: true }` on the WS route, or a `_secured` flag on the handler).
176
+ * Public routes (the default) always pass. A secured route needs a valid JWT
177
+ * via the Authorization header, the `bearer` subprotocol, or `?token=`.
178
+ *
179
+ * Returns `[payload, ok]` — the verified token payload (or `null`) and whether
180
+ * the upgrade may proceed. Mirrors Python's `ws_authorized`.
181
+ */
182
+ export function wsAuthorized(
183
+ route: { authRequired?: boolean } | null | undefined,
184
+ headers: Record<string, string | string[] | undefined>,
185
+ queryString = "",
186
+ subprotocol = "",
187
+ ): [Record<string, unknown> | null, boolean] {
188
+ if (!route?.authRequired) return [null, true];
189
+ const token = wsToken(headers, queryString, subprotocol);
190
+ if (!token) return [null, false];
191
+ const payload = validToken(token);
192
+ return [payload, payload !== null];
193
+ }
194
+
195
+ /**
196
+ * Whether the client offered the `bearer` subprotocol — if so, the server MUST
197
+ * echo `bearer` back as the accepted `Sec-WebSocket-Protocol` in the handshake
198
+ * (RFC 6455 §1.3; browsers send the token as the second subprotocol token).
199
+ */
200
+ export function offeredBearerSubprotocol(subprotocol: string): boolean {
201
+ return subprotocol
202
+ .split(",")
203
+ .some((p) => p.trim().toLowerCase() === "bearer");
204
+ }
205
+
75
206
  /**
76
207
  * Parse HTTP headers from raw upgrade request data.
77
208
  */
@@ -179,11 +310,26 @@ export class WebSocketServer {
179
310
  private clientRooms: Map<string, Set<string>> = new Map();
180
311
  /** Route-style handlers registered via route(), keyed by path */
181
312
  private _routeHandlers: Map<string, (conn: WebSocketConnection) => void | Promise<void>> = new Map();
313
+ /**
314
+ * Backplane (multi-instance scaling). Lazily wired on the first broadcast
315
+ * via {@link ensureBackplane}. Each instance owns a stable id so it can
316
+ * ignore its own echoes coming back over the shared pub/sub channel.
317
+ */
318
+ private backplane: WsBackplaneManager = new WsBackplaneManager();
319
+ /** Set once ensureBackplane() has fired so we only attempt the wiring once. */
320
+ private backplaneStarted = false;
321
+ /** Idle-connection reaper timer (opt-in via TINA4_WS_IDLE_TIMEOUT). */
322
+ private reaperTimer: ReturnType<typeof setInterval> | null = null;
182
323
 
183
324
  constructor(options?: { port?: number }) {
184
325
  this.port = options?.port ?? parseInt(process.env.TINA4_WS_PORT ?? "8080", 10);
185
326
  }
186
327
 
328
+ /** Test/diagnostic helper: this instance's stable backplane id. */
329
+ get instanceId(): string {
330
+ return this.backplane.instanceId;
331
+ }
332
+
187
333
  /**
188
334
  * Register an event handler.
189
335
  */
@@ -204,7 +350,11 @@ export class WebSocketServer {
204
350
  * to the Router's `(conn, event, data)` style and registers it via
205
351
  * `Router.websocket()`.
206
352
  */
207
- route(path: string, handler: (conn: WebSocketConnection) => void | Promise<void>): void {
353
+ route(
354
+ path: string,
355
+ handler: (conn: WebSocketConnection) => void | Promise<void>,
356
+ options?: { secured?: boolean },
357
+ ): void {
208
358
  this._routeHandlers.set(path, handler);
209
359
 
210
360
  // Adapt to Router's (conn, event, data) style
@@ -231,7 +381,9 @@ export class WebSocketServer {
231
381
  }
232
382
  };
233
383
 
234
- Router.websocket(path, adapter);
384
+ // Carry the secured flag through so the upgrade enforces auth. Public by
385
+ // default (mirrors GET); pass { secured: true } to require a valid JWT.
386
+ Router.websocket(path, adapter, options);
235
387
  }
236
388
 
237
389
  /**
@@ -242,35 +394,34 @@ export class WebSocketServer {
242
394
  * When `path` is omitted/undefined, all clients receive the message
243
395
  * (backward compatible).
244
396
  */
245
- broadcast(message: string, excludeIds?: string[], path?: string): void {
246
- const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
397
+ broadcast(message: string | Buffer, excludeIds?: string[], path?: string): void {
398
+ this.ensureBackplane();
247
399
  const exclude = new Set(excludeIds ?? []);
248
-
249
- for (const [id, client] of this.clients) {
400
+ // Deliver to LOCAL connections first (resilient — a dead client is pruned,
401
+ // never aborts the loop), then fan out to sibling instances.
402
+ for (const [id, client] of Array.from(this.clients)) {
250
403
  if (exclude.has(id)) continue;
251
- if (client.closed) continue;
252
404
  if (path !== undefined && client.path !== path) continue;
253
- try {
254
- client.socket.write(frame);
255
- } catch {
256
- // client disconnected
257
- }
405
+ this.safeSend(client, message);
258
406
  }
407
+ this.backplane.publish(
408
+ path !== undefined ? "path" : "all",
409
+ message,
410
+ { path: path ?? null, exclude: excludeIds?.[0] ?? null },
411
+ Log,
412
+ );
259
413
  }
260
414
 
261
415
  /**
262
416
  * Send a message to a specific client by ID.
417
+ *
418
+ * Local-only: a connection lives on exactly one instance, so there is
419
+ * nothing to fan out over the backplane.
263
420
  */
264
- sendTo(clientId: string, message: string): void {
421
+ sendTo(clientId: string, message: string | Buffer): void {
265
422
  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
- }
423
+ if (!client) return;
424
+ this.safeSend(client, message);
274
425
  }
275
426
 
276
427
  /**
@@ -288,6 +439,7 @@ export class WebSocketServer {
288
439
  });
289
440
 
290
441
  this.server.listen(this.port, () => {
442
+ this.startIdleReaper();
291
443
  resolve();
292
444
  });
293
445
 
@@ -302,6 +454,11 @@ export class WebSocketServer {
302
454
  * Stop the server and disconnect all clients.
303
455
  */
304
456
  stop(): void {
457
+ // Cancel the idle reaper if it was started.
458
+ if (this.reaperTimer !== null) {
459
+ clearInterval(this.reaperTimer);
460
+ this.reaperTimer = null;
461
+ }
305
462
  // Close all client connections
306
463
  for (const [id, client] of this.clients) {
307
464
  if (!client.closed) {
@@ -395,24 +552,26 @@ export class WebSocketServer {
395
552
 
396
553
  /**
397
554
  * Broadcast a message to all clients in a room.
555
+ *
556
+ * Resilient to dead clients (a failed send prunes the client, never aborts
557
+ * the loop) and fans out to sibling instances over the backplane when
558
+ * configured — a room can span instances, so each one delivers to its own
559
+ * members.
398
560
  */
399
- broadcastToRoom(roomName: string, message: string, excludeIds?: string[]): void {
561
+ broadcastToRoom(roomName: string, message: string | Buffer, excludeIds?: string[]): void {
562
+ this.ensureBackplane();
400
563
  const members = this.rooms.get(roomName);
401
- if (!members) return;
402
-
403
- const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
404
564
  const exclude = new Set(excludeIds ?? []);
405
565
 
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
566
+ if (members) {
567
+ for (const clientId of Array.from(members)) {
568
+ if (exclude.has(clientId)) continue;
569
+ const client = this.clients.get(clientId);
570
+ if (!client) continue;
571
+ this.safeSend(client, message);
414
572
  }
415
573
  }
574
+ this.backplane.publish("room", message, { room: roomName, exclude: excludeIds?.[0] ?? null }, Log);
416
575
  }
417
576
 
418
577
  // ── Private ────────────────────────────────────────────────
@@ -424,6 +583,155 @@ export class WebSocketServer {
424
583
  }
425
584
  }
426
585
 
586
+ // ── Backplane (multi-instance scaling) ─────────────────────
587
+ //
588
+ // When TINA4_WS_BACKPLANE is configured, every broadcast is ALSO published
589
+ // to a shared pub/sub channel so sibling server instances can relay it to
590
+ // their own local connections. Node is single-threaded async, so the
591
+ // subscribe callback relays directly on the event loop (no thread bridge) —
592
+ // it still applies the origin guard (drop our own echo) and never
593
+ // re-publishes (which would loop the cluster). The wiring is best-effort: a
594
+ // backplane failure logs and degrades to local-only, never crashing a
595
+ // broadcast.
596
+
597
+ /** Lazily wire the backplane on the first broadcast. Idempotent. */
598
+ private ensureBackplane(): void {
599
+ if (this.backplaneStarted) return;
600
+ this.backplaneStarted = true;
601
+ // Fire-and-forget: ensure() is async (connecting to the bus), but the
602
+ // local delivery that just happened must not wait on it. Any failure is
603
+ // logged inside ensure() and degrades to local-only.
604
+ void this.backplane.ensure((env) => this.relayLocal(env), Log);
605
+ }
606
+
607
+ /**
608
+ * Deliver a remote-originated envelope to LOCAL connections only. NEVER
609
+ * re-publishes (that would loop the message around the cluster). Dispatches
610
+ * by `kind`: room / path / all.
611
+ */
612
+ private relayLocal(env: WsEnvelope): void {
613
+ const message = WsBackplaneManager.decodeMessage(env);
614
+ if (message === null) return;
615
+ const exclude = env.exclude ?? null;
616
+
617
+ let targets: WebSocketClient[];
618
+ if (env.kind === "room") {
619
+ const members = env.room ? this.rooms.get(env.room) : undefined;
620
+ targets = members
621
+ ? Array.from(members).map((id) => this.clients.get(id)).filter((c): c is WebSocketClient => !!c)
622
+ : [];
623
+ } else if (env.kind === "path") {
624
+ targets = env.path
625
+ ? Array.from(this.clients.values()).filter((c) => c.path === env.path)
626
+ : [];
627
+ } else {
628
+ // "all" (and anything unknown) → every local connection
629
+ targets = Array.from(this.clients.values());
630
+ }
631
+
632
+ for (const client of targets) {
633
+ if (exclude && client.id === exclude) continue;
634
+ this.safeSend(client, message);
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Send to ONE client without letting a single dead/slow client abort a
640
+ * broadcast loop. A write failure (or an already-closed client) is logged
641
+ * and the client is pruned. Returns true if the frame was handed to the
642
+ * socket.
643
+ *
644
+ * Slow-client backpressure: `socket.write()` returns false when the kernel
645
+ * send buffer is full. We don't unboundedly buffer — a client whose backlog
646
+ * has blown past TINA4_WS_MAX_BACKLOG bytes is hopelessly behind, so we drop
647
+ * and close it rather than let it grow the process heap without bound.
648
+ */
649
+ private safeSend(client: WebSocketClient, message: string | Buffer): boolean {
650
+ if (client.closed) {
651
+ this.pruneClient(client);
652
+ return false;
653
+ }
654
+ const payload = typeof message === "string" ? Buffer.from(message, "utf-8") : message;
655
+ const opcode = typeof message === "string" ? OP_TEXT : OP_BINARY;
656
+ const frame = buildFrame(opcode, payload);
657
+ try {
658
+ // A saturated socket buffer means the client can't keep up. write()
659
+ // still queues the frame and returns false; if the queued backlog is
660
+ // hopeless, close the client rather than buffer without bound.
661
+ client.socket.write(frame);
662
+ const backlog = client.socket.writableLength ?? 0;
663
+ const maxBacklog = parseInt(process.env.TINA4_WS_MAX_BACKLOG ?? "1048576", 10);
664
+ if (maxBacklog > 0 && backlog > maxBacklog) {
665
+ Log.warn(`WebSocket client ${client.id} backlog ${backlog}B exceeds limit, dropping slow client`);
666
+ this.pruneClient(client);
667
+ return false;
668
+ }
669
+ return true;
670
+ } catch (err) {
671
+ Log.warn(`WebSocket send to ${client.id} failed, pruning: ${(err as Error).message}`);
672
+ this.pruneClient(client);
673
+ return false;
674
+ }
675
+ }
676
+
677
+ /** Remove a client from the manager, rooms, and close its socket. */
678
+ private pruneClient(client: WebSocketClient): void {
679
+ client.closed = true;
680
+ this.clients.delete(client.id);
681
+ this.removeClientFromAllRooms(client.id);
682
+ try {
683
+ client.socket.destroy();
684
+ } catch {
685
+ /* already gone */
686
+ }
687
+ }
688
+
689
+ // ── Idle reaper (opt-in via TINA4_WS_IDLE_TIMEOUT) ──────────
690
+
691
+ /**
692
+ * Close connections whose last inbound frame is older than `timeoutSeconds`.
693
+ * Returns the number reaped. `timeoutSeconds <= 0` is a no-op (the reaper is
694
+ * opt-in via TINA4_WS_IDLE_TIMEOUT).
695
+ */
696
+ reapIdle(timeoutSeconds: number): number {
697
+ if (timeoutSeconds <= 0) return 0;
698
+ const now = Date.now();
699
+ const cutoff = timeoutSeconds * 1000;
700
+ const stale = Array.from(this.clients.values()).filter(
701
+ (c) => now - (c.lastActivity ?? c.connectedAt ?? now) > cutoff,
702
+ );
703
+ for (const client of stale) {
704
+ this.close(client.id, CLOSE_GOING_AWAY, "idle timeout");
705
+ }
706
+ if (stale.length > 0) {
707
+ Log.info(`WebSocket idle reaper closed ${stale.length} connection(s)`);
708
+ }
709
+ return stale.length;
710
+ }
711
+
712
+ /**
713
+ * Spin up the idle-connection reaper when TINA4_WS_IDLE_TIMEOUT is a
714
+ * positive number of seconds. Opt-in and non-breaking — unset/0 means no
715
+ * timer is created at all (current behaviour). Called from start().
716
+ */
717
+ private startIdleReaper(): void {
718
+ const raw = process.env.TINA4_WS_IDLE_TIMEOUT ?? "0";
719
+ const timeout = parseFloat(raw);
720
+ if (!Number.isFinite(timeout) || timeout <= 0 || this.reaperTimer !== null) return;
721
+ // Sweep at a fraction of the timeout (min 1s) so an idle conn is closed
722
+ // within roughly one timeout window of going silent.
723
+ const intervalMs = Math.max(1000, (timeout / 2) * 1000);
724
+ this.reaperTimer = setInterval(() => {
725
+ try {
726
+ this.reapIdle(timeout);
727
+ } catch (err) {
728
+ Log.error(`WebSocket idle reaper sweep failed: ${(err as Error).message}`);
729
+ }
730
+ }, intervalMs);
731
+ // Don't keep the event loop alive just for the reaper.
732
+ this.reaperTimer.unref?.();
733
+ }
734
+
427
735
  private handleUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
428
736
  const wsKey = req.headers["sec-websocket-key"];
429
737
  if (!wsKey) {
@@ -439,18 +747,44 @@ export class WebSocketServer {
439
747
  return;
440
748
  }
441
749
 
442
- // Compute accept key and send upgrade response
443
- const acceptKey = computeAcceptKey(wsKey);
444
- const response = [
750
+ // Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow
751
+ // all, so this never breaks an existing deployment.
752
+ if (!originAllowed(req.headers)) {
753
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
754
+ socket.destroy();
755
+ return;
756
+ }
757
+
758
+ // Per-route auth — checked AFTER the origin allow-list, BEFORE accept.
759
+ // A WS route is public by default (mirrors GET); a secured route requires a
760
+ // valid JWT via the Authorization header, the `bearer` subprotocol, or
761
+ // `?token=`. Missing/invalid → reject the upgrade (HTTP 401, never accept).
762
+ // Public routes (and any path with no registered route) always pass —
763
+ // non-breaking. The verified payload is exposed as client.auth.
764
+ const [reqPath, reqQuery = ""] = (req.url ?? "/").split("?");
765
+ const wsRoute = Router.matchWebSocket(reqPath);
766
+ const offeredProto = (req.headers["sec-websocket-protocol"] as string | undefined) ?? "";
767
+ const [authPayload, authOk] = wsAuthorized(wsRoute, req.headers, reqQuery, offeredProto);
768
+ if (!authOk) {
769
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
770
+ socket.destroy();
771
+ return;
772
+ }
773
+
774
+ // Compute accept key and send upgrade response. Echo the `bearer`
775
+ // subprotocol when the client offered it (the browser token transport).
776
+ const acceptKey = computeAcceptKey(Array.isArray(wsKey) ? wsKey[0] : wsKey);
777
+ const responseLines = [
445
778
  "HTTP/1.1 101 Switching Protocols",
446
779
  "Upgrade: websocket",
447
780
  "Connection: Upgrade",
448
781
  `Sec-WebSocket-Accept: ${acceptKey}`,
449
- "",
450
- "",
451
- ].join("\r\n");
452
-
453
- socket.write(response);
782
+ ];
783
+ if (offeredBearerSubprotocol(offeredProto)) {
784
+ responseLines.push("Sec-WebSocket-Protocol: bearer");
785
+ }
786
+ responseLines.push("", "");
787
+ socket.write(responseLines.join("\r\n"));
454
788
 
455
789
  // Create client — track the URL path for path-scoped broadcast
456
790
  const clientId = randomUUID().slice(0, 8);
@@ -461,13 +795,15 @@ export class WebSocketServer {
461
795
  connectedAt: Date.now(),
462
796
  closed: false,
463
797
  path: req.url ?? "/",
798
+ lastActivity: Date.now(),
799
+ auth: authPayload,
464
800
  };
465
801
 
466
802
  this.clients.set(clientId, client);
467
803
  this.emit("open", client);
468
804
 
469
805
  // Handle incoming data
470
- let buffer = Buffer.alloc(0);
806
+ let buffer: Buffer = Buffer.alloc(0);
471
807
  if (head.length > 0) {
472
808
  buffer = Buffer.concat([buffer, head]);
473
809
  }
@@ -506,6 +842,7 @@ export class WebSocketServer {
506
842
  if (!frame) break;
507
843
 
508
844
  remaining = remaining.subarray(frame.bytesConsumed);
845
+ client.lastActivity = Date.now(); // mark activity for the idle reaper
509
846
 
510
847
  switch (frame.opcode) {
511
848
  case OP_TEXT:
@@ -563,6 +900,293 @@ export class WebSocketServer {
563
900
  }
564
901
  }
565
902
 
903
+ // ── Integrated WebSocket route manager (server.ts upgrade path) ──
904
+ //
905
+ // The standalone WebSocketServer above owns its own HTTP server. The INTEGRATED
906
+ // Tina4 server (server.ts) runs one HTTP server for everything, so user WS
907
+ // routes registered via Router.websocket() / WebSocketServer.route() must be
908
+ // driven from server.ts's `upgrade` handler. This module-level manager mirrors
909
+ // Python's `_ws_manager`: it holds the live route connections, drives the
910
+ // open/message/close lifecycle on the raw socket, and powers path-scoped
911
+ // broadcast / rooms for those connections. Auth is enforced on the upgrade
912
+ // BEFORE a connection is created (see serveWebSocketRoute).
913
+
914
+ /** A live route connection in the integrated server. */
915
+ interface RouteConnState {
916
+ conn: WebSocketConnection;
917
+ socket: Socket;
918
+ path: string;
919
+ rooms: Set<string>;
920
+ closed: boolean;
921
+ /** Optional dev-admin tracker id so the connection shows in the websockets list. */
922
+ trackerId?: string;
923
+ }
924
+
925
+ /**
926
+ * Process-wide manager for user WebSocket route connections served by the
927
+ * integrated server. Path-scoped broadcast + rooms mirror the standalone
928
+ * WebSocketServer semantics so a route handler behaves the same either way.
929
+ */
930
+ class WsRouteManager {
931
+ private connections = new Map<string, RouteConnState>();
932
+ private rooms = new Map<string, Set<string>>();
933
+ private onAdd?: (remoteAddress: string, path: string) => string;
934
+ private onRemove?: (id: string) => void;
935
+
936
+ /** Wire dev-admin tracking callbacks (WsTracker.add / WsTracker.remove). */
937
+ setTracker(onAdd: (remoteAddress: string, path: string) => string, onRemove: (id: string) => void): void {
938
+ this.onAdd = onAdd;
939
+ this.onRemove = onRemove;
940
+ }
941
+
942
+ /** Currently-open route connections (test/diagnostic helper). */
943
+ get size(): number {
944
+ return this.connections.size;
945
+ }
946
+
947
+ add(state: RouteConnState): void {
948
+ if (this.onAdd) state.trackerId = this.onAdd(state.socket.remoteAddress ?? "unknown", state.path);
949
+ this.connections.set(state.conn.id, state);
950
+ }
951
+
952
+ remove(id: string): void {
953
+ const state = this.connections.get(id);
954
+ if (!state) return;
955
+ for (const room of state.rooms) this.rooms.get(room)?.delete(id);
956
+ this.connections.delete(id);
957
+ if (state.trackerId && this.onRemove) this.onRemove(state.trackerId);
958
+ }
959
+
960
+ /** Send a text frame to one connection (best-effort). */
961
+ sendTo(id: string, message: string): void {
962
+ const state = this.connections.get(id);
963
+ if (!state || state.closed) return;
964
+ try {
965
+ state.socket.write(buildFrame(OP_TEXT, Buffer.from(message, "utf-8")));
966
+ } catch {
967
+ /* dropped */
968
+ }
969
+ }
970
+
971
+ /** Broadcast a text frame to every connection on the same path. */
972
+ broadcastPath(path: string, message: string): void {
973
+ const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
974
+ for (const state of this.connections.values()) {
975
+ if (state.path !== path || state.closed) continue;
976
+ try {
977
+ state.socket.write(frame);
978
+ } catch {
979
+ /* dropped */
980
+ }
981
+ }
982
+ }
983
+
984
+ joinRoom(id: string, room: string): void {
985
+ const state = this.connections.get(id);
986
+ if (!state) return;
987
+ state.rooms.add(room);
988
+ if (!this.rooms.has(room)) this.rooms.set(room, new Set());
989
+ this.rooms.get(room)!.add(id);
990
+ }
991
+
992
+ leaveRoom(id: string, room: string): void {
993
+ this.connections.get(id)?.rooms.delete(room);
994
+ this.rooms.get(room)?.delete(id);
995
+ }
996
+ }
997
+
998
+ /** Process-wide manager for integrated-server user WS route connections. */
999
+ export const wsRouteManager = new WsRouteManager();
1000
+
1001
+ /**
1002
+ * Build a concrete {@link WebSocketConnection} bound to a raw socket, for a
1003
+ * route served by the integrated server.
1004
+ */
1005
+ function createRouteConnection(
1006
+ socket: Socket,
1007
+ path: string,
1008
+ headers: Record<string, string>,
1009
+ params: Record<string, string>,
1010
+ auth: Record<string, unknown> | null,
1011
+ ): WebSocketConnection {
1012
+ const id = randomUUID().slice(0, 8);
1013
+ const send = (message: string): void => {
1014
+ try {
1015
+ socket.write(buildFrame(OP_TEXT, Buffer.from(message, "utf-8")));
1016
+ } catch {
1017
+ /* socket gone */
1018
+ }
1019
+ };
1020
+ const conn: WebSocketConnection = {
1021
+ id,
1022
+ path,
1023
+ ip: socket.remoteAddress ?? "unknown",
1024
+ headers,
1025
+ params,
1026
+ auth,
1027
+ send,
1028
+ sendJson: (data: unknown) => send(JSON.stringify(data)),
1029
+ broadcast: (message: string) => wsRouteManager.broadcastPath(path, message),
1030
+ joinRoom: (room: string) => wsRouteManager.joinRoom(id, room),
1031
+ leaveRoom: (room: string) => wsRouteManager.leaveRoom(id, room),
1032
+ close: () => {
1033
+ try {
1034
+ socket.write(buildFrame(OP_CLOSE, Buffer.from([0x03, 0xe8]))); // 1000
1035
+ socket.end();
1036
+ } catch {
1037
+ /* already closed */
1038
+ }
1039
+ },
1040
+ _onMessage: null,
1041
+ _onClose: null,
1042
+ onMessage(handler) {
1043
+ this._onMessage = handler;
1044
+ },
1045
+ onClose(handler) {
1046
+ this._onClose = handler;
1047
+ },
1048
+ };
1049
+ return conn;
1050
+ }
1051
+
1052
+ /**
1053
+ * Reject a WebSocket upgrade on the raw socket with an HTTP status line.
1054
+ * Used before the handshake completes (origin/auth failures).
1055
+ */
1056
+ function rejectUpgrade(socket: Socket, statusLine: string): void {
1057
+ try {
1058
+ socket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`);
1059
+ socket.destroy();
1060
+ } catch {
1061
+ /* socket already gone */
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Serve a user WebSocket route on the integrated server's `upgrade` event.
1067
+ *
1068
+ * This is the second half of per-route WS auth in Node: it's the entry point
1069
+ * that wires a user-registered WS route (the WS route table) into the integrated
1070
+ * server so the route actually gets a live open/message/close lifecycle on a
1071
+ * real connection — parity with Python/PHP/Ruby — AND enforces per-route auth.
1072
+ *
1073
+ * Order mirrors Python's `_handle_dev_websocket` / `_handle_asgi_websocket`:
1074
+ * 1. require a Sec-WebSocket-Key (else 400);
1075
+ * 2. origin allow-list (else 403) — TINA4_WS_ALLOWED_ORIGINS, unset = allow all;
1076
+ * 3. per-route auth (else 401) — public by default, secured needs a valid JWT;
1077
+ * 4. accept the handshake, echoing `bearer` when offered;
1078
+ * 5. build the connection (conn.auth = payload), fire "open", then pump
1079
+ * frames into "message" / "close".
1080
+ *
1081
+ * Returns true if a matching route was found and handled (accepted OR rejected),
1082
+ * false if no WS route matched this path (caller falls through to its 404).
1083
+ */
1084
+ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head: Buffer): boolean {
1085
+ const [reqPath, reqQuery = ""] = (req.url ?? "/").split("?");
1086
+ const route = Router.matchWebSocket(reqPath);
1087
+ if (!route) return false;
1088
+
1089
+ const wsKey = req.headers["sec-websocket-key"];
1090
+ if (!wsKey || (typeof wsKey === "string" && wsKey.length === 0)) {
1091
+ rejectUpgrade(socket, "400 Bad Request");
1092
+ return true;
1093
+ }
1094
+
1095
+ // Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow all.
1096
+ if (!originAllowed(req.headers)) {
1097
+ rejectUpgrade(socket, "403 Forbidden");
1098
+ return true;
1099
+ }
1100
+
1101
+ // Per-route auth: AFTER the origin check, BEFORE accept. Public by default;
1102
+ // a secured route requires a valid JWT (header / bearer subprotocol / ?token=).
1103
+ const offeredProto = (req.headers["sec-websocket-protocol"] as string | undefined) ?? "";
1104
+ const [authPayload, authOk] = wsAuthorized(route, req.headers, reqQuery, offeredProto);
1105
+ if (!authOk) {
1106
+ rejectUpgrade(socket, "401 Unauthorized");
1107
+ return true;
1108
+ }
1109
+
1110
+ // Accept — echo the `bearer` subprotocol when the client offered it.
1111
+ const acceptKey = computeAcceptKey(Array.isArray(wsKey) ? wsKey[0] : wsKey);
1112
+ const responseLines = [
1113
+ "HTTP/1.1 101 Switching Protocols",
1114
+ "Upgrade: websocket",
1115
+ "Connection: Upgrade",
1116
+ `Sec-WebSocket-Accept: ${acceptKey}`,
1117
+ ];
1118
+ if (offeredBearerSubprotocol(offeredProto)) {
1119
+ responseLines.push("Sec-WebSocket-Protocol: bearer");
1120
+ }
1121
+ responseLines.push("", "");
1122
+ try {
1123
+ socket.write(responseLines.join("\r\n"));
1124
+ } catch {
1125
+ return true; // socket died during handshake
1126
+ }
1127
+
1128
+ const headerMap: Record<string, string> = {};
1129
+ for (const [k, v] of Object.entries(req.headers)) {
1130
+ headerMap[k] = Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
1131
+ }
1132
+ const conn = createRouteConnection(socket, reqPath, headerMap, {}, authPayload);
1133
+ const state: RouteConnState = { conn, socket, path: reqPath, rooms: new Set(), closed: false };
1134
+ wsRouteManager.add(state);
1135
+
1136
+ const handler = route.handler;
1137
+ // Fire "open" — may set conn._onMessage / conn._onClose (decorator style).
1138
+ void Promise.resolve()
1139
+ .then(() => handler(conn, "open", ""))
1140
+ .catch((e) => Log.error(`WebSocket open handler error: ${(e as Error).message}`));
1141
+
1142
+ let buffer = head && head.length > 0 ? Buffer.from(head) : Buffer.alloc(0);
1143
+ const fireClose = () => {
1144
+ if (state.closed) return;
1145
+ state.closed = true;
1146
+ void Promise.resolve()
1147
+ .then(() => (conn._onClose ? conn._onClose() : handler(conn, "close", "")))
1148
+ .catch((e) => Log.error(`WebSocket close handler error: ${(e as Error).message}`))
1149
+ .finally(() => wsRouteManager.remove(conn.id));
1150
+ };
1151
+
1152
+ socket.on("data", (chunk: Buffer) => {
1153
+ buffer = Buffer.concat([buffer, chunk]);
1154
+ while (buffer.length > 0) {
1155
+ const frame = parseFrame(buffer);
1156
+ if (!frame) break;
1157
+ buffer = buffer.subarray(frame.bytesConsumed);
1158
+ switch (frame.opcode) {
1159
+ case OP_TEXT: {
1160
+ const text = frame.payload.toString("utf-8");
1161
+ void Promise.resolve()
1162
+ .then(() => (conn._onMessage ? conn._onMessage(text) : handler(conn, "message", text)))
1163
+ .catch((e) => Log.error(`WebSocket message handler error: ${(e as Error).message}`));
1164
+ break;
1165
+ }
1166
+ case OP_PING:
1167
+ try {
1168
+ socket.write(buildFrame(OP_PONG, frame.payload));
1169
+ } catch {
1170
+ /* client gone */
1171
+ }
1172
+ break;
1173
+ case OP_CLOSE:
1174
+ try {
1175
+ socket.write(buildFrame(OP_CLOSE, Buffer.from([0x03, 0xe8])));
1176
+ socket.end();
1177
+ } catch {
1178
+ /* already closed */
1179
+ }
1180
+ fireClose();
1181
+ return;
1182
+ }
1183
+ }
1184
+ });
1185
+ socket.on("close", fireClose);
1186
+ socket.on("error", fireClose);
1187
+ return true;
1188
+ }
1189
+
566
1190
  // ── Dev-reload WebSocket manager ─────────────────────────────
567
1191
 
568
1192
  /** A single accepted /__dev_reload socket plus its dashboard tracker id. */