tina4-nodejs 3.13.92 → 3.13.95
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 +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous socket transport — ONE persistent connection per target.
|
|
3
|
+
*
|
|
4
|
+
* Serves the RESP backends (Redis, Valkey) and memcached's text protocol. The
|
|
5
|
+
* sync-over-async plumbing lives in syncBridge; this file is only the socket
|
|
6
|
+
* worker and the two protocol entry points.
|
|
7
|
+
*
|
|
8
|
+
* See syncBridge for WHY: each command used to run in a short-lived `node -e`
|
|
9
|
+
* child, paying a process spawn AND a fresh TCP connection every time (spawn p50
|
|
10
|
+
* 41ms / p99 487ms, connect tail 0.5-0.9s), and that tail tripped the child's
|
|
11
|
+
* deadline under load — the cause of the Valkey session flake.
|
|
12
|
+
*
|
|
13
|
+
* Reconnection is the worker's business: a dropped socket is re-established on
|
|
14
|
+
* the next command rather than surfacing as a caller error.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
getBridge,
|
|
18
|
+
closeBridges,
|
|
19
|
+
DATA_BYTES,
|
|
20
|
+
STATUS_ERROR,
|
|
21
|
+
STATUS_NIL,
|
|
22
|
+
STATUS_OK,
|
|
23
|
+
STATUS_TRANSPORT,
|
|
24
|
+
} from "./syncBridge.js";
|
|
25
|
+
|
|
26
|
+
export interface SyncSocketTarget {
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
/** AUTH password (empty/undefined = no AUTH sent). */
|
|
30
|
+
password?: string;
|
|
31
|
+
/** SELECT db index (0/undefined = no SELECT sent). */
|
|
32
|
+
db?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long the worker waits for a TCP connection. Deliberately shorter than the
|
|
37
|
+
* bridge's reply timeout so an unreachable host is reported as a connect failure
|
|
38
|
+
* with a useful message, rather than as a generic reply timeout — before this
|
|
39
|
+
* existed, learning a server was down took the full 5s deadline.
|
|
40
|
+
*/
|
|
41
|
+
const CONNECT_TIMEOUT_MS = 2000;
|
|
42
|
+
|
|
43
|
+
const SOCKET_WORKER = `
|
|
44
|
+
const net = require("node:net");
|
|
45
|
+
const target = workerData.target;
|
|
46
|
+
|
|
47
|
+
let sock = null;
|
|
48
|
+
let buffer = Buffer.alloc(0);
|
|
49
|
+
let pending = null;
|
|
50
|
+
|
|
51
|
+
function encodeCommand(args) {
|
|
52
|
+
let out = "*" + args.length + "\\r\\n";
|
|
53
|
+
for (const s of args) out += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse one RESP value at offset. Returns { value, next } or null when more
|
|
58
|
+
// bytes are needed, so a bulk string split across TCP chunks is reassembled
|
|
59
|
+
// rather than truncated.
|
|
60
|
+
function parse(buf, off) {
|
|
61
|
+
if (off >= buf.length) return null;
|
|
62
|
+
const type = buf[off];
|
|
63
|
+
const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
|
|
64
|
+
if (crlf === -1) return null;
|
|
65
|
+
const line = buf.toString("utf-8", off + 1, crlf);
|
|
66
|
+
const after = crlf + 2;
|
|
67
|
+
if (type === 0x2b) return { value: line, next: after }; // '+'
|
|
68
|
+
if (type === 0x3a) return { value: line, next: after }; // ':'
|
|
69
|
+
if (type === 0x2d) return { value: { __err: line }, next: after }; // '-'
|
|
70
|
+
if (type === 0x24) { // '$'
|
|
71
|
+
const len = parseInt(line, 10);
|
|
72
|
+
if (len === -1) return { value: null, next: after };
|
|
73
|
+
if (after + len + 2 > buf.length) return null;
|
|
74
|
+
return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
|
|
75
|
+
}
|
|
76
|
+
if (type === 0x2a) { // '*'
|
|
77
|
+
const count = parseInt(line, 10);
|
|
78
|
+
if (count === -1) return { value: null, next: after };
|
|
79
|
+
const arr = [];
|
|
80
|
+
let pos = after;
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
const el = parse(buf, pos);
|
|
83
|
+
if (!el) return null;
|
|
84
|
+
arr.push(el.value);
|
|
85
|
+
pos = el.next;
|
|
86
|
+
}
|
|
87
|
+
return { value: arr, next: pos };
|
|
88
|
+
}
|
|
89
|
+
return { value: line, next: after };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function settle(status, payload) {
|
|
93
|
+
if (!pending) return;
|
|
94
|
+
pending = null;
|
|
95
|
+
__reply(status, payload);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function drop(err) {
|
|
99
|
+
// The socket is gone. Fail the in-flight command, then let the NEXT command
|
|
100
|
+
// reconnect - a transient blip must not poison the channel forever.
|
|
101
|
+
try { if (sock) sock.destroy(); } catch (e) {}
|
|
102
|
+
sock = null;
|
|
103
|
+
buffer = Buffer.alloc(0);
|
|
104
|
+
settle(${STATUS_TRANSPORT}, err || "connection lost");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onRespData(chunk) {
|
|
108
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
109
|
+
while (pending) {
|
|
110
|
+
const p = parse(buffer, 0);
|
|
111
|
+
if (!p) break;
|
|
112
|
+
buffer = buffer.subarray(p.next);
|
|
113
|
+
pending.replies.push(p.value);
|
|
114
|
+
if (pending.replies.length < pending.expected) continue;
|
|
115
|
+
|
|
116
|
+
// A rejected AUTH/SELECT is surfaced ahead of the command's own reply.
|
|
117
|
+
for (let i = 0; i < pending.expected - 1; i++) {
|
|
118
|
+
const r = pending.replies[i];
|
|
119
|
+
if (r && typeof r === "object" && r.__err !== undefined) { settle(${STATUS_ERROR}, r.__err); return; }
|
|
120
|
+
}
|
|
121
|
+
const result = pending.replies[pending.expected - 1];
|
|
122
|
+
if (result && typeof result === "object" && result.__err !== undefined) settle(${STATUS_ERROR}, result.__err);
|
|
123
|
+
else if (result === null || result === undefined) settle(${STATUS_NIL}, null);
|
|
124
|
+
else settle(${STATUS_OK}, result);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onTextData(chunk) {
|
|
130
|
+
// Text protocols (memcached) carry no length prefix: the reply is complete
|
|
131
|
+
// when one of the caller's terminators appears.
|
|
132
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
133
|
+
if (!pending) return;
|
|
134
|
+
const text = buffer.toString("utf-8");
|
|
135
|
+
for (const t of pending.terminators) {
|
|
136
|
+
if (text.includes(t)) { buffer = Buffer.alloc(0); settle(${STATUS_OK}, text); return; }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function connect(then) {
|
|
141
|
+
const s = net.createConnection({ host: target.host, port: target.port });
|
|
142
|
+
s.setNoDelay(true);
|
|
143
|
+
// An unroutable host neither connects nor errors promptly, so bound it here:
|
|
144
|
+
// otherwise the caller sits out its whole reply deadline for a host that was
|
|
145
|
+
// never going to answer.
|
|
146
|
+
const connectTimer = setTimeout(() => {
|
|
147
|
+
if (sock !== s) { try { s.destroy(); } catch (e) {} drop("connect timed out"); }
|
|
148
|
+
}, ${CONNECT_TIMEOUT_MS});
|
|
149
|
+
s.on("data", (c) => (pending && pending.text ? onTextData(c) : onRespData(c)));
|
|
150
|
+
s.on("error", (e) => { clearTimeout(connectTimer); drop(e.message); });
|
|
151
|
+
s.on("close", () => { clearTimeout(connectTimer); if (sock === s) drop("connection closed"); });
|
|
152
|
+
s.on("connect", () => { clearTimeout(connectTimer); sock = s; then(); });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
parentPort.on("message", (msg) => {
|
|
156
|
+
// Set pending BEFORE connecting. A connect error (ECONNREFUSED) otherwise
|
|
157
|
+
// arrives with nothing in flight, and the caller blocks for its full reply
|
|
158
|
+
// timeout just to learn the server is down.
|
|
159
|
+
const fresh = !sock;
|
|
160
|
+
|
|
161
|
+
if (msg.mode === "text") {
|
|
162
|
+
pending = { text: true, terminators: msg.terminators };
|
|
163
|
+
const writeText = () => {
|
|
164
|
+
try { sock.write(msg.payload, "utf-8"); } catch (e) { drop(e.message); }
|
|
165
|
+
};
|
|
166
|
+
if (sock) writeText(); else { try { connect(writeText); } catch (e) { drop(e.message); } }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The handshake rides with the FIRST command on a fresh connection only;
|
|
171
|
+
// afterwards the connection is already authenticated and selected.
|
|
172
|
+
const handshake = fresh ? ((target.password ? 1 : 0) + (target.db ? 1 : 0)) : 0;
|
|
173
|
+
pending = { expected: 1 + handshake, replies: [] };
|
|
174
|
+
|
|
175
|
+
const write = () => {
|
|
176
|
+
let payload = "";
|
|
177
|
+
if (fresh) {
|
|
178
|
+
if (target.password) payload += encodeCommand(["AUTH", target.password]);
|
|
179
|
+
if (target.db) payload += encodeCommand(["SELECT", String(target.db)]);
|
|
180
|
+
}
|
|
181
|
+
payload += encodeCommand(msg.args);
|
|
182
|
+
try { sock.write(payload); } catch (e) { drop(e.message); }
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (sock) write(); else { try { connect(write); } catch (e) { drop(e.message); } }
|
|
186
|
+
});
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
function socketBridge(target: SyncSocketTarget) {
|
|
190
|
+
const key = `sock:${target.host}:${target.port}:${target.db ?? 0}:${target.password ? "auth" : ""}`;
|
|
191
|
+
return getBridge(key, SOCKET_WORKER, { target });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run a single RESP command synchronously and return the reply.
|
|
196
|
+
*
|
|
197
|
+
* - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
|
|
198
|
+
* - A transport FAILURE (unreachable, timeout, connection closed) THROWS
|
|
199
|
+
* `<label> command failed: ...`.
|
|
200
|
+
* - A RESP error reply — including a rejected AUTH/SELECT — THROWS
|
|
201
|
+
* `<label> error: ...`.
|
|
202
|
+
*
|
|
203
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
204
|
+
* backend silently logs every user out instead of surfacing an outage. A dead
|
|
205
|
+
* socket and a healthy server saying "-ERR" are different failures and read
|
|
206
|
+
* differently in a log, so they keep different wording.
|
|
207
|
+
*/
|
|
208
|
+
export function syncCommand(target: SyncSocketTarget, args: string[], label = "Redis"): string {
|
|
209
|
+
const { status, payload } = socketBridge(target).call({ args }, label);
|
|
210
|
+
|
|
211
|
+
if (status === STATUS_NIL) return ""; // genuine key miss
|
|
212
|
+
if (status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${payload}`);
|
|
213
|
+
if (status === STATUS_ERROR) throw new Error(`${label} error: ${payload}`);
|
|
214
|
+
return payload;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run a raw text-protocol command (memcached) over the same persistent channel.
|
|
219
|
+
*
|
|
220
|
+
* The reply is whatever arrived once one of `terminators` appears — a text
|
|
221
|
+
* protocol carries no length prefix, so the caller names its own end markers.
|
|
222
|
+
* Failure semantics match {@link syncCommand}.
|
|
223
|
+
*/
|
|
224
|
+
export function syncTextCommand(
|
|
225
|
+
target: SyncSocketTarget,
|
|
226
|
+
payload: string,
|
|
227
|
+
terminators: string[],
|
|
228
|
+
label = "Memcached",
|
|
229
|
+
): string {
|
|
230
|
+
const reply = socketBridge(target).call({ mode: "text", payload, terminators }, label);
|
|
231
|
+
if (reply.status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${reply.payload}`);
|
|
232
|
+
return reply.payload;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Re-exported so callers do not need to know the bridge exists. */
|
|
236
|
+
export { closeBridges as closeSyncSockets, DATA_BYTES };
|
|
@@ -19,7 +19,7 @@ import { Socket } from "node:net";
|
|
|
19
19
|
import { createRequest } from "./request.js";
|
|
20
20
|
import { createResponse } from "./response.js";
|
|
21
21
|
import { defaultRouter, Router, runRouteMiddlewares } from "./router.js";
|
|
22
|
-
import { MiddlewareRunner } from "./middleware.js";
|
|
22
|
+
import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
|
|
23
23
|
import { enforceRouteAuth } from "./authGate.js";
|
|
24
24
|
|
|
25
25
|
export class TestResponse {
|
|
@@ -220,10 +220,21 @@ export class TestClient {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
// Per-route middleware,
|
|
223
|
+
// Per-route middleware: functions, string specs, and CLASSES, through the
|
|
224
|
+
// same runner the live dispatcher uses. A route class's afterX hooks join
|
|
225
|
+
// the after pass below, exactly as in server.ts.
|
|
226
|
+
//
|
|
227
|
+
// ORDER DRIFT, stated rather than hidden: the live dispatcher runs the auth
|
|
228
|
+
// gate BEFORE the route's own middleware (ADR-0012 — middleware on a
|
|
229
|
+
// secured route must never process a request that is about to be rejected);
|
|
230
|
+
// here it still runs before the gate. Aligning the two is a behaviour
|
|
231
|
+
// change to the test surface and belongs in its own change.
|
|
232
|
+
const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
|
|
224
233
|
if (match.middlewares && match.middlewares.length > 0) {
|
|
225
234
|
const proceed = await runRouteMiddlewares(match.middlewares, req, res);
|
|
226
235
|
if (!proceed || res.raw.writableEnded) {
|
|
236
|
+
await MiddlewareRunner.runAfter([...globalMiddleware, ...routeMiddlewareClasses], req, res);
|
|
237
|
+
if (!res.raw.writableEnded) res.raw.end();
|
|
227
238
|
return this._collect(rawRes, chunks, socket);
|
|
228
239
|
}
|
|
229
240
|
}
|
|
@@ -243,9 +254,11 @@ export class TestClient {
|
|
|
243
254
|
// Execute handler (only if auth passed)
|
|
244
255
|
if (!rejected) {
|
|
245
256
|
await match.handler(req, res);
|
|
246
|
-
// Global afterX hooks (logging / post-processing),
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
// Global + route-class afterX hooks (logging / post-processing),
|
|
258
|
+
// mirroring the live tail.
|
|
259
|
+
const afterMiddleware = [...globalMiddleware, ...routeMiddlewareClasses];
|
|
260
|
+
if (afterMiddleware.length > 0) {
|
|
261
|
+
await MiddlewareRunner.runAfter(afterMiddleware, req, res);
|
|
249
262
|
}
|
|
250
263
|
}
|
|
251
264
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which upstream hops are allowed to speak for a client (ADR-0019).
|
|
3
|
+
*
|
|
4
|
+
* X-Forwarded-For is written by whoever sends it. Reading it unconditionally
|
|
5
|
+
* lets any client choose its own rate-limit bucket, and - worse - choose
|
|
6
|
+
* SOMEONE ELSE'S, which is a starvation primitive against a third party. So
|
|
7
|
+
* the forwarding headers are believed only when the raw socket peer is a proxy
|
|
8
|
+
* the operator has explicitly declared.
|
|
9
|
+
*
|
|
10
|
+
* Configured by TINA4_TRUSTED_PROXIES: comma-separated exact addresses and/or
|
|
11
|
+
* CIDR ranges, IPv4 and IPv6, e.g. "10.0.0.0/8, 192.168.1.5, ::1, fd00::/8".
|
|
12
|
+
* Empty or unset means trust NOTHING, which is the secure default.
|
|
13
|
+
*
|
|
14
|
+
* Zero-dependency: node: has no CIDR matcher, so the packing and prefix
|
|
15
|
+
* comparison are done here over plain byte arrays.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Log } from "./logger.js";
|
|
19
|
+
|
|
20
|
+
/** [packed network address, prefix bits] */
|
|
21
|
+
type Network = [Uint8Array, number];
|
|
22
|
+
|
|
23
|
+
// Parsed TINA4_TRUSTED_PROXIES, cached on the raw env string so a change is
|
|
24
|
+
// picked up but the parse does not run per request.
|
|
25
|
+
let cachedRaw: string | null = null;
|
|
26
|
+
let cachedNetworks: Network[] = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strip the decorations a peer address can arrive with: the "[::1]" bracket
|
|
30
|
+
* form and an IPv6 zone id ("fe80::1%eth0").
|
|
31
|
+
*/
|
|
32
|
+
function normalise(value: string): string {
|
|
33
|
+
let out = value.trim();
|
|
34
|
+
if (out.startsWith("[")) {
|
|
35
|
+
const close = out.indexOf("]");
|
|
36
|
+
if (close !== -1) out = out.slice(1, close);
|
|
37
|
+
}
|
|
38
|
+
const pct = out.indexOf("%");
|
|
39
|
+
if (pct !== -1) out = out.slice(0, pct);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseIpv4(value: string): Uint8Array | null {
|
|
44
|
+
const parts = value.split(".");
|
|
45
|
+
if (parts.length !== 4) return null;
|
|
46
|
+
const bytes = new Uint8Array(4);
|
|
47
|
+
for (let i = 0; i < 4; i++) {
|
|
48
|
+
const part = parts[i];
|
|
49
|
+
// Reject "", "01", "1e2", "+1" and anything out of range. A permissive
|
|
50
|
+
// parse here would let "10.0.0.01" or "10.0.0.1x" through as a match.
|
|
51
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
52
|
+
const n = Number(part);
|
|
53
|
+
if (n > 255) return null;
|
|
54
|
+
bytes[i] = n;
|
|
55
|
+
}
|
|
56
|
+
return bytes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseIpv6(value: string): Uint8Array | null {
|
|
60
|
+
const halves = value.split("::");
|
|
61
|
+
if (halves.length > 2) return null;
|
|
62
|
+
|
|
63
|
+
const expand = (chunk: string): string[] => (chunk === "" ? [] : chunk.split(":"));
|
|
64
|
+
const head = expand(halves[0]);
|
|
65
|
+
const tail = halves.length === 2 ? expand(halves[1]) : [];
|
|
66
|
+
|
|
67
|
+
// A trailing IPv4 literal (::ffff:10.0.0.1) occupies the last two groups.
|
|
68
|
+
const groups: string[] = [];
|
|
69
|
+
const consume = (source: string[], into: string[]): boolean => {
|
|
70
|
+
for (let i = 0; i < source.length; i++) {
|
|
71
|
+
const part = source[i];
|
|
72
|
+
if (part.includes(".")) {
|
|
73
|
+
if (i !== source.length - 1) return false;
|
|
74
|
+
const v4 = parseIpv4(part);
|
|
75
|
+
if (!v4) return false;
|
|
76
|
+
into.push(((v4[0] << 8) | v4[1]).toString(16), ((v4[2] << 8) | v4[3]).toString(16));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return false;
|
|
80
|
+
into.push(part);
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const headGroups: string[] = [];
|
|
86
|
+
const tailGroups: string[] = [];
|
|
87
|
+
if (!consume(head, headGroups) || !consume(tail, tailGroups)) return null;
|
|
88
|
+
|
|
89
|
+
const missing = 8 - headGroups.length - tailGroups.length;
|
|
90
|
+
if (halves.length === 2) {
|
|
91
|
+
if (missing < 0) return null;
|
|
92
|
+
groups.push(...headGroups, ...Array(missing).fill("0"), ...tailGroups);
|
|
93
|
+
} else {
|
|
94
|
+
if (headGroups.length !== 8) return null;
|
|
95
|
+
groups.push(...headGroups);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const bytes = new Uint8Array(16);
|
|
99
|
+
for (let i = 0; i < 8; i++) {
|
|
100
|
+
const n = parseInt(groups[i], 16);
|
|
101
|
+
if (Number.isNaN(n)) return null;
|
|
102
|
+
bytes[i * 2] = (n >> 8) & 0xff;
|
|
103
|
+
bytes[i * 2 + 1] = n & 0xff;
|
|
104
|
+
}
|
|
105
|
+
return bytes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pack an address to its bytes, unmapping IPv4-in-IPv6.
|
|
110
|
+
*
|
|
111
|
+
* A peer arriving as ::ffff:10.0.0.1 must match an allow-list entry of
|
|
112
|
+
* 10.0.0.0/8 - dual-stack listeners hand out the mapped form routinely, and
|
|
113
|
+
* Node's socket.remoteAddress is a common source of it.
|
|
114
|
+
*/
|
|
115
|
+
export function packAddress(value: string): Uint8Array | null {
|
|
116
|
+
const address = normalise(value);
|
|
117
|
+
if (address === "") return null;
|
|
118
|
+
const packed = address.includes(":") ? parseIpv6(address) : parseIpv4(address);
|
|
119
|
+
if (!packed) return null;
|
|
120
|
+
if (packed.length === 16) {
|
|
121
|
+
let mapped = true;
|
|
122
|
+
for (let i = 0; i < 10; i++) {
|
|
123
|
+
if (packed[i] !== 0) { mapped = false; break; }
|
|
124
|
+
}
|
|
125
|
+
if (mapped && packed[10] === 0xff && packed[11] === 0xff) {
|
|
126
|
+
return packed.slice(12);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return packed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseNetwork(entry: string): Network | null {
|
|
133
|
+
let address = entry;
|
|
134
|
+
let bits: number | null = null;
|
|
135
|
+
|
|
136
|
+
const slash = entry.lastIndexOf("/");
|
|
137
|
+
if (slash !== -1) {
|
|
138
|
+
address = entry.slice(0, slash).trim();
|
|
139
|
+
const suffix = entry.slice(slash + 1).trim();
|
|
140
|
+
if (!/^\d{1,3}$/.test(suffix)) return null;
|
|
141
|
+
bits = Number(suffix);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const packed = packAddress(address);
|
|
145
|
+
if (!packed) return null;
|
|
146
|
+
|
|
147
|
+
const maxBits = packed.length * 8;
|
|
148
|
+
if (bits === null) bits = maxBits;
|
|
149
|
+
if (bits < 0 || bits > maxBits) return null;
|
|
150
|
+
return [packed, bits];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Do the first `bits` bits of two packed addresses agree? */
|
|
154
|
+
function prefixMatches(candidate: Uint8Array, network: Uint8Array, bits: number): boolean {
|
|
155
|
+
const wholeBytes = Math.floor(bits / 8);
|
|
156
|
+
for (let i = 0; i < wholeBytes; i++) {
|
|
157
|
+
if (candidate[i] !== network[i]) return false;
|
|
158
|
+
}
|
|
159
|
+
const remainder = bits % 8;
|
|
160
|
+
if (remainder === 0) return true;
|
|
161
|
+
const mask = (0xff << (8 - remainder)) & 0xff;
|
|
162
|
+
return (candidate[wholeBytes] & mask) === (network[wholeBytes] & mask);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The configured trusted-proxy networks, parsed once per distinct config value.
|
|
167
|
+
*/
|
|
168
|
+
export function trustedProxyNetworks(): Network[] {
|
|
169
|
+
const raw = process.env.TINA4_TRUSTED_PROXIES ?? "";
|
|
170
|
+
if (raw === cachedRaw) return cachedNetworks;
|
|
171
|
+
|
|
172
|
+
const networks: Network[] = [];
|
|
173
|
+
for (const rawEntry of raw.split(",")) {
|
|
174
|
+
const entry = normalise(rawEntry);
|
|
175
|
+
if (entry === "") continue;
|
|
176
|
+
|
|
177
|
+
const parsed = parseNetwork(entry);
|
|
178
|
+
if (!parsed) {
|
|
179
|
+
// Loud, and exactly once per distinct config value (the cache below
|
|
180
|
+
// means this parse runs once). A silently-skipped entry would leave a
|
|
181
|
+
// real proxy untrusted, which looks like the app over-limiting every
|
|
182
|
+
// client - a very expensive typo to debug.
|
|
183
|
+
Log.error(
|
|
184
|
+
`TINA4_TRUSTED_PROXIES: ignoring invalid entry '${entry}' - ` +
|
|
185
|
+
"expected an IP address or CIDR range, e.g. 10.0.0.0/8 or 192.168.1.5",
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
networks.push(parsed);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
cachedRaw = raw;
|
|
193
|
+
cachedNetworks = networks;
|
|
194
|
+
return networks;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Is this address a configured trusted proxy? */
|
|
198
|
+
export function isTrustedProxy(address: string): boolean {
|
|
199
|
+
const networks = trustedProxyNetworks();
|
|
200
|
+
if (networks.length === 0 || !address) return false;
|
|
201
|
+
|
|
202
|
+
const packed = packAddress(address);
|
|
203
|
+
if (!packed) return false;
|
|
204
|
+
|
|
205
|
+
for (const [network, bits] of networks) {
|
|
206
|
+
if (network.length === packed.length && prefixMatches(packed, network, bits)) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Reset the parsed cache. Test hook - config normally changes only at boot. */
|
|
214
|
+
export function resetTrustedProxyCache(): void {
|
|
215
|
+
cachedRaw = null;
|
|
216
|
+
cachedNetworks = [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the client IP, honouring forwarding headers ONLY behind a trusted proxy.
|
|
221
|
+
*
|
|
222
|
+
* Within the chain the RIGHTMOST entry that is not itself a trusted proxy wins.
|
|
223
|
+
* Taking the leftmost would be no safer than trusting the header outright: a
|
|
224
|
+
* client can prepend its own hop, and the proxy appends rather than replaces.
|
|
225
|
+
* This is the algorithm Rack uses (Rack::Request#ip).
|
|
226
|
+
*/
|
|
227
|
+
export function resolveClientIp(
|
|
228
|
+
headers: Record<string, string | string[] | undefined>,
|
|
229
|
+
peer: string,
|
|
230
|
+
): string {
|
|
231
|
+
if (!peer || !isTrustedProxy(peer)) return peer;
|
|
232
|
+
|
|
233
|
+
const rawForwarded = headers["x-forwarded-for"];
|
|
234
|
+
// A repeated header arrives as an array. Joining keeps every hop in order;
|
|
235
|
+
// reading only element 0 would silently drop the rest of the chain.
|
|
236
|
+
const forwarded = Array.isArray(rawForwarded) ? rawForwarded.join(",") : rawForwarded;
|
|
237
|
+
if (forwarded && forwarded.trim() !== "") {
|
|
238
|
+
const hops = forwarded.split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
239
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
240
|
+
if (!isTrustedProxy(hops[i])) return hops[i];
|
|
241
|
+
}
|
|
242
|
+
// Every hop is itself a trusted proxy - the peer is the best we have.
|
|
243
|
+
return peer;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rawRealIp = headers["x-real-ip"];
|
|
247
|
+
const realIp = (Array.isArray(rawRealIp) ? rawRealIp[0] : rawRealIp)?.trim();
|
|
248
|
+
return realIp || peer;
|
|
249
|
+
}
|
|
@@ -45,10 +45,24 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
45
45
|
url: string;
|
|
46
46
|
body: unknown;
|
|
47
47
|
ip: string;
|
|
48
|
+
/**
|
|
49
|
+
* Raw socket peer address - NEVER honours X-Forwarded-For (which any
|
|
50
|
+
* caller can spoof), so it can be trusted for security decisions.
|
|
51
|
+
* Empty for in-process / synthetic requests. Parity with Python's
|
|
52
|
+
* request.remote_ip and PHP's Request::$remoteIp.
|
|
53
|
+
*/
|
|
54
|
+
remoteIp: string;
|
|
48
55
|
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
49
56
|
cookies: Record<string, string>;
|
|
50
57
|
contentType: string;
|
|
51
|
-
|
|
58
|
+
/**
|
|
59
|
+
* NULL when the session backend was unusable for this request (ADR-0021).
|
|
60
|
+
* The request path logs the failure and degrades rather than 500-ing, so a
|
|
61
|
+
* request really can arrive without a session and the type has to say so -
|
|
62
|
+
* `req.session?.get(...)`. Parity with Python, where `request.session` is
|
|
63
|
+
* `None` on the same path.
|
|
64
|
+
*/
|
|
65
|
+
session: Tina4Session | null;
|
|
52
66
|
user?: Record<string, unknown>;
|
|
53
67
|
/** Get a specific header value by name (case-insensitive). */
|
|
54
68
|
header(name: string): string | undefined;
|
|
@@ -194,8 +208,15 @@ export type Middleware = (
|
|
|
194
208
|
) => void | Promise<void>;
|
|
195
209
|
|
|
196
210
|
/**
|
|
197
|
-
* A
|
|
198
|
-
*
|
|
211
|
+
* A class-based middleware: static `beforeX` / `afterX` hooks discovered by
|
|
212
|
+
* `MiddlewareRunner`. The hooks are a NAMING CONVENTION, not an interface —
|
|
213
|
+
* exactly as in Python/PHP/Ruby — so this is deliberately just "a class".
|
|
214
|
+
*/
|
|
215
|
+
export type MiddlewareClass = abstract new (...args: never[]) => unknown;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A route middleware entry: a middleware function, a middleware CLASS, or a
|
|
219
|
+
* string spec resolved by the router to a built-in middleware.
|
|
199
220
|
*
|
|
200
221
|
* String forms (parity with Python/PHP/Ruby):
|
|
201
222
|
* "ResponseCache" → responseCache() with the default/env TTL
|
|
@@ -203,9 +224,12 @@ export type Middleware = (
|
|
|
203
224
|
*
|
|
204
225
|
* The router resolves string specs to middleware functions when the route
|
|
205
226
|
* runs, so callers can register a response-cache middleware without importing
|
|
206
|
-
* `responseCache`.
|
|
227
|
+
* `responseCache`. A CLASS runs its beforeX/afterX hooks through the same
|
|
228
|
+
* `MiddlewareRunner` as global middleware — Python and PHP already ran
|
|
229
|
+
* per-route class hooks; Node used to invoke every spec as `mw(req, res, next)`
|
|
230
|
+
* and a class was therefore inert.
|
|
207
231
|
*/
|
|
208
|
-
export type MiddlewareSpec = Middleware | string;
|
|
232
|
+
export type MiddlewareSpec = Middleware | MiddlewareClass | string;
|
|
209
233
|
|
|
210
234
|
/**
|
|
211
235
|
* Handler for WebSocket routes.
|
|
@@ -82,6 +82,20 @@ type EventHandler = (...args: unknown[]) => void;
|
|
|
82
82
|
/**
|
|
83
83
|
* Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
84
84
|
*/
|
|
85
|
+
/**
|
|
86
|
+
* Build an RFC 6455 close frame carrying a status code and optional reason.
|
|
87
|
+
*
|
|
88
|
+
* The code is a big-endian uint16 in the first two payload bytes (s5.5.1), so
|
|
89
|
+
* a peer that only reads the code still gets a valid one.
|
|
90
|
+
*/
|
|
91
|
+
export function buildCloseFrame(code: number, reason: string = ""): Buffer {
|
|
92
|
+
const reasonBytes = Buffer.from(reason, "utf-8");
|
|
93
|
+
const payload = Buffer.alloc(2 + reasonBytes.length);
|
|
94
|
+
payload.writeUInt16BE(code, 0);
|
|
95
|
+
reasonBytes.copy(payload, 2);
|
|
96
|
+
return buildFrame(OP_CLOSE, payload);
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export function computeAcceptKey(key: string): string {
|
|
86
100
|
return createHash("sha1")
|
|
87
101
|
.update(key + MAGIC_STRING)
|
|
@@ -957,6 +971,35 @@ class WsRouteManager {
|
|
|
957
971
|
if (state.trackerId && this.onRemove) this.onRemove(state.trackerId);
|
|
958
972
|
}
|
|
959
973
|
|
|
974
|
+
/**
|
|
975
|
+
* Close every open route connection with an RFC 6455 status code.
|
|
976
|
+
*
|
|
977
|
+
* Used by graceful shutdown with 1001 "going away", which is the code RFC
|
|
978
|
+
* 6455 s7.4.1 defines for exactly this case ("a server going down"). A
|
|
979
|
+
* client that is told 1001 can reconnect on a schedule; a socket that just
|
|
980
|
+
* vanishes looks like a network fault and produces an error instead.
|
|
981
|
+
*
|
|
982
|
+
* Best-effort per connection: a dead socket is skipped, never aborting the
|
|
983
|
+
* rest. Returns how many were signalled.
|
|
984
|
+
*/
|
|
985
|
+
closeAll(code: number = CLOSE_GOING_AWAY, reason: string = "server shutting down"): number {
|
|
986
|
+
const frame = buildCloseFrame(code, reason);
|
|
987
|
+
let closed = 0;
|
|
988
|
+
for (const state of [...this.connections.values()]) {
|
|
989
|
+
if (state.closed) continue;
|
|
990
|
+
state.closed = true;
|
|
991
|
+
try {
|
|
992
|
+
state.socket.write(frame);
|
|
993
|
+
state.socket.end();
|
|
994
|
+
closed++;
|
|
995
|
+
} catch {
|
|
996
|
+
/* already gone */
|
|
997
|
+
}
|
|
998
|
+
this.remove(state.conn.id);
|
|
999
|
+
}
|
|
1000
|
+
return closed;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
960
1003
|
/** Send a text frame to one connection (best-effort). */
|
|
961
1004
|
sendTo(id: string, message: string): void {
|
|
962
1005
|
const state = this.connections.get(id);
|
|
@@ -1266,6 +1309,29 @@ class DevReloadWsManager {
|
|
|
1266
1309
|
return this.clients.size;
|
|
1267
1310
|
}
|
|
1268
1311
|
|
|
1312
|
+
/**
|
|
1313
|
+
* Close every open dev-reload socket with an RFC 6455 status code (1001
|
|
1314
|
+
* "going away" on shutdown). The browser client reconnects on a schedule
|
|
1315
|
+
* when it is told the server went away, rather than reporting an error.
|
|
1316
|
+
* Best-effort per socket. Returns how many were signalled.
|
|
1317
|
+
*/
|
|
1318
|
+
closeAll(code: number = CLOSE_GOING_AWAY, reason: string = "server shutting down"): number {
|
|
1319
|
+
const frame = buildCloseFrame(code, reason);
|
|
1320
|
+
let closed = 0;
|
|
1321
|
+
for (const client of [...this.clients]) {
|
|
1322
|
+
try {
|
|
1323
|
+
client.socket.write(frame);
|
|
1324
|
+
client.socket.end();
|
|
1325
|
+
closed++;
|
|
1326
|
+
} catch {
|
|
1327
|
+
/* already gone */
|
|
1328
|
+
}
|
|
1329
|
+
this.clients.delete(client);
|
|
1330
|
+
if (client.trackerId && this.onRemove) this.onRemove(client.trackerId);
|
|
1331
|
+
}
|
|
1332
|
+
return closed;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1269
1335
|
/**
|
|
1270
1336
|
* Accept a WebSocket upgrade on `/__dev_reload` and hold the socket open.
|
|
1271
1337
|
*
|