tina4-nodejs 3.13.38 → 3.13.40
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 +54 -5
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +91 -21
- package/packages/core/src/index.ts +9 -4
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +105 -12
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +120 -22
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +21 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +14 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -30,6 +30,7 @@ import type { WebSocketConnection } from "./websocketConnection.js";
|
|
|
30
30
|
import type { WebSocketRouteHandler } from "./types.js";
|
|
31
31
|
import { Router } from "./router.js";
|
|
32
32
|
import { Log } from "./logger.js";
|
|
33
|
+
import { validToken } from "./auth.js";
|
|
33
34
|
import { WsBackplaneManager, type WsEnvelope } from "./websocketBackplane.js";
|
|
34
35
|
|
|
35
36
|
// ── Constants ────────────────────────────────────────────────
|
|
@@ -66,6 +67,12 @@ export interface WebSocketClient {
|
|
|
66
67
|
* (opt-in). Optional so externally-injected/legacy client objects still fit.
|
|
67
68
|
*/
|
|
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;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
type EventHandler = (...args: unknown[]) => void;
|
|
@@ -105,6 +112,97 @@ export function originAllowed(headers: Record<string, string | string[] | undefi
|
|
|
105
112
|
return origin !== undefined && allowed.has(origin);
|
|
106
113
|
}
|
|
107
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
|
+
|
|
108
206
|
/**
|
|
109
207
|
* Parse HTTP headers from raw upgrade request data.
|
|
110
208
|
*/
|
|
@@ -252,7 +350,11 @@ export class WebSocketServer {
|
|
|
252
350
|
* to the Router's `(conn, event, data)` style and registers it via
|
|
253
351
|
* `Router.websocket()`.
|
|
254
352
|
*/
|
|
255
|
-
route(
|
|
353
|
+
route(
|
|
354
|
+
path: string,
|
|
355
|
+
handler: (conn: WebSocketConnection) => void | Promise<void>,
|
|
356
|
+
options?: { secured?: boolean },
|
|
357
|
+
): void {
|
|
256
358
|
this._routeHandlers.set(path, handler);
|
|
257
359
|
|
|
258
360
|
// Adapt to Router's (conn, event, data) style
|
|
@@ -279,7 +381,9 @@ export class WebSocketServer {
|
|
|
279
381
|
}
|
|
280
382
|
};
|
|
281
383
|
|
|
282
|
-
|
|
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);
|
|
283
387
|
}
|
|
284
388
|
|
|
285
389
|
/**
|
|
@@ -651,18 +755,36 @@ export class WebSocketServer {
|
|
|
651
755
|
return;
|
|
652
756
|
}
|
|
653
757
|
|
|
654
|
-
//
|
|
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).
|
|
655
776
|
const acceptKey = computeAcceptKey(Array.isArray(wsKey) ? wsKey[0] : wsKey);
|
|
656
|
-
const
|
|
777
|
+
const responseLines = [
|
|
657
778
|
"HTTP/1.1 101 Switching Protocols",
|
|
658
779
|
"Upgrade: websocket",
|
|
659
780
|
"Connection: Upgrade",
|
|
660
781
|
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
782
|
+
];
|
|
783
|
+
if (offeredBearerSubprotocol(offeredProto)) {
|
|
784
|
+
responseLines.push("Sec-WebSocket-Protocol: bearer");
|
|
785
|
+
}
|
|
786
|
+
responseLines.push("", "");
|
|
787
|
+
socket.write(responseLines.join("\r\n"));
|
|
666
788
|
|
|
667
789
|
// Create client — track the URL path for path-scoped broadcast
|
|
668
790
|
const clientId = randomUUID().slice(0, 8);
|
|
@@ -674,6 +796,7 @@ export class WebSocketServer {
|
|
|
674
796
|
closed: false,
|
|
675
797
|
path: req.url ?? "/",
|
|
676
798
|
lastActivity: Date.now(),
|
|
799
|
+
auth: authPayload,
|
|
677
800
|
};
|
|
678
801
|
|
|
679
802
|
this.clients.set(clientId, client);
|
|
@@ -777,6 +900,293 @@ export class WebSocketServer {
|
|
|
777
900
|
}
|
|
778
901
|
}
|
|
779
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
|
+
|
|
780
1190
|
// ── Dev-reload WebSocket manager ─────────────────────────────
|
|
781
1191
|
|
|
782
1192
|
/** A single accepted /__dev_reload socket plus its dashboard tracker id. */
|
|
@@ -13,6 +13,12 @@ export interface WebSocketConnection {
|
|
|
13
13
|
headers: Record<string, string>;
|
|
14
14
|
/** Route parameters extracted from `{param}` segments in the path */
|
|
15
15
|
params: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Verified JWT payload on a `@secured` / `.secure()` WebSocket route, or
|
|
18
|
+
* `null` on a public route (the default). Set on the upgrade after the token
|
|
19
|
+
* is validated — mirrors Python's `connection.auth`.
|
|
20
|
+
*/
|
|
21
|
+
auth: Record<string, unknown> | null;
|
|
16
22
|
/** Send a message to this connection only */
|
|
17
23
|
send(message: string): void;
|
|
18
24
|
/** Serialize an object to JSON and send it to this connection. Parity with Python/PHP. */
|