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
|
@@ -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
|
-
|
|
512
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
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
|
-
|
|
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 === "
|
|
983
|
-
|
|
1027
|
+
if (!statusFilter || statusFilter === "reserved") {
|
|
1028
|
+
jobs.push(...readQueueDir(join(topicDir, "reserved"), topic, "reserved"));
|
|
984
1029
|
}
|
|
985
|
-
if (!statusFilter || statusFilter === "dead") {
|
|
986
|
-
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|