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.
Files changed (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. 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, same as the live dispatcher.
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), mirroring the live tail.
247
- if (globalMiddleware.length > 0) {
248
- await MiddlewareRunner.runAfter(globalMiddleware, req, res);
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
- session: Tina4Session;
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 route middleware entry: either a middleware function, or a string spec
198
- * resolved by the router to a built-in middleware.
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
  *