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.
- package/CLAUDE.md +65 -20
- package/README.md +6 -6
- 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/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- 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 +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -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 +56 -8
- package/packages/core/src/server.ts +138 -23
- 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/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- 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 +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
397
|
+
broadcast(message: string | Buffer, excludeIds?: string[], path?: string): void {
|
|
398
|
+
this.ensureBackplane();
|
|
247
399
|
const exclude = new Set(excludeIds ?? []);
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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. */
|