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
@@ -17,7 +17,7 @@ import type { Router } from "./router.js";
17
17
  import type { RouteHandler, Tina4Request } from "./types.js";
18
18
  import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
- import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
20
+ import { quickMetrics, fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
22
  import { getDefaultDevServer, mcpEnabled, isRequestAllowed } from "./mcp.js";
23
23
  import { timingSafeEqual } from "node:crypto";
@@ -508,8 +508,30 @@ export class DevAdmin {
508
508
  { method: "POST", pattern: "/__dev/api/gallery/deploy", handler: handleGalleryDeploy(router) },
509
509
  // Metrics
510
510
  { method: "GET", pattern: "/__dev/api/metrics", handler: (_req: any, res: any) => { res.json(quickMetrics()); } },
511
- { method: "GET", pattern: "/__dev/api/metrics/full", handler: (_req: any, res: any) => { res.json(fullAnalysis()); } },
512
- { method: "GET", pattern: "/__dev/api/metrics/file", handler: (req: any, res: any) => { const url = new URL(req.url ?? "/", "http://localhost"); const p = (url.searchParams.get("path") || "").toString(); res.json(fileDetail(p)); } },
511
+ // No fallback (ADR-0002): a missing or stale CLI is a 503 naming the
512
+ // install command, never zeros that read as a healthy codebase.
513
+ { method: "GET", pattern: "/__dev/api/metrics/full", handler: (_req: any, res: any) => {
514
+ try { res.json(fullAnalysis()); }
515
+ catch (e) {
516
+ if (e instanceof MetricsEngineError) { res.status(503).json({ error: e.message }); return; }
517
+ throw e;
518
+ }
519
+ } },
520
+ { method: "GET", pattern: "/__dev/api/metrics/file", handler: (req: any, res: any) => {
521
+ const url = new URL(req.url ?? "/", "http://localhost");
522
+ const p = (url.searchParams.get("path") || "").toString();
523
+ try { res.json(fileDetail(p)); }
524
+ catch (e) {
525
+ if (e instanceof MetricsEngineError) {
526
+ // A bad path is the caller's mistake (404); anything else is the
527
+ // engine being unavailable (503).
528
+ const badPath = /no such file|not a file|needs a path/.test(e.message);
529
+ res.status(badPath ? 404 : 503).json({ error: e.message });
530
+ return;
531
+ }
532
+ throw e;
533
+ }
534
+ } },
513
535
  // GraphQL schema introspection (auto-discovers registered ORM models)
514
536
  { method: "GET", pattern: "/__dev/api/graphql/schema", handler: async (_req: any, res: any) => {
515
537
  try {
@@ -755,7 +777,7 @@ function handleStatus(router: Router): RouteHandler {
755
777
  try {
756
778
  const { getAdapter } = await import("../../orm/src/index.js");
757
779
  const db = getAdapter();
758
- dbTableCount = db.tables().length;
780
+ dbTableCount = db.getTables().length;
759
781
  } catch { /* no database connected */ }
760
782
  res.json({
761
783
  nodeVersion: process.version,
@@ -861,7 +883,7 @@ const handleSystem: RouteHandler = async (_req, res) => {
861
883
  try {
862
884
  const { getAdapter } = await import("../../orm/src/index.js");
863
885
  const db = getAdapter();
864
- dbTableCount = db.tables().length;
886
+ dbTableCount = db.getTables().length;
865
887
  dbConnected = true;
866
888
  } catch { /* no database connected */ }
867
889
  // Respond in both the shared-JS format and the Node-specific format
@@ -944,12 +966,31 @@ function mapQueueJob(job: any, topic: string, status: string) {
944
966
  };
945
967
  }
946
968
 
969
+ /**
970
+ * Read every `*.queue-data` record in one queue directory, oldest name first.
971
+ * Corrupt files are skipped, exactly as LiteBackend.size() skips them, so the
972
+ * list and the count always see the same set.
973
+ */
974
+ function readQueueDir(dir: string, topic: string, status: string) {
975
+ if (!existsSync(dir)) return [];
976
+ const jobs: Array<ReturnType<typeof mapQueueJob>> = [];
977
+ for (const filename of readdirSync(dir).sort()) {
978
+ if (!filename.endsWith(".queue-data")) continue; // skips failed/ + reserved/ subdirs
979
+ try {
980
+ jobs.push(mapQueueJob(JSON.parse(readFileSync(join(dir, filename), "utf-8")), topic, status));
981
+ } catch {
982
+ // skip corrupt files
983
+ }
984
+ }
985
+ return jobs;
986
+ }
987
+
947
988
  const handleQueue: RouteHandler = async (req, res) => {
948
989
  const url = new URL(req.url ?? "/", "http://localhost");
949
990
  const topic = url.searchParams.get("topic") ?? "default";
950
991
  const statusFilter = url.searchParams.get("status") ?? "";
951
992
  try {
952
- const { Queue } = await import("./queue.js");
993
+ const { Queue, queueBasePath } = await import("./queue.js");
953
994
  const queue = new Queue({ topic });
954
995
 
955
996
  const stats = {
@@ -959,31 +1000,35 @@ const handleQueue: RouteHandler = async (req, res) => {
959
1000
  reserved: queue.size("reserved"),
960
1001
  };
961
1002
 
962
- // Jobs by status parity with Python's _api_queue: list PENDING by reading
963
- // the on-disk queue files directly (data/queue/<topic>/*.queue-data), then
964
- // fold in the file-backed failed() + deadLetters() jobs. This is why real
965
- // persisted jobs now show even though DevQueue (in-memory) is empty.
1003
+ // The job list and the stats above MUST describe the same set of jobs.
1004
+ // Two defects broke that (both measured 2026-08-05, see the regression test
1005
+ // test/devAdminQueuePath.test.ts):
1006
+ //
1007
+ // 1. The directory. This scanned a hardcoded cwd/data/queue/<topic> while
1008
+ // Queue.size() reads queueBasePath() — so with TINA4_QUEUE_PATH set the
1009
+ // panel listed one directory and counted another (measured: 100 stale
1010
+ // jobs listed, 12 real jobs counted).
1011
+ // 2. The set. Reserved jobs were counted by stats.reserved but never
1012
+ // listed, and a failed-but-retryable job — which lives in the PENDING
1013
+ // directory with status "pending" — was listed twice: once by the
1014
+ // directory scan and again by queue.failed(), which re-reads the same
1015
+ // files. Each job now appears exactly once, in the bucket its own stat
1016
+ // counts it in: pending -> the queue dir, reserved -> reserved/,
1017
+ // failed/dead -> failed/ (the directory size("failed") counts, read the
1018
+ // same way — queue.deadLetters() applies THIS queue's maxRetries, which
1019
+ // the dev admin cannot know, so it can return fewer jobs than the panel
1020
+ // is showing a count for).
1021
+ const topicDir = join(queueBasePath(), topic);
966
1022
  const jobs: Array<ReturnType<typeof mapQueueJob>> = [];
967
1023
 
968
1024
  if (!statusFilter || statusFilter === "pending") {
969
- const queueDir = join(process.cwd(), "data", "queue", topic);
970
- if (existsSync(queueDir)) {
971
- for (const filename of readdirSync(queueDir).sort()) {
972
- if (!filename.endsWith(".queue-data")) continue; // skips failed/ + reserved/ subdirs
973
- try {
974
- const job = JSON.parse(readFileSync(join(queueDir, filename), "utf-8"));
975
- jobs.push(mapQueueJob(job, topic, "pending"));
976
- } catch {
977
- // skip corrupt files
978
- }
979
- }
980
- }
1025
+ jobs.push(...readQueueDir(topicDir, topic, "pending"));
981
1026
  }
982
- if (!statusFilter || statusFilter === "failed") {
983
- for (const j of queue.failed()) jobs.push(mapQueueJob(j, topic, "failed"));
1027
+ if (!statusFilter || statusFilter === "reserved") {
1028
+ jobs.push(...readQueueDir(join(topicDir, "reserved"), topic, "reserved"));
984
1029
  }
985
- if (!statusFilter || statusFilter === "dead") {
986
- for (const j of queue.deadLetters()) jobs.push(mapQueueJob(j, topic, "dead_letter"));
1030
+ if (!statusFilter || statusFilter === "failed" || statusFilter === "dead") {
1031
+ jobs.push(...readQueueDir(join(topicDir, "failed"), topic, "dead_letter"));
987
1032
  }
988
1033
 
989
1034
  res.json({ stats, jobs });
@@ -996,14 +1041,16 @@ const handleQueue: RouteHandler = async (req, res) => {
996
1041
  }
997
1042
  };
998
1043
 
999
- const handleQueueTopics: RouteHandler = (_req, res) => {
1044
+ const handleQueueTopics: RouteHandler = async (_req, res) => {
1000
1045
  try {
1001
- // Prefer on-disk file-queue topics under ./data/queue; fall back to "default".
1046
+ // On-disk file-queue topics under the REAL store (TINA4_QUEUE_PATH, else
1047
+ // data/queue); fall back to "default".
1002
1048
  // This module is ESM ("type": "module"), so a bare require() is a ReferenceError
1003
1049
  // that the catch below swallowed - the endpoint always returned ["default"] and
1004
1050
  // never listed a real topic. node:fs/node:path are already imported at the top of
1005
1051
  // this file, so the "avoids a hard dep" rationale for the require() never held.
1006
- const queueDir = join(process.cwd(), "data", "queue");
1052
+ const { queueBasePath } = await import("./queue.js");
1053
+ const queueDir = queueBasePath();
1007
1054
  let topics: string[] = [];
1008
1055
  if (existsSync(queueDir)) {
1009
1056
  topics = readdirSync(queueDir)
@@ -1128,7 +1175,7 @@ const handleTable: RouteHandler = async (req, res) => {
1128
1175
  try {
1129
1176
  const { getAdapter } = await import("../../orm/src/index.js");
1130
1177
  const db = getAdapter();
1131
- const columns = db.columns(name);
1178
+ const columns = db.getColumns(name);
1132
1179
  if (!columns.length) {
1133
1180
  res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
1134
1181
  return;
@@ -1151,7 +1198,7 @@ const handleTables: RouteHandler = async (_req, res) => {
1151
1198
  try {
1152
1199
  const { getAdapter } = await import("../../orm/src/index.js");
1153
1200
  const db = getAdapter();
1154
- const tables = db.tables();
1201
+ const tables = db.getTables();
1155
1202
  res.json({ tables });
1156
1203
  } catch {
1157
1204
  res.json({ tables: [], message: "Database not connected" });
@@ -1180,7 +1227,7 @@ const handleSeed: RouteHandler = async (req, res) => {
1180
1227
  const { seedTable } = orm;
1181
1228
  // A shared FakeData seeds the RNG so a `seed` makes the run reproducible.
1182
1229
  const fake = new orm.FakeData(seed);
1183
- const columns = db.columns(table);
1230
+ const columns = db.getColumns(table);
1184
1231
  if (!columns.length) {
1185
1232
  res.json({ error: `Table '${table}' not found or has no columns` });
1186
1233
  return;
@@ -15,8 +15,6 @@ import { join } from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
 
17
17
  import type { SendResult, EmailMessage } from "./messenger.js";
18
- import { Messenger } from "./messenger.js";
19
- import { isTruthy } from "./dotenv.js";
20
18
 
21
19
  // ── DevMailbox ───────────────────────────────────────────────
22
20
 
@@ -38,20 +36,35 @@ export class DevMailbox {
38
36
 
39
37
  /**
40
38
  * Capture an email to the dev mailbox instead of sending it.
39
+ *
40
+ * The parameter order MATCHES Messenger.send() on purpose. It did not before:
41
+ * send()'s 5th positional was `text` and capture()'s was `cc`, so the same call
42
+ * meant different things depending on which door it came through -- that mismatch
43
+ * IS nodejs#42.
44
+ *
45
+ * BREAKING: `text` is now the 5th positional. A caller passing cc positionally
46
+ * must move it. Aligning the two signatures is the fix; leaving them apart would
47
+ * preserve the bug.
41
48
  */
42
49
  capture(
43
50
  to: string | string[],
44
51
  subject: string,
45
52
  body: string,
46
53
  html: boolean = false,
47
- cc: string[] = [],
48
- bcc: string[] = [],
54
+ text?: string,
55
+ cc: string | string[] = [],
56
+ bcc: string | string[] = [],
49
57
  replyTo?: string,
50
58
  attachments: string[] = [],
51
59
  from?: string,
52
60
  ): SendResult {
53
61
  const id = randomUUID();
54
62
  const toList = Array.isArray(to) ? to : [to];
63
+ // Normalised HERE, at the boundary, so a message is well formed however it
64
+ // arrived. A dev mailbox that stores a malformed message and reports success
65
+ // defeats its own purpose -- it exists to show you what you WOULD have sent.
66
+ const ccList = Array.isArray(cc) ? cc : (cc ? [cc] : []);
67
+ const bccList = Array.isArray(bcc) ? bcc : (bcc ? [bcc] : []);
55
68
  const now = new Date().toISOString();
56
69
 
57
70
  const message: EmailMessage = {
@@ -59,11 +72,12 @@ export class DevMailbox {
59
72
  type: "outbox",
60
73
  from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
61
74
  to: toList,
62
- cc,
63
- bcc,
75
+ cc: ccList,
76
+ bcc: bccList,
64
77
  reply_to: replyTo,
65
78
  subject,
66
79
  body,
80
+ text,
67
81
  html,
68
82
  attachments,
69
83
  date: now,
@@ -280,41 +294,3 @@ export class DevMailbox {
280
294
  }
281
295
 
282
296
  // ── Factory ──────────────────────────────────────────────────
283
-
284
- /**
285
- * Create a Messenger or DevMailbox based on the environment.
286
- *
287
- * Returns DevMailbox when:
288
- * - TINA4_DEBUG is "true", OR
289
- * - No TINA4_MAIL_HOST is configured
290
- *
291
- * Returns a real Messenger otherwise (SMTP configured + not debug mode).
292
- *
293
- * This follows the factory pattern from PHP's MessengerFactory.
294
- */
295
- export function createMessenger(): Messenger | DevMailbox {
296
- const debug = process.env.TINA4_DEBUG;
297
- const smtpHost = process.env.TINA4_MAIL_HOST;
298
-
299
- // Production = NOT debug mode AND NODE_ENV is "production".
300
- // Derived here (was previously referenced undefined → ReferenceError).
301
- const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
302
-
303
- // Force dev mode when TINA4_DEBUG is truthy
304
- if (isTruthy(debug)) {
305
- return new DevMailbox();
306
- }
307
-
308
- // No SMTP configured — must use dev mailbox
309
- if (!smtpHost) {
310
- return new DevMailbox();
311
- }
312
-
313
- // Non-production environment — use dev mailbox
314
- if (!isProd) {
315
- return new DevMailbox();
316
- }
317
-
318
- // Production with SMTP configured — use real Messenger
319
- return new Messenger();
320
- }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The dispatch pipeline: the concerns of `dispatch`, named and extracted.
3
+ *
4
+ * `dispatch` was a 485-line closure at cyclomatic complexity 65 against a
5
+ * ceiling of 10, on the path of every request, nested inside `startServer`
6
+ * (which is why that measured 45 as well). These are its concerns as
7
+ * standalone functions, so each can be read and tested without standing up a
8
+ * server.
9
+ *
10
+ * PROLOGUE_STAGES run before anything else, in order. They are extracted FIRST
11
+ * because they close over nothing from `startServer` - only the raw
12
+ * request/response - so no context object is needed for them at all. The later
13
+ * stages need `router`, `staticDir`, `port` and `middleware`, and follow.
14
+ * `sessionAutoStart` is the one prologue stage that runs INSIDE the dispatch
15
+ * try-block, so a TINA4_SESSION_STRICT refusal renders a 500 like every other
16
+ * request error instead of rejecting `dispatch` into an unhandled rejection
17
+ * that takes the worker down (ADR-0021; parity with Python, where the raise
18
+ * leaves the request path and the ASGI server turns it into a 500).
19
+ *
20
+ * Ordering here is BEHAVIOUR, not taste:
21
+ * * `headStripIntercept` MUST run before anything can write. Node streams its
22
+ * response, so there is no single exit point to strip at - the interception
23
+ * IS the mechanism (ADR-0011: the CONTRACT is the outcome, and Ruby and
24
+ * Python satisfy it by stripping late at their single return instead).
25
+ * * `sessionAutoStart` wraps `end` after that, so its save-and-set-cookie
26
+ * runs on the real `end` rather than on the HEAD interceptor's.
27
+ *
28
+ * @see tina4-ruby/lib/tina4/dispatch_pipeline.rb - the same extraction, and
29
+ * the source of the stage-list-as-data pattern.
30
+ */
31
+ import type { IncomingMessage, ServerResponse } from "node:http";
32
+ import type { Tina4Request } from "./types.js";
33
+ import type { Session as SessionInstance } from "./session.js";
34
+ import { Log } from "./logger.js";
35
+
36
+ /**
37
+ * The prologue, in order. Exported as DATA so the pipeline can be asserted and
38
+ * compared across frameworks without reading an implementation.
39
+ */
40
+ export const PROLOGUE_STAGES = [
41
+ "resetRequestCaches",
42
+ "headStripIntercept",
43
+ "sessionAutoStart",
44
+ ] as const;
45
+
46
+ /**
47
+ * After the prologue, before a route is looked up.
48
+ *
49
+ * `wrapResponseEnd` MUST come before the global pass: it installs the end()
50
+ * wrapper that injects the dev toolbar and captures the request, and a
51
+ * middleware that short-circuits still has to be captured.
52
+ */
53
+ export const REQUEST_STAGES = [
54
+ "blockAiPortReload",
55
+ "wrapResponseEnd",
56
+ "runGlobalMiddlewarePass",
57
+ ] as const;
58
+
59
+ /**
60
+ * A matched route, in order - and the order is BEHAVIOUR (ADR-0012):
61
+ * POST-MATCH globals -> auth gate -> the route's OWN middleware -> handler.
62
+ *
63
+ * The globals run BEFORE the gate so a rate limiter can throttle a brute-force
64
+ * login and an access log records the 401 - neither is possible if they only
65
+ * run on authenticated requests. The route's own middleware stays AFTER the
66
+ * gate, so middleware attached to a secured route never processes an
67
+ * unauthenticated request.
68
+ *
69
+ * `runGlobalMiddlewarePass` appears here AND in REQUEST_STAGES on purpose:
70
+ * one function, two phases. That split IS ADR-0012.
71
+ */
72
+ export const ROUTE_STAGES = [
73
+ "runGlobalMiddlewarePass",
74
+ "enforceRouteAuth",
75
+ "runRouteMiddlewares",
76
+ "invokeRouteHandler",
77
+ "renderIfTemplateRoute",
78
+ ] as const;
79
+
80
+ /**
81
+ * Nothing matched a route: the fallback chain, walked until one answers.
82
+ *
83
+ * Order is BEHAVIOUR: a template beats the landing page (so a project's own
84
+ * pages/index.twig wins at "/"), 405 beats static (a known path with the wrong
85
+ * method is not a missing file), and the 404 is terminal.
86
+ *
87
+ * This chain runs AFTER matching because routes beat files (ADR-0010): a file
88
+ * from a build step or a careless deploy must never shadow a reviewed route.
89
+ *
90
+ * server.ts holds the same order as an array of the real FUNCTIONS - that is
91
+ * what dispatch actually walks. dispatchPipeline.test.ts asserts the two agree,
92
+ * so this list cannot drift from the runner.
93
+ */
94
+ export const FALLBACK_STAGES = [
95
+ "serveTemplateFallback",
96
+ "serveLandingPage",
97
+ "serveMethodNotAllowed",
98
+ "serveStaticAsset",
99
+ "serveNotFound",
100
+ ] as const;
101
+
102
+ /** The catch arm. Everything above throws into this one. */
103
+ export const ERROR_STAGES = ["renderDispatchError"] as const;
104
+
105
+ /**
106
+ * Memoised import of the ORM's cache reset. Resolves once on first use;
107
+ * subsequent requests reuse the resolved module.
108
+ */
109
+ let _resetRequestCaches: Promise<(() => void) | null> | undefined;
110
+
111
+ /**
112
+ * Request-scoped DB query cache boundary.
113
+ *
114
+ * Clears the request-scoped cache on every live connection at the START of each
115
+ * request so it never serves rows across requests (persistent-mode connections
116
+ * are left alone). The ORM is loaded lazily and may be absent, so this is
117
+ * best-effort: a failure here must never break a request. Mirrors Python's
118
+ * dispatcher calling `Database.reset_request_caches()`.
119
+ */
120
+ export async function resetRequestCaches(): Promise<void> {
121
+ if (_resetRequestCaches === undefined) {
122
+ _resetRequestCaches = import("../../orm/src/index.js")
123
+ .then((orm) => orm.resetRequestCaches as () => void)
124
+ .catch(() => null);
125
+ }
126
+ try {
127
+ const reset = await _resetRequestCaches;
128
+ if (reset) reset();
129
+ } catch {
130
+ /* ORM not installed / cache unavailable — non-fatal */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * RFC 9110 s9.3.2: the server MUST NOT send content in a HEAD response.
136
+ *
137
+ * Intercepts `write` / `end` so every code path - an explicit `Router.head()`
138
+ * handler, the GET auto-fallback, 405 and 404 responses - drops its body.
139
+ * Content-Length is preserved when present, so cache validators, link checkers
140
+ * and monitoring probes still see the size the equivalent GET would have sent.
141
+ *
142
+ * No-op for any method other than HEAD.
143
+ *
144
+ * @param rawReq Node's incoming message, read for the method
145
+ * @param rawRes Node's server response, whose write/end are replaced in place
146
+ */
147
+ export function headStripIntercept(rawReq: IncomingMessage, rawRes: ServerResponse): void {
148
+ if ((rawReq.method ?? "GET").toUpperCase() !== "HEAD") return;
149
+
150
+ const origEnd = rawRes.end.bind(rawRes);
151
+ const origWrite = rawRes.write.bind(rawRes);
152
+ let accumulated = 0;
153
+
154
+ rawRes.write = ((chunk?: any, _enc?: any, cb?: any): boolean => {
155
+ if (chunk != null) {
156
+ accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
157
+ }
158
+ if (typeof cb === "function") cb();
159
+ return true;
160
+ }) as typeof rawRes.write;
161
+
162
+ rawRes.end = ((chunk?: any, _enc?: any, cb?: any): any => {
163
+ if (chunk != null && typeof chunk !== "function") {
164
+ accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
165
+ }
166
+ if (accumulated > 0 && !rawRes.headersSent && !rawRes.hasHeader("Content-Length")) {
167
+ rawRes.setHeader("Content-Length", String(accumulated));
168
+ }
169
+ const realCb = typeof chunk === "function" ? chunk : cb;
170
+ void origWrite; // referenced to keep tsc happy
171
+ return typeof realCb === "function" ? origEnd(realCb) : origEnd();
172
+ }) as typeof rawRes.end;
173
+ }
174
+
175
+ /**
176
+ * `Type: message` for a caught value, so an operator reading the log sees the
177
+ * REAL driver failure rather than an opaque wrapper. Mirrors the Python fix's
178
+ * `f"({type(e).__name__}): {e}"`.
179
+ */
180
+ function errorLabel(err: unknown): string {
181
+ return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
182
+ }
183
+
184
+ /**
185
+ * Auto-start the session: read the cookie, create the session, then save it and
186
+ * set the cookie when the response ends.
187
+ *
188
+ * The incoming cookie is read by the SAME configured name the write side emits
189
+ * (`TINA4_SESSION_NAME`, default `tina4_session`) via the shared
190
+ * `sessionCookieName()` resolver - otherwise a renamed cookie would be written
191
+ * but never read back and the session would silently never resume. A whole
192
+ * cookie pair is matched by its exact `name=` prefix (split on ";", trim,
193
+ * startsWith) so `tina4_session` never matches `tina4_session_foo=` nor a value
194
+ * mid-header. Parity with Python `core/server._init_session`.
195
+ *
196
+ * @param rawReq Node's incoming message, read for cookies and the proxy scheme
197
+ * @param rawRes Node's server response, whose `end` is wrapped
198
+ * @param req The Tina4 request the session is attached to
199
+ */
200
+ export async function sessionAutoStart(
201
+ rawReq: IncomingMessage,
202
+ rawRes: ServerResponse,
203
+ req: Tina4Request,
204
+ ): Promise<void> {
205
+ const { Session, buildSessionCookie, sessionCookieName, sessionStrictMode } =
206
+ await import("./session.js");
207
+ const cookieHeader = rawReq.headers.cookie ?? "";
208
+
209
+ const cookiePrefix = sessionCookieName() + "=";
210
+ let existingSid: string | undefined;
211
+ for (const part of cookieHeader.split(";")) {
212
+ const trimmed = part.trim();
213
+ if (trimmed.startsWith(cookiePrefix)) {
214
+ existingSid = trimmed.slice(cookiePrefix.length);
215
+ break;
216
+ }
217
+ }
218
+
219
+ // LOG LOUD, THEN DEGRADE (ADR-0021). Construction and start() sat outside
220
+ // every guard, so a backend that cannot be reached 500'd EVERY request
221
+ // instead of degrading: the database handler opens its connection and runs a
222
+ // PRAGMA in its constructor, and an unknown TINA4_SESSION_BACKEND throws by
223
+ // design. Session's own safeRead/safeWrite policy cannot cover either - both
224
+ // fail BEFORE there is an object to ask, which is also why strict mode has to
225
+ // be read from the module-level `sessionStrictMode()` here.
226
+ //
227
+ // An EMPTY session never reaches this catch. `start()` returns a fresh empty
228
+ // session for a first-time visitor and for a well-formed id the store has
229
+ // never heard of; both are ORDINARY, and logging them would fill the log with
230
+ // noise on every new visitor and bury the real outage - the same blindness
231
+ // this fix exists to cure.
232
+ let sess: SessionInstance;
233
+ try {
234
+ sess = new Session();
235
+ sess.start(existingSid);
236
+ } catch (err) {
237
+ Log.error(`Session unavailable for this request (${errorLabel(err)})`);
238
+ if (sessionStrictMode()) throw err;
239
+ (req as any).session = null;
240
+ return;
241
+ }
242
+ (req as any).session = sess;
243
+
244
+ const origEnd = rawRes.end.bind(rawRes);
245
+ // Finalise EXACTLY once. Under strict mode the save below re-throws, and the
246
+ // dispatch catch renders its 500 by calling end() again - without this the
247
+ // retry hits the still-dirty save, throws a second time out of the error
248
+ // renderer, and the worker dies on the way to reporting the failure.
249
+ let finalised = false;
250
+ rawRes.end = function (...args: any[]) {
251
+ if (finalised) return origEnd(...args);
252
+ finalised = true;
253
+
254
+ // Same policy as the start side above: loud, then degrade. Session.save()
255
+ // already logs a backend write failure and returns false without throwing,
256
+ // so anything arriving here is a failure OUTSIDE that policy (a strict-mode
257
+ // re-throw, or the cookie/gc work below) and was previously unguarded - in
258
+ // res.end, where an uncaught throw is especially bad.
259
+ try {
260
+ sess.save();
261
+ } catch (err) {
262
+ Log.error(`Session could not be finalised for this response (${errorLabel(err)})`);
263
+ if (sessionStrictMode()) throw err;
264
+ }
265
+
266
+ // Probabilistic garbage collection (~1% of requests)
267
+ if (Math.floor(Math.random() * 100) === 0) {
268
+ try { sess.gc(); } catch { /* GC failure is non-critical */ }
269
+ }
270
+
271
+ const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
272
+ if (newSid && newSid !== existingSid && !rawRes.headersSent) {
273
+ const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
274
+ // Thread the client's real scheme in so an HTTPS deploy behind a
275
+ // TLS-terminating proxy ships the session cookie with `Secure`
276
+ // (nodejs#34). `x-forwarded-proto` is the same header request.ts trusts
277
+ // for URL construction; native socket TLS is the fallback.
278
+ const xfProto = rawReq.headers["x-forwarded-proto"];
279
+ const forwardedProto = Array.isArray(xfProto) ? xfProto[0] : xfProto;
280
+ const socketEncrypted = (rawReq.socket as { encrypted?: boolean })?.encrypted === true;
281
+ rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted));
282
+ }
283
+ return origEnd(...args);
284
+ } as typeof rawRes.end;
285
+ }