tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- 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/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- 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 +1 -1
- 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 +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- package/packages/core/src/session.ts +244 -27
- 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 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- 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/orm/dist/index.js +22717 -20168
- 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 +60 -24
- package/packages/orm/src/autoCrud.ts +12 -10
- 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 +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -4
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- 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/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -12,15 +12,20 @@ import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
|
12
12
|
import { enforceRouteAuth } from "./authGate.js";
|
|
13
13
|
import { discoverRoutes } from "./routeDiscovery.js";
|
|
14
14
|
import { createRequest } from "./request.js";
|
|
15
|
+
import {
|
|
16
|
+
resetRequestCaches,
|
|
17
|
+
headStripIntercept,
|
|
18
|
+
sessionAutoStart,
|
|
19
|
+
} from "./dispatchPipeline.js";
|
|
15
20
|
import { createResponse, setDefaultTemplatesDir } from "./response.js";
|
|
16
|
-
import { MiddlewareChain, MiddlewareRunner, cors, requestLogger } from "./middleware.js";
|
|
21
|
+
import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass } from "./middleware.js";
|
|
17
22
|
import { tryServeStatic } from "./static.js";
|
|
18
23
|
import { loadEnv, isTruthy } from "./dotenv.js";
|
|
19
24
|
import { createHealthRoutes } from "./health.js";
|
|
20
25
|
import { rateLimiter } from "./rateLimiter.js";
|
|
21
26
|
import { Log } from "./logger.js";
|
|
22
27
|
import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
|
|
23
|
-
import { devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
|
|
28
|
+
import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
|
|
24
29
|
import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
|
|
25
30
|
import { I18n } from "./i18n.js";
|
|
26
31
|
import { stopAllBackgroundTasks } from "./background.js";
|
|
@@ -55,6 +60,34 @@ function isSwaggerAssetPath(pathname: string): boolean {
|
|
|
55
60
|
return pathname === "/swagger" || pathname.startsWith("/swagger/");
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
/** How long a graceful shutdown waits for in-flight requests, in seconds. */
|
|
64
|
+
export const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the shutdown budget from `TINA4_SHUTDOWN_TIMEOUT` (seconds).
|
|
68
|
+
*
|
|
69
|
+
* 30s matches Kubernetes' default `terminationGracePeriodSeconds` and
|
|
70
|
+
* Gunicorn's `graceful_timeout`, so the drain finishes just BEFORE the
|
|
71
|
+
* orchestrator's SIGKILL rather than being truncated by it. Same env var and
|
|
72
|
+
* same default as tina4-ruby's `Tina4::Shutdown`.
|
|
73
|
+
*
|
|
74
|
+
* A non-numeric or negative value falls back to the default rather than
|
|
75
|
+
* silently disabling the drain - a typo must not turn shutdown into a
|
|
76
|
+
* zero-second force-kill.
|
|
77
|
+
*/
|
|
78
|
+
export function shutdownTimeoutSeconds(): number {
|
|
79
|
+
const raw = process.env.TINA4_SHUTDOWN_TIMEOUT;
|
|
80
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_SHUTDOWN_TIMEOUT_SECONDS;
|
|
81
|
+
const parsed = Number(raw);
|
|
82
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
83
|
+
Log.warning(
|
|
84
|
+
`TINA4_SHUTDOWN_TIMEOUT="${raw}" is not a valid number of seconds - using ${DEFAULT_SHUTDOWN_TIMEOUT_SECONDS}`,
|
|
85
|
+
);
|
|
86
|
+
return DEFAULT_SHUTDOWN_TIMEOUT_SECONDS;
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
/**
|
|
59
92
|
* Build the startup banner's optional surface lines (issue #99).
|
|
60
93
|
*
|
|
@@ -345,9 +378,29 @@ function openBrowser(url: string) {
|
|
|
345
378
|
* for backwards compatibility.
|
|
346
379
|
*/
|
|
347
380
|
export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
381
|
+
// Port: explicit config > TINA4_PORT > PORT (deprecated) > default.
|
|
382
|
+
//
|
|
383
|
+
// This read PORT and nothing else, so TINA4_PORT - the name the CLI
|
|
384
|
+
// documents and prefers, and the one devAdmin.ts itself reads first - was
|
|
385
|
+
// IGNORED on the path that binds the socket. Setting it did nothing and said
|
|
386
|
+
// nothing.
|
|
387
|
+
//
|
|
388
|
+
// Bare PORT stays honoured so no deployment breaks, and warns so the
|
|
389
|
+
// migration happens. Removal is 3.14.
|
|
390
|
+
const tina4Port = process.env.TINA4_PORT;
|
|
391
|
+
const legacyPort = process.env.PORT;
|
|
392
|
+
let port: number;
|
|
393
|
+
if (config?.port !== undefined) {
|
|
394
|
+
port = config.port;
|
|
395
|
+
} else if (tina4Port && /^\d+$/.test(tina4Port)) {
|
|
396
|
+
port = parseInt(tina4Port, 10);
|
|
397
|
+
} else if (legacyPort && /^\d+$/.test(legacyPort)) {
|
|
398
|
+
port = parseInt(legacyPort, 10);
|
|
399
|
+
warnDeprecatedPort(port);
|
|
400
|
+
} else {
|
|
401
|
+
port = 7148;
|
|
402
|
+
}
|
|
403
|
+
|
|
351
404
|
const host = config?.host
|
|
352
405
|
?? process.env.TINA4_HOST
|
|
353
406
|
?? process.env.HOST
|
|
@@ -355,6 +408,22 @@ export function resolvePortAndHost(config?: { port?: number; host?: string }): {
|
|
|
355
408
|
return { port, host };
|
|
356
409
|
}
|
|
357
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Warn ONCE that bare PORT was used instead of TINA4_PORT.
|
|
413
|
+
*
|
|
414
|
+
* Once, because resolvePortAndHost can be called more than once per process
|
|
415
|
+
* and a warning repeated on every call is a warning people filter out.
|
|
416
|
+
*/
|
|
417
|
+
let portDeprecationWarned = false;
|
|
418
|
+
function warnDeprecatedPort(port: number): void {
|
|
419
|
+
if (portDeprecationWarned) return;
|
|
420
|
+
portDeprecationWarned = true;
|
|
421
|
+
Log.warning(
|
|
422
|
+
`PORT is deprecated and will be removed in 3.14 - use TINA4_PORT instead ` +
|
|
423
|
+
`(binding port ${port} from PORT)`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
358
427
|
/**
|
|
359
428
|
* Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
|
|
360
429
|
* silence the ASCII-art banner and route table on startup — useful in CI,
|
|
@@ -754,7 +823,6 @@ let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<v
|
|
|
754
823
|
// not installed). Memoised so the dynamic import happens once, then every
|
|
755
824
|
// request reuses the resolved function — see the request-scoped cache boundary
|
|
756
825
|
// in dispatch().
|
|
757
|
-
let _resetRequestCaches: Promise<(() => void) | null> | undefined;
|
|
758
826
|
|
|
759
827
|
/** Module-level server handle for start()/stop() parity. */
|
|
760
828
|
let _serverHandle: { close: () => void; router: Router; port: number } | null = null;
|
|
@@ -763,6 +831,67 @@ let _serverHandle: { close: () => void; router: Router; port: number } | null =
|
|
|
763
831
|
* Start the Tina4 HTTP server.
|
|
764
832
|
* Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
|
|
765
833
|
*/
|
|
834
|
+
/**
|
|
835
|
+
* Watch for a handler that occupies the event loop, and say so.
|
|
836
|
+
*
|
|
837
|
+
* Node runs ONE loop. An `await`ing handler yields it and blocks nobody -
|
|
838
|
+
* measured, /fast answers in 0.030s while a route awaits a 2s timer. A
|
|
839
|
+
* CPU-BOUND handler does not yield, and everything else waits: the same /fast
|
|
840
|
+
* took 1.575s during a 2s busy loop.
|
|
841
|
+
*
|
|
842
|
+
* That is inherent to a single-loop runtime, not a bug to engineer away. PHP
|
|
843
|
+
* fixed its equivalent by forking per request because `sleep()` is the obvious
|
|
844
|
+
* thing to write there and it blocks; in JavaScript the obvious thing is
|
|
845
|
+
* `await`, which does not. So the exposure here is narrower - CPU-bound work
|
|
846
|
+
* and synchronous I/O - and the honest fix is to make it VISIBLE rather than
|
|
847
|
+
* to move handlers onto threads a closure cannot cross.
|
|
848
|
+
*
|
|
849
|
+
* The mechanism is loop lag: a timer set for TICK_MS fires late by however
|
|
850
|
+
* long the loop was blocked. If that lateness passes the threshold, something
|
|
851
|
+
* held the loop and the developer wants to know which.
|
|
852
|
+
*
|
|
853
|
+
* A 100ms repeating timer is the classic way to pin a process open forever, so
|
|
854
|
+
* there are two guards against it: close() stops the timer, and the timer is
|
|
855
|
+
* unref'd. Measured: either one alone is enough, and the signal path exits
|
|
856
|
+
* regardless of both. They are kept together because they cost nothing and
|
|
857
|
+
* cover different exits - close() covers the in-process handle, unref() covers
|
|
858
|
+
* a path that never reaches close() at all.
|
|
859
|
+
*/
|
|
860
|
+
const LOOP_WATCHDOG_TICK_MS = 100;
|
|
861
|
+
|
|
862
|
+
function startLoopWatchdog(): { stop: () => void } {
|
|
863
|
+
const raw = (process.env.TINA4_LOOP_LAG_WARN_MS ?? "").trim();
|
|
864
|
+
// 0 or a negative value disables it; a non-numeric value falls to the
|
|
865
|
+
// default rather than silently disabling a diagnostic.
|
|
866
|
+
const threshold = /^\d+$/.test(raw) ? parseInt(raw, 10) : 250;
|
|
867
|
+
if (threshold <= 0) {
|
|
868
|
+
return { stop: () => {} };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let last = Date.now();
|
|
872
|
+
let warned = 0;
|
|
873
|
+
const timer = setInterval(() => {
|
|
874
|
+
const now = Date.now();
|
|
875
|
+
const lag = now - last - LOOP_WATCHDOG_TICK_MS;
|
|
876
|
+
last = now;
|
|
877
|
+
if (lag < threshold) return;
|
|
878
|
+
|
|
879
|
+
// Rate-limited: a handler that blocks on every request would otherwise
|
|
880
|
+
// produce a wall of identical warnings, which people filter out.
|
|
881
|
+
warned++;
|
|
882
|
+
if (warned > 5 && warned % 20 !== 0) return;
|
|
883
|
+
Log.warning(
|
|
884
|
+
`Event loop blocked for ${lag}ms. Node serves every request on one loop, ` +
|
|
885
|
+
`so a handler doing CPU-bound work or synchronous I/O stalls all the ` +
|
|
886
|
+
`others for that long. Move the work to Tina4's queue, or await it. ` +
|
|
887
|
+
`Set TINA4_LOOP_LAG_WARN_MS to change the ${threshold}ms threshold, or 0 to silence.`,
|
|
888
|
+
);
|
|
889
|
+
}, LOOP_WATCHDOG_TICK_MS);
|
|
890
|
+
timer.unref();
|
|
891
|
+
|
|
892
|
+
return { stop: () => clearInterval(timer) };
|
|
893
|
+
}
|
|
894
|
+
|
|
766
895
|
export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> {
|
|
767
896
|
const isManaged = process.argv.includes('--managed');
|
|
768
897
|
if (!isManaged && process.env.TINA4_OVERRIDE_CLIENT !== 'true') {
|
|
@@ -809,6 +938,526 @@ export async function handle(rawReq: IncomingMessage, rawRes: ServerResponse): P
|
|
|
809
938
|
return _dispatchFn(rawReq, rawRes);
|
|
810
939
|
}
|
|
811
940
|
|
|
941
|
+
/**
|
|
942
|
+
* Run one global-middleware pass.
|
|
943
|
+
*
|
|
944
|
+
* The pre-match and post-match passes had byte-identical bodies; this is that
|
|
945
|
+
* body, once.
|
|
946
|
+
*
|
|
947
|
+
* AFTER-ON-4xx RULE (M2): the after_* hooks ALWAYS run when a before_*
|
|
948
|
+
* short-circuited (4xx, a clean 500, or the response already ended), so they
|
|
949
|
+
* can still add headers and logging. Consistent across all four frameworks.
|
|
950
|
+
*
|
|
951
|
+
* @returns true when the pass answered the request and the handler must be skipped
|
|
952
|
+
*/
|
|
953
|
+
async function runGlobalMiddlewarePass(
|
|
954
|
+
middleware: unknown[],
|
|
955
|
+
req: Tina4Request,
|
|
956
|
+
res: Tina4Response,
|
|
957
|
+
): Promise<boolean> {
|
|
958
|
+
if (middleware.length === 0) return false;
|
|
959
|
+
|
|
960
|
+
const [, , proceed] = await MiddlewareRunner.runBefore(middleware as never, req, res);
|
|
961
|
+
if (proceed && !res.raw.writableEnded) return false;
|
|
962
|
+
|
|
963
|
+
await MiddlewareRunner.runAfter(middleware as never, req, res);
|
|
964
|
+
if (!res.raw.writableEnded) res.raw.end();
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Invoke a matched route's handler, binding path params BY NAME.
|
|
970
|
+
*
|
|
971
|
+
* A handler declares whatever it needs - `(id, request, response)`, `(req, res)`,
|
|
972
|
+
* or nothing - and each parameter is resolved by its name: a path param wins,
|
|
973
|
+
* then `request`/`req`, then the response.
|
|
974
|
+
*/
|
|
975
|
+
async function invokeRouteHandler(
|
|
976
|
+
match: { handler: unknown },
|
|
977
|
+
req: Tina4Request,
|
|
978
|
+
res: Tina4Response,
|
|
979
|
+
): Promise<unknown> {
|
|
980
|
+
const routeParams = req.params || {};
|
|
981
|
+
const fnStr = (match.handler as { toString(): string }).toString();
|
|
982
|
+
const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
983
|
+
const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
|
|
984
|
+
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
985
|
+
|
|
986
|
+
if (filteredArgs.length === 0) return await (match.handler as any)();
|
|
987
|
+
|
|
988
|
+
const args = filteredArgs.map((name: string) => {
|
|
989
|
+
if (name in routeParams) return routeParams[name];
|
|
990
|
+
if (name === "request" || name === "req") return req;
|
|
991
|
+
return res;
|
|
992
|
+
});
|
|
993
|
+
return await (match.handler as any)(...args);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Render a template route's return value, when it is one.
|
|
998
|
+
*
|
|
999
|
+
* A route that exports a template AND whose handler returned a plain object
|
|
1000
|
+
* renders through the template engine instead of being sent as JSON. Every
|
|
1001
|
+
* other shape - a handler that already wrote, no template, null/undefined, a
|
|
1002
|
+
* string, a Buffer - is left exactly as it was.
|
|
1003
|
+
*/
|
|
1004
|
+
async function renderIfTemplateRoute(
|
|
1005
|
+
match: { template?: string },
|
|
1006
|
+
res: Tina4Response,
|
|
1007
|
+
result: unknown,
|
|
1008
|
+
): Promise<void> {
|
|
1009
|
+
if (res.raw.writableEnded) return;
|
|
1010
|
+
if (!match.template) return;
|
|
1011
|
+
if (result === null || result === undefined) return;
|
|
1012
|
+
if (typeof result !== "object" || Buffer.isBuffer(result)) return;
|
|
1013
|
+
|
|
1014
|
+
await res.render(match.template, result as Record<string, unknown>);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** State the matched-route pipeline needs. */
|
|
1018
|
+
interface MatchedRouteContext {
|
|
1019
|
+
req: Tina4Request;
|
|
1020
|
+
res: Tina4Response;
|
|
1021
|
+
pathname: string;
|
|
1022
|
+
match: { params?: Record<string, unknown>; handler: unknown; template?: string; middlewares?: unknown[] };
|
|
1023
|
+
postMatchMiddleware: unknown[];
|
|
1024
|
+
/**
|
|
1025
|
+
* EVERY global middleware, both phases. The after pass runs over all of it,
|
|
1026
|
+
* not just the post-match group - see runMatchedRoute.
|
|
1027
|
+
*/
|
|
1028
|
+
allGlobalMiddleware: unknown[];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Run a matched route end to end.
|
|
1033
|
+
*
|
|
1034
|
+
* Order, and it is BEHAVIOUR (ADR-0012):
|
|
1035
|
+
* post-match globals -> auth gate -> the route's own middleware -> handler
|
|
1036
|
+
*
|
|
1037
|
+
* The globals run BEFORE the gate so a rate limiter can throttle a brute-force
|
|
1038
|
+
* login and an access log records the 401 - neither is possible if they only
|
|
1039
|
+
* run on authenticated requests (Django enforces auth in a view decorator after
|
|
1040
|
+
* all MIDDLEWARE; Laravel's `web` group runs before the `auth` route
|
|
1041
|
+
* middleware; ASP.NET puts UseAuthorization last before the endpoint).
|
|
1042
|
+
*
|
|
1043
|
+
* The route's OWN middleware runs AFTER, so middleware attached to a secured
|
|
1044
|
+
* route never processes an unauthenticated request. Node used to run it first,
|
|
1045
|
+
* which meant a body-parsing or audit middleware on a secured route saw traffic
|
|
1046
|
+
* that was about to be rejected.
|
|
1047
|
+
*/
|
|
1048
|
+
async function runMatchedRoute(ctx: MatchedRouteContext): Promise<void> {
|
|
1049
|
+
const { req, res, match, postMatchMiddleware } = ctx;
|
|
1050
|
+
req.params = match.params as never;
|
|
1051
|
+
|
|
1052
|
+
if (await runGlobalMiddlewarePass(postMatchMiddleware, req, res)) return;
|
|
1053
|
+
|
|
1054
|
+
// Auth enforcement lives in enforceRouteAuth (authGate.ts) so the in-process
|
|
1055
|
+
// TestClient enforces the EXACT same gate - parity with Python #PY2, where a
|
|
1056
|
+
// tokenless write must 401 in tests too, or a green test hides a live 401.
|
|
1057
|
+
// Dev admin routes (/__dev) are always public.
|
|
1058
|
+
if (enforceRouteAuth(req, res, match as never, ctx.pathname.startsWith("/__dev"))) return;
|
|
1059
|
+
|
|
1060
|
+
// The route's OWN class middleware: its beforeX hooks run inside
|
|
1061
|
+
// runRouteMiddlewares, its afterX hooks join the after pass below - one
|
|
1062
|
+
// effective list for the response phase, the way Python merges the globals
|
|
1063
|
+
// and the route's middleware into `_effective_middleware` for both passes.
|
|
1064
|
+
const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
|
|
1065
|
+
|
|
1066
|
+
let handlerSkipped = false;
|
|
1067
|
+
if (match.middlewares && match.middlewares.length > 0) {
|
|
1068
|
+
const proceed = await runRouteMiddlewares(match.middlewares as never, req, res);
|
|
1069
|
+
handlerSkipped = !proceed || res.raw.writableEnded;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (!handlerSkipped) {
|
|
1073
|
+
await renderIfTemplateRoute(match, res, await invokeRouteHandler(match, req, res));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Global afterX hooks (logging / post-processing), over EVERY global
|
|
1077
|
+
// middleware - both phases, not just the post-match group - PLUS the route's
|
|
1078
|
+
// own middleware classes.
|
|
1079
|
+
//
|
|
1080
|
+
// The response phase must cover everything the request phase entered, so it
|
|
1081
|
+
// also runs when the ROUTE's middleware short-circuited - the after hooks of a
|
|
1082
|
+
// middleware whose before hook denied the request are exactly what add the
|
|
1083
|
+
// headers and the access-log line for that denial. Dispatch used to return
|
|
1084
|
+
// early there, so a cache HIT (a route middleware that answers and ends)
|
|
1085
|
+
// skipped every global after hook, and a route middleware that short-circuited
|
|
1086
|
+
// WITHOUT ending the response left the request hanging with no end() at all.
|
|
1087
|
+
//
|
|
1088
|
+
// Running only the post-match group meant a `preMatch` middleware's afterX NEVER ran
|
|
1089
|
+
// on a successful request: measured 0 runs in 5 requests. An acquire/release
|
|
1090
|
+
// pair leaked one slot per request, unbounded; a timer started in beforeX was
|
|
1091
|
+
// never stopped; an access log saw the request and never the response - the
|
|
1092
|
+
// very hole ADR-0012 moved the globals ahead of the auth gate to close.
|
|
1093
|
+
//
|
|
1094
|
+
// Worse, it inverted: the pre-match afterX DID run when the pre-match pass
|
|
1095
|
+
// short-circuited, so it fired on the error path and not the happy one.
|
|
1096
|
+
//
|
|
1097
|
+
// Django unwinds its single MIDDLEWARE list in reverse, Laravel runs the
|
|
1098
|
+
// response/terminate phase for global, group AND route middleware, Rails runs
|
|
1099
|
+
// every declared after_action, ASP.NET unwinds through every component
|
|
1100
|
+
// entered. Ruby and PHP already did this. Splitting the BEFORE pass by
|
|
1101
|
+
// dependency (ADR-0012) says nothing about the after pass: an after hook adds
|
|
1102
|
+
// headers or logging and needs no route metadata either way.
|
|
1103
|
+
//
|
|
1104
|
+
// No double-run: when the pre-match pass short-circuits, dispatch returns
|
|
1105
|
+
// before ever reaching this.
|
|
1106
|
+
//
|
|
1107
|
+
// Header mutations here are no-ops once the response is flushed - Node sends
|
|
1108
|
+
// headers with the body - so response headers belong in beforeX.
|
|
1109
|
+
const afterMiddleware = [...ctx.allGlobalMiddleware, ...routeMiddlewareClasses];
|
|
1110
|
+
if (afterMiddleware.length > 0) {
|
|
1111
|
+
await MiddlewareRunner.runAfter(afterMiddleware as never, req, res);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (!res.raw.writableEnded) res.raw.end();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** State the response-end wrappers need. */
|
|
1118
|
+
interface ResponseWrapContext {
|
|
1119
|
+
req: Tina4Request;
|
|
1120
|
+
res: Tina4Response;
|
|
1121
|
+
pathname: string;
|
|
1122
|
+
router: Router;
|
|
1123
|
+
reqStartTime: number;
|
|
1124
|
+
requestId: string;
|
|
1125
|
+
/** Holder, because the wrapper reads this at end() time - after route matching. */
|
|
1126
|
+
matchedPattern: { value: string };
|
|
1127
|
+
isAiPortRequest: boolean;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Block /__dev_reload on the AI port so AI tools never trigger a browser reload.
|
|
1132
|
+
*
|
|
1133
|
+
* @returns true when the request was answered
|
|
1134
|
+
*/
|
|
1135
|
+
function blockAiPortReload(res: Tina4Response, pathname: string, isAiPortRequest: boolean): boolean {
|
|
1136
|
+
if (!isAiPortRequest || pathname !== "/__dev_reload") return false;
|
|
1137
|
+
|
|
1138
|
+
res.raw.writeHead(404, { "Content-Type": "application/json" });
|
|
1139
|
+
res.raw.end(JSON.stringify({ error: "Not available on AI port" }));
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Rebuild the arguments `end` was called with.
|
|
1145
|
+
*
|
|
1146
|
+
* Node's `end` has three overloads and the wrappers must forward exactly the
|
|
1147
|
+
* shape they were given, or a callback lands in the encoding slot.
|
|
1148
|
+
*/
|
|
1149
|
+
function callOriginalEnd(
|
|
1150
|
+
originalEnd: (...args: any[]) => any,
|
|
1151
|
+
chunk: unknown,
|
|
1152
|
+
encodingOrCb?: BufferEncoding | (() => void),
|
|
1153
|
+
cb?: () => void,
|
|
1154
|
+
): any {
|
|
1155
|
+
if (typeof encodingOrCb === "function") return originalEnd(chunk, encodingOrCb);
|
|
1156
|
+
if (encodingOrCb !== undefined) return originalEnd(chunk, encodingOrCb, cb);
|
|
1157
|
+
return originalEnd(chunk, cb);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Whether the response is declaring itself as HTML. */
|
|
1161
|
+
function isHtmlResponse(res: Tina4Response): boolean {
|
|
1162
|
+
const contentType = res.raw.getHeader("content-type");
|
|
1163
|
+
return typeof contentType === "string" && contentType.includes("text/html");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** The chunk as an HTML string, or null when it is neither a string nor a Buffer. */
|
|
1167
|
+
function asHtmlString(chunk: unknown): string | null {
|
|
1168
|
+
if (typeof chunk === "string") return chunk;
|
|
1169
|
+
if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Inject the dev toolbar (dev mode only) and the feedback widget into an HTML body.
|
|
1175
|
+
*
|
|
1176
|
+
* The feedback injector re-checks the whitelist, path and html marker itself,
|
|
1177
|
+
* so calling it unconditionally is cheap when it no-ops.
|
|
1178
|
+
*/
|
|
1179
|
+
function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: string): string {
|
|
1180
|
+
if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
|
|
1181
|
+
|
|
1182
|
+
const toolbarCtx: DevToolbarContext = {
|
|
1183
|
+
version: TINA4_VERSION,
|
|
1184
|
+
method: ctx.req.method ?? "GET",
|
|
1185
|
+
path: ctx.pathname,
|
|
1186
|
+
matchedPattern: ctx.matchedPattern.value || ctx.pathname,
|
|
1187
|
+
requestId: ctx.requestId,
|
|
1188
|
+
routeCount: ctx.router.getRoutes().length,
|
|
1189
|
+
};
|
|
1190
|
+
return injectFeedbackWidget(ctx.req, injectDevToolbar(html, toolbarCtx));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Wrap `res.raw.end` to inject the dev toolbar and/or the feedback widget, and
|
|
1195
|
+
* to capture the request for the dev inspector.
|
|
1196
|
+
*
|
|
1197
|
+
* Two modes, and the distinction is deliberate:
|
|
1198
|
+
* * dev mode (off the AI port, outside /__dev) gets the toolbar, the feedback
|
|
1199
|
+
* widget and inspector capture;
|
|
1200
|
+
* * otherwise a whitelisted user still gets the feedback widget alone. The
|
|
1201
|
+
* injector re-checks the whitelist, path and html marker itself, so the
|
|
1202
|
+
* wrapper is cheap when it no-ops.
|
|
1203
|
+
*
|
|
1204
|
+
* Content-Length is removed on injection because the body size changes.
|
|
1205
|
+
*/
|
|
1206
|
+
function wrapResponseEnd(ctx: ResponseWrapContext): void {
|
|
1207
|
+
const { req, res, pathname } = ctx;
|
|
1208
|
+
const devToolbar = isDevMode() && !pathname.startsWith("/__dev") && !ctx.isAiPortRequest;
|
|
1209
|
+
const feedbackOnly =
|
|
1210
|
+
!devToolbar &&
|
|
1211
|
+
feedbackEnabled() &&
|
|
1212
|
+
!pathname.startsWith("/__dev") &&
|
|
1213
|
+
!pathname.startsWith("/__feedback");
|
|
1214
|
+
|
|
1215
|
+
if (!devToolbar && !feedbackOnly) return;
|
|
1216
|
+
|
|
1217
|
+
const originalEnd = res.raw.end.bind(res.raw);
|
|
1218
|
+
res.raw.end = function (
|
|
1219
|
+
chunk?: unknown,
|
|
1220
|
+
encodingOrCb?: BufferEncoding | (() => void),
|
|
1221
|
+
cb?: () => void,
|
|
1222
|
+
) {
|
|
1223
|
+
if (devToolbar && ctx.reqStartTime > 0) {
|
|
1224
|
+
RequestInspector.capture(
|
|
1225
|
+
req.method ?? "GET",
|
|
1226
|
+
pathname,
|
|
1227
|
+
res.raw.statusCode ?? 200,
|
|
1228
|
+
Date.now() - ctx.reqStartTime,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (isHtmlResponse(res)) {
|
|
1233
|
+
const html = asHtmlString(chunk);
|
|
1234
|
+
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
1235
|
+
// Dropped for ANY html response, not only one carrying a body: that is
|
|
1236
|
+
// what the two original wrappers did, and a refactor does not get to
|
|
1237
|
+
// narrow it. An end() with no chunk on a text/html response still has
|
|
1238
|
+
// its stale content-length removed.
|
|
1239
|
+
if (!res.raw.headersSent) res.raw.removeHeader("content-length");
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return callOriginalEnd(originalEnd, chunk, encodingOrCb, cb);
|
|
1243
|
+
} as typeof res.raw.end;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Turn an uncaught dispatch error into a response, and surface it.
|
|
1248
|
+
*
|
|
1249
|
+
* v3.13.7: log structured + surface to observability BEFORE rendering.
|
|
1250
|
+
* Listeners get the canonical {exception, request} payload mirrored by Python /
|
|
1251
|
+
* PHP / Ruby. Listener errors are swallowed and warning-logged so a broken
|
|
1252
|
+
* listener cannot break the 500 page.
|
|
1253
|
+
*
|
|
1254
|
+
* SECURITY (CWE-209): the production response body must NOT contain the stack
|
|
1255
|
+
* trace or the exception message. `error_message` is passed empty - 500.twig
|
|
1256
|
+
* only renders the trace block when it is truthy. The rich overlay with stack
|
|
1257
|
+
* and source context is dev-only.
|
|
1258
|
+
*
|
|
1259
|
+
* @param err The thrown value (not necessarily an Error)
|
|
1260
|
+
* @param req The request, for the log line and the error page
|
|
1261
|
+
* @param res The response; untouched if it has already ended
|
|
1262
|
+
* @param templatesDir Where to look for 500.twig
|
|
1263
|
+
*/
|
|
1264
|
+
async function renderDispatchError(
|
|
1265
|
+
err: unknown,
|
|
1266
|
+
req: Tina4Request,
|
|
1267
|
+
res: Tina4Response,
|
|
1268
|
+
templatesDir: string,
|
|
1269
|
+
): Promise<void> {
|
|
1270
|
+
Log.error(`Route error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, {
|
|
1271
|
+
method: req?.method,
|
|
1272
|
+
path: req?.path,
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
try {
|
|
1276
|
+
const { Events } = await import("./events.js");
|
|
1277
|
+
Events.emit("tina4.request.error", { exception: err, request: req });
|
|
1278
|
+
} catch (listenerErr) {
|
|
1279
|
+
try {
|
|
1280
|
+
Log.warn(
|
|
1281
|
+
`Listener for tina4.request.error raised: ${
|
|
1282
|
+
listenerErr instanceof Error
|
|
1283
|
+
? `${listenerErr.name}: ${listenerErr.message}`
|
|
1284
|
+
: String(listenerErr)
|
|
1285
|
+
}`
|
|
1286
|
+
);
|
|
1287
|
+
} catch {
|
|
1288
|
+
// Log failures must never block the 500 render.
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (res.raw.writableEnded) return;
|
|
1293
|
+
|
|
1294
|
+
if (isDevMode() && err instanceof Error) {
|
|
1295
|
+
// Rich error overlay with stack trace, source context, and line numbers
|
|
1296
|
+
const { renderErrorOverlay } = await import("./errorOverlay.js");
|
|
1297
|
+
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1298
|
+
res.raw.end(renderErrorOverlay(err, req));
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const html500 = await renderErrorPage(500, {
|
|
1303
|
+
error_message: "",
|
|
1304
|
+
request_id: `${Date.now().toString(36)}`,
|
|
1305
|
+
path: req.path,
|
|
1306
|
+
}, templatesDir);
|
|
1307
|
+
if (html500) {
|
|
1308
|
+
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1309
|
+
res.raw.end(html500);
|
|
1310
|
+
} else {
|
|
1311
|
+
res({ error: "Internal Server Error", statusCode: 500 }, 500);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* State the not-found fallback stages need.
|
|
1317
|
+
*
|
|
1318
|
+
* Passed explicitly rather than closed over, so each stage is callable on its
|
|
1319
|
+
* own instead of only from inside `dispatch`. That coupling is what the
|
|
1320
|
+
* extraction removes.
|
|
1321
|
+
*/
|
|
1322
|
+
interface FallbackContext {
|
|
1323
|
+
req: Tina4Request;
|
|
1324
|
+
res: Tina4Response;
|
|
1325
|
+
pathname: string;
|
|
1326
|
+
router: Router;
|
|
1327
|
+
port: number;
|
|
1328
|
+
staticDir: string;
|
|
1329
|
+
srcPublicDir: string;
|
|
1330
|
+
templatesDir: string;
|
|
1331
|
+
frondEngine: { render(file: string, data: Record<string, unknown>): string } | null;
|
|
1332
|
+
swaggerAssetsEnabled: boolean;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Serve a template file for a GET (e.g. /hello -> src/templates/pages/hello.twig).
|
|
1337
|
+
*
|
|
1338
|
+
* Rendered through Frond so {% include %} / {% extends %} work, rather than a
|
|
1339
|
+
* raw readFileSync.
|
|
1340
|
+
*/
|
|
1341
|
+
function serveTemplateFallback(ctx: FallbackContext): boolean {
|
|
1342
|
+
if ((ctx.req.method ?? "GET") !== "GET") return false;
|
|
1343
|
+
|
|
1344
|
+
const tplFile = resolveTemplate(ctx.pathname, ctx.templatesDir);
|
|
1345
|
+
if (!tplFile) return false;
|
|
1346
|
+
|
|
1347
|
+
const html = ctx.frondEngine
|
|
1348
|
+
? ctx.frondEngine.render(tplFile, {})
|
|
1349
|
+
: readFileSync(resolve(ctx.templatesDir, tplFile), "utf-8");
|
|
1350
|
+
ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1351
|
+
ctx.res.raw.end(html);
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The branded landing page.
|
|
1357
|
+
*
|
|
1358
|
+
* Renders only at "/" AND only when TINA4_DEBUG=true. In production "/" with no
|
|
1359
|
+
* static index.html and no pages/index.twig falls through to a clean 404, so
|
|
1360
|
+
* the framework's welcome, gallery and version never leak to real users.
|
|
1361
|
+
*/
|
|
1362
|
+
function serveLandingPage(ctx: FallbackContext): boolean {
|
|
1363
|
+
if ((ctx.req.method ?? "GET") !== "GET") return false;
|
|
1364
|
+
if (ctx.pathname !== "/" || !isDevMode()) return false;
|
|
1365
|
+
|
|
1366
|
+
const allRoutes = ctx.router.getRoutes().map((r) => ({
|
|
1367
|
+
method: r.method,
|
|
1368
|
+
pattern: r.pattern,
|
|
1369
|
+
flags: [] as string[],
|
|
1370
|
+
}));
|
|
1371
|
+
ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1372
|
+
ctx.res.raw.end(renderLandingPage(allRoutes, ctx.port));
|
|
1373
|
+
return true;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* RFC 9110 conformance - before falling through to 404, check whether the PATH
|
|
1378
|
+
* is registered under any OTHER method.
|
|
1379
|
+
* - OPTIONS -> 204 with Allow (s9.3.7)
|
|
1380
|
+
* - Any other method (PUT on GET-only, TRACE, CONNECT) -> 405 with Allow
|
|
1381
|
+
* (s15.5.6 + s10.2.1)
|
|
1382
|
+
*/
|
|
1383
|
+
function serveMethodNotAllowed(ctx: FallbackContext): boolean {
|
|
1384
|
+
const allowedMethods = ctx.router.methodsAllowedForPath(ctx.pathname);
|
|
1385
|
+
if (allowedMethods.length === 0) return false;
|
|
1386
|
+
|
|
1387
|
+
const allowHeader = allowedMethods.join(", ");
|
|
1388
|
+
const requestMethod = (ctx.req.method ?? "GET").toUpperCase();
|
|
1389
|
+
|
|
1390
|
+
if (requestMethod === "OPTIONS") {
|
|
1391
|
+
ctx.res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" });
|
|
1392
|
+
ctx.res.raw.end();
|
|
1393
|
+
return true;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const body = JSON.stringify({
|
|
1397
|
+
error: "Method Not Allowed",
|
|
1398
|
+
path: ctx.pathname,
|
|
1399
|
+
method: requestMethod,
|
|
1400
|
+
allow: allowedMethods,
|
|
1401
|
+
statusCode: 405,
|
|
1402
|
+
});
|
|
1403
|
+
ctx.res.raw.writeHead(405, httpReason(405), {
|
|
1404
|
+
Allow: allowHeader,
|
|
1405
|
+
"Content-Type": "application/json",
|
|
1406
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
1407
|
+
});
|
|
1408
|
+
ctx.res.raw.end(body);
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* No route claimed the path, so NOW try the filesystem (ADR-0010).
|
|
1414
|
+
*
|
|
1415
|
+
* Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work.
|
|
1416
|
+
* The framework-bundled directory holds the Swagger UI (public/swagger/), so
|
|
1417
|
+
* that lookup MUST honour the swagger gate or /swagger is served in production
|
|
1418
|
+
* regardless of TINA4_SWAGGER_ENABLED / TINA4_DEBUG.
|
|
1419
|
+
*/
|
|
1420
|
+
function serveStaticAsset(ctx: FallbackContext): boolean {
|
|
1421
|
+
if (existsSync(ctx.staticDir) && tryServeStatic(ctx.staticDir, ctx.req, ctx.res)) return true;
|
|
1422
|
+
if (existsSync(ctx.srcPublicDir) && tryServeStatic(ctx.srcPublicDir, ctx.req, ctx.res)) return true;
|
|
1423
|
+
|
|
1424
|
+
if (ctx.swaggerAssetsEnabled || !isSwaggerAssetPath(ctx.pathname)) {
|
|
1425
|
+
if (tryServeStatic(BUILTIN_PUBLIC_DIR, ctx.req, ctx.res)) return true;
|
|
1426
|
+
}
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** Terminal stage: 404, with the canonical reason phrase so the status line is well-formed. */
|
|
1431
|
+
async function serveNotFound(ctx: FallbackContext): Promise<boolean> {
|
|
1432
|
+
const html404 = await renderErrorPage(404, { path: ctx.pathname }, ctx.templatesDir);
|
|
1433
|
+
if (html404) {
|
|
1434
|
+
ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
|
|
1435
|
+
ctx.res.raw.end(html404);
|
|
1436
|
+
} else {
|
|
1437
|
+
ctx.res(
|
|
1438
|
+
{ error: "Not Found", statusCode: 404, message: `No route found for ${ctx.req.method} ${ctx.pathname}` },
|
|
1439
|
+
404,
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* The not-found fallback chain, in order. Data, so the pipeline can be read and
|
|
1447
|
+
* compared across frameworks without reading an implementation.
|
|
1448
|
+
*
|
|
1449
|
+
* Order is BEHAVIOUR: a template beats the landing page (so a project's own
|
|
1450
|
+
* pages/index.twig wins at "/"), 405 beats static (a known path with the wrong
|
|
1451
|
+
* method is not a missing file), and the 404 is terminal.
|
|
1452
|
+
*/
|
|
1453
|
+
const FALLBACK_STAGES: Array<(ctx: FallbackContext) => boolean | Promise<boolean>> = [
|
|
1454
|
+
serveTemplateFallback,
|
|
1455
|
+
serveLandingPage,
|
|
1456
|
+
serveMethodNotAllowed,
|
|
1457
|
+
serveStaticAsset,
|
|
1458
|
+
serveNotFound,
|
|
1459
|
+
];
|
|
1460
|
+
|
|
812
1461
|
export async function startServer(config?: Tina4Config): Promise<{
|
|
813
1462
|
close: () => void;
|
|
814
1463
|
router: Router;
|
|
@@ -838,8 +1487,18 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
838
1487
|
const host = resolved.host;
|
|
839
1488
|
let port = resolved.port;
|
|
840
1489
|
|
|
841
|
-
// Claim the requested port — kill whatever is on it if needed
|
|
842
|
-
|
|
1490
|
+
// Claim the requested port — kill whatever is on it if needed.
|
|
1491
|
+
//
|
|
1492
|
+
// NOT in a cluster worker. A worker does not own the port: the primary binds
|
|
1493
|
+
// it once and hands the handle down through cluster's IPC. A worker running
|
|
1494
|
+
// this finds the port "in use" (the primary is holding it) and KILLS the
|
|
1495
|
+
// process holding it, which is its own parent. Every worker did that, then
|
|
1496
|
+
// died itself with `write EPIPE` from cluster._getServer because the primary
|
|
1497
|
+
// it needed to ask for the socket was gone. Cluster mode never served a
|
|
1498
|
+
// single request.
|
|
1499
|
+
if (!cluster.isWorker) {
|
|
1500
|
+
port = findAvailablePort(port);
|
|
1501
|
+
}
|
|
843
1502
|
|
|
844
1503
|
// Cluster mode for production: fork workers based on CPU count
|
|
845
1504
|
// Only when --production is explicitly set (via TINA4_PRODUCTION env var)
|
|
@@ -1128,114 +1787,49 @@ ${reset}
|
|
|
1128
1787
|
const req = createRequest(rawReq);
|
|
1129
1788
|
const res = createResponse(rawRes);
|
|
1130
1789
|
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
// first use; subsequent requests reuse the resolved module.
|
|
1138
|
-
if (_resetRequestCaches === undefined) {
|
|
1139
|
-
_resetRequestCaches = import("../../orm/src/index.js")
|
|
1140
|
-
.then((orm) => orm.resetRequestCaches as () => void)
|
|
1141
|
-
.catch(() => null);
|
|
1142
|
-
}
|
|
1143
|
-
try {
|
|
1144
|
-
const reset = await _resetRequestCaches;
|
|
1145
|
-
if (reset) reset();
|
|
1146
|
-
} catch {
|
|
1147
|
-
/* ORM not installed / cache unavailable — non-fatal */
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// RFC 9110 §9.3.2: the server MUST NOT send content in a HEAD response.
|
|
1151
|
-
// Intercept rawRes.write / rawRes.end so every code path — explicit
|
|
1152
|
-
// Router.head() handler, GET auto-fallback, 405 / 404 responses — drops
|
|
1153
|
-
// its body. Content-Length is preserved when present, so cache
|
|
1154
|
-
// validators / link checkers / monitoring probes still see the size
|
|
1155
|
-
// the equivalent GET would have sent.
|
|
1156
|
-
if ((rawReq.method ?? "GET").toUpperCase() === "HEAD") {
|
|
1157
|
-
const origEnd = rawRes.end.bind(rawRes);
|
|
1158
|
-
const origWrite = rawRes.write.bind(rawRes);
|
|
1159
|
-
let accumulated = 0;
|
|
1160
|
-
rawRes.write = ((chunk?: any, _enc?: any, cb?: any): boolean => {
|
|
1161
|
-
if (chunk != null) {
|
|
1162
|
-
accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
1163
|
-
}
|
|
1164
|
-
if (typeof cb === "function") cb();
|
|
1165
|
-
return true;
|
|
1166
|
-
}) as typeof rawRes.write;
|
|
1167
|
-
rawRes.end = ((chunk?: any, _enc?: any, cb?: any): any => {
|
|
1168
|
-
if (chunk != null && typeof chunk !== "function") {
|
|
1169
|
-
accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
1170
|
-
}
|
|
1171
|
-
if (accumulated > 0 && !rawRes.headersSent && !rawRes.hasHeader("Content-Length")) {
|
|
1172
|
-
rawRes.setHeader("Content-Length", String(accumulated));
|
|
1173
|
-
}
|
|
1174
|
-
const realCb = typeof chunk === "function" ? chunk : cb;
|
|
1175
|
-
void origWrite; // referenced to keep tsc happy
|
|
1176
|
-
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
1177
|
-
}) as typeof rawRes.end;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// Auto-start session — read cookie, create session, save + set cookie on response end
|
|
1181
|
-
{
|
|
1182
|
-
const { Session, buildSessionCookie, sessionCookieName } = await import("./session.js");
|
|
1183
|
-
const cookieHeader = rawReq.headers.cookie ?? "";
|
|
1184
|
-
// Read the incoming session cookie by the SAME configured name the write
|
|
1185
|
-
// side emits (TINA4_SESSION_NAME, default tina4_session) via the shared
|
|
1186
|
-
// sessionCookieName() resolver — otherwise a renamed cookie would be
|
|
1187
|
-
// written but never read back and the session would silently never
|
|
1188
|
-
// resume. Match a whole cookie pair by its exact `name=` prefix (split on
|
|
1189
|
-
// ";", trim, startsWith) so `tina4_session` never matches
|
|
1190
|
-
// `tina4_session_foo=` nor a value mid-header. Parity with Python
|
|
1191
|
-
// core/server._init_session.
|
|
1192
|
-
const cookiePrefix = sessionCookieName() + "=";
|
|
1193
|
-
let existingSid: string | undefined;
|
|
1194
|
-
for (const part of cookieHeader.split(";")) {
|
|
1195
|
-
const trimmed = part.trim();
|
|
1196
|
-
if (trimmed.startsWith(cookiePrefix)) {
|
|
1197
|
-
existingSid = trimmed.slice(cookiePrefix.length);
|
|
1198
|
-
break;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
const sess = new Session();
|
|
1202
|
-
sess.start(existingSid);
|
|
1203
|
-
(req as any).session = sess;
|
|
1204
|
-
|
|
1205
|
-
const origEnd = rawRes.end.bind(rawRes);
|
|
1206
|
-
rawRes.end = function (...args: any[]) {
|
|
1207
|
-
sess.save();
|
|
1208
|
-
|
|
1209
|
-
// Probabilistic garbage collection (~1% of requests)
|
|
1210
|
-
if (Math.floor(Math.random() * 100) === 0) {
|
|
1211
|
-
try { sess.gc(); } catch { /* GC failure is non-critical */ }
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
|
|
1215
|
-
if (newSid && newSid !== existingSid && !rawRes.headersSent) {
|
|
1216
|
-
const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
|
|
1217
|
-
// Thread the client's real scheme in so an HTTPS deploy behind a
|
|
1218
|
-
// TLS-terminating proxy ships the session cookie with `Secure`
|
|
1219
|
-
// (nodejs#34). `x-forwarded-proto` is the same header request.ts
|
|
1220
|
-
// trusts for URL construction; native socket TLS is the fallback.
|
|
1221
|
-
const xfProto = rawReq.headers["x-forwarded-proto"];
|
|
1222
|
-
const forwardedProto = Array.isArray(xfProto) ? xfProto[0] : xfProto;
|
|
1223
|
-
const socketEncrypted = (rawReq.socket as { encrypted?: boolean })?.encrypted === true;
|
|
1224
|
-
rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted));
|
|
1225
|
-
}
|
|
1226
|
-
return origEnd(...args);
|
|
1227
|
-
} as typeof rawRes.end;
|
|
1228
|
-
}
|
|
1790
|
+
// PROLOGUE STAGES. Extracted to dispatchPipeline.ts - see PROLOGUE_STAGES
|
|
1791
|
+
// there for the ordered list and why the order is behaviour, not taste.
|
|
1792
|
+
// These three close over nothing from startServer, which is why they went
|
|
1793
|
+
// first: no context object is needed for them at all.
|
|
1794
|
+
await resetRequestCaches();
|
|
1795
|
+
headStripIntercept(rawReq, rawRes);
|
|
1229
1796
|
|
|
1230
1797
|
// res.render() is handled natively by response.ts via Frond
|
|
1231
1798
|
|
|
1232
1799
|
try {
|
|
1800
|
+
// sessionAutoStart is the one prologue stage INSIDE the try. It degrades
|
|
1801
|
+
// on its own (ADR-0021), so the only thing that escapes it is a
|
|
1802
|
+
// TINA4_SESSION_STRICT refusal - and that must become a 500 through the
|
|
1803
|
+
// normal error renderer, like Python's raise becomes a 500 in the ASGI
|
|
1804
|
+
// server. Outside the try it rejected `dispatch`, and nothing awaits the
|
|
1805
|
+
// listener http.createServer() calls: an unhandled rejection that takes
|
|
1806
|
+
// the whole worker down is not "refuse this request".
|
|
1807
|
+
await sessionAutoStart(rawReq, rawRes, req);
|
|
1808
|
+
|
|
1233
1809
|
// Run middleware chain
|
|
1234
1810
|
await middleware.run(req, res);
|
|
1235
1811
|
if (res.raw.writableEnded) return;
|
|
1236
1812
|
|
|
1237
|
-
// Parse request body
|
|
1238
|
-
|
|
1813
|
+
// Parse request body.
|
|
1814
|
+
//
|
|
1815
|
+
// A body that breaks a documented limit is the client's error, not the
|
|
1816
|
+
// server's. PayloadTooLargeError already carried `statusCode = 413` and
|
|
1817
|
+
// nothing read it, so an oversized upload answered 500 - which tells the
|
|
1818
|
+
// caller to retry the exact request that will fail again.
|
|
1819
|
+
try {
|
|
1820
|
+
await req.parseBody();
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
const status = (err as { statusCode?: number })?.statusCode;
|
|
1823
|
+
if (typeof status === "number" && status >= 400 && status < 500) {
|
|
1824
|
+
if (!rawRes.writableEnded) {
|
|
1825
|
+
rawRes.statusCode = status;
|
|
1826
|
+
rawRes.setHeader("content-type", "application/json");
|
|
1827
|
+
rawRes.end(JSON.stringify({ error: (err as Error).message }));
|
|
1828
|
+
}
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
throw err;
|
|
1832
|
+
}
|
|
1239
1833
|
|
|
1240
1834
|
const pathname = req.path;
|
|
1241
1835
|
|
|
@@ -1243,334 +1837,68 @@ ${reset}
|
|
|
1243
1837
|
const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
|
|
1244
1838
|
|
|
1245
1839
|
// Mutable ref so wrappedEnd can read the matched pattern after route matching
|
|
1246
|
-
|
|
1840
|
+
// A HOLDER, not a plain string: the end-wrapper below reads it at end()
|
|
1841
|
+
// time, after route matching has assigned it.
|
|
1842
|
+
const matchedPattern = { value: "" };
|
|
1247
1843
|
const requestId = Date.now().toString(36);
|
|
1248
1844
|
|
|
1249
1845
|
// Wrap res.raw.end to inject dev toolbar and capture requests
|
|
1250
1846
|
// Skip toolbar injection on the AI port (no-reload behaviour)
|
|
1251
1847
|
const isAiPortRequest = !!(rawReq as any)._tina4AiPort;
|
|
1252
1848
|
|
|
1253
|
-
// AI port: block /__dev_reload so AI tools never trigger a browser reload
|
|
1254
|
-
if (
|
|
1255
|
-
res.raw.writeHead(404, { "Content-Type": "application/json" });
|
|
1256
|
-
res.raw.end(JSON.stringify({ error: "Not available on AI port" }));
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1849
|
+
// AI port: block /__dev_reload so AI tools never trigger a browser reload.
|
|
1850
|
+
if (blockAiPortReload(res, pathname, isAiPortRequest)) return;
|
|
1259
1851
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
// Capture request for dev inspector
|
|
1269
|
-
if (reqStartTime > 0) {
|
|
1270
|
-
const duration = Date.now() - reqStartTime;
|
|
1271
|
-
const status = res.raw.statusCode ?? 200;
|
|
1272
|
-
RequestInspector.capture(req.method ?? "GET", pathname, status, duration);
|
|
1273
|
-
}
|
|
1852
|
+
// Wrap res.raw.end so the dev toolbar / feedback widget can be injected
|
|
1853
|
+
// and the request captured for the inspector. Extracted - see
|
|
1854
|
+
// wrapResponseEnd. matchedPattern is a HOLDER because the wrapper reads
|
|
1855
|
+
// it at end() time, long after route matching has assigned it.
|
|
1856
|
+
wrapResponseEnd({
|
|
1857
|
+
req, res, pathname, router,
|
|
1858
|
+
reqStartTime, requestId, matchedPattern, isAiPortRequest,
|
|
1859
|
+
});
|
|
1274
1860
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
routeCount: router.getRoutes().length,
|
|
1284
|
-
};
|
|
1285
|
-
if (typeof chunk === "string") {
|
|
1286
|
-
chunk = injectDevToolbar(chunk, toolbarCtx);
|
|
1287
|
-
chunk = injectFeedbackWidget(req, chunk as string);
|
|
1288
|
-
} else if (Buffer.isBuffer(chunk)) {
|
|
1289
|
-
const html = chunk.toString("utf-8");
|
|
1290
|
-
chunk = injectFeedbackWidget(req, injectDevToolbar(html, toolbarCtx));
|
|
1291
|
-
}
|
|
1292
|
-
// Remove content-length since toolbar injection changes body size
|
|
1293
|
-
if (!res.raw.headersSent) {
|
|
1294
|
-
res.raw.removeHeader("content-length");
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
if (typeof encodingOrCb === "function") {
|
|
1298
|
-
return originalEnd(chunk, encodingOrCb);
|
|
1299
|
-
}
|
|
1300
|
-
if (encodingOrCb !== undefined) {
|
|
1301
|
-
return originalEnd(chunk, encodingOrCb, cb);
|
|
1302
|
-
}
|
|
1303
|
-
return originalEnd(chunk, cb);
|
|
1304
|
-
};
|
|
1305
|
-
res.raw.end = wrappedEnd;
|
|
1306
|
-
} else if (
|
|
1307
|
-
feedbackEnabled() &&
|
|
1308
|
-
!pathname.startsWith("/__dev") &&
|
|
1309
|
-
!pathname.startsWith("/__feedback")
|
|
1310
|
-
) {
|
|
1311
|
-
// Production / non-dev path: still inject the feedback widget for
|
|
1312
|
-
// whitelisted users. The injector itself re-checks the whitelist,
|
|
1313
|
-
// path, and html marker so the wrapper is cheap when it no-ops.
|
|
1314
|
-
const originalEnd = res.raw.end.bind(res.raw);
|
|
1315
|
-
const wrappedEnd: typeof res.raw.end = function (
|
|
1316
|
-
chunk?: unknown,
|
|
1317
|
-
encodingOrCb?: BufferEncoding | (() => void),
|
|
1318
|
-
cb?: () => void,
|
|
1319
|
-
) {
|
|
1320
|
-
const contentType = res.raw.getHeader("content-type");
|
|
1321
|
-
if (
|
|
1322
|
-
typeof contentType === "string" &&
|
|
1323
|
-
contentType.includes("text/html")
|
|
1324
|
-
) {
|
|
1325
|
-
if (typeof chunk === "string") {
|
|
1326
|
-
chunk = injectFeedbackWidget(req, chunk);
|
|
1327
|
-
} else if (Buffer.isBuffer(chunk)) {
|
|
1328
|
-
chunk = injectFeedbackWidget(req, chunk.toString("utf-8"));
|
|
1329
|
-
}
|
|
1330
|
-
if (!res.raw.headersSent) {
|
|
1331
|
-
res.raw.removeHeader("content-length");
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
if (typeof encodingOrCb === "function") {
|
|
1335
|
-
return originalEnd(chunk, encodingOrCb);
|
|
1336
|
-
}
|
|
1337
|
-
if (encodingOrCb !== undefined) {
|
|
1338
|
-
return originalEnd(chunk, encodingOrCb, cb);
|
|
1339
|
-
}
|
|
1340
|
-
return originalEnd(chunk, cb);
|
|
1341
|
-
};
|
|
1342
|
-
res.raw.end = wrappedEnd;
|
|
1343
|
-
}
|
|
1861
|
+
// Global middleware, split by what it depends on (ADR-0012). The
|
|
1862
|
+
// PRE-match set runs before a route is even looked up, so CORS and
|
|
1863
|
+
// anything else that must survive a short-circuit can set headers that
|
|
1864
|
+
// outlive a 401/403; opt in with `static preMatch = true`.
|
|
1865
|
+
const { pre: preMatchMiddleware, post: postMatchMiddleware } =
|
|
1866
|
+
MiddlewareRunner.partitionByMatchPhase([
|
|
1867
|
+
...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
|
|
1868
|
+
]);
|
|
1344
1869
|
|
|
1345
|
-
|
|
1346
|
-
// Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work.
|
|
1347
|
-
if (existsSync(staticDir) && tryServeStatic(staticDir, req, res)) {
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
if (existsSync(srcPublicDir) && tryServeStatic(srcPublicDir, req, res)) {
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
// Framework-bundled assets. The Swagger UI lives here (public/swagger/),
|
|
1354
|
-
// and static files resolve BEFORE routes -- so this path MUST honour the
|
|
1355
|
-
// swagger gate or /swagger is served in production regardless of
|
|
1356
|
-
// TINA4_SWAGGER_ENABLED / TINA4_DEBUG.
|
|
1357
|
-
if (swaggerAssetsEnabled || !isSwaggerAssetPath(pathname)) {
|
|
1358
|
-
if (tryServeStatic(BUILTIN_PUBLIC_DIR, req, res)) {
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1870
|
+
if (await runGlobalMiddlewarePass(preMatchMiddleware, req, res)) return;
|
|
1362
1871
|
|
|
1363
|
-
// Match route
|
|
1872
|
+
// Match route. ROUTES BEAT FILES (ADR-0010): static assets resolve in
|
|
1873
|
+
// the not-found fallback below, only once no route has claimed the path.
|
|
1874
|
+
// A file in public/ can arrive from a build step, an upload directory or
|
|
1875
|
+
// a careless deploy, and it must never silently shadow a reviewed route.
|
|
1364
1876
|
const match = router.match(req.method ?? "GET", pathname);
|
|
1365
1877
|
if (match) {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
// MiddlewareRunner.use(...) — run the beforeX hooks before the handler.
|
|
1371
|
-
// beforeX may set response headers (they persist through the handler's
|
|
1372
|
-
// write), mutate the request, or short-circuit on a >= 400 status.
|
|
1373
|
-
// (Parity with Python/PHP/Ruby, whose Router.use class middleware runs.)
|
|
1374
|
-
const globalMiddleware = [
|
|
1375
|
-
...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
|
|
1376
|
-
];
|
|
1377
|
-
if (globalMiddleware.length > 0) {
|
|
1378
|
-
const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
|
|
1379
|
-
if (!proceed || res.raw.writableEnded) {
|
|
1380
|
-
// AFTER-ON-4xx RULE (M2): after_* ALWAYS run even when a before_*
|
|
1381
|
-
// short-circuited (4xx / clean 500 / response ended), so they can
|
|
1382
|
-
// still add headers / logging. Run them, then stop the handler.
|
|
1383
|
-
await MiddlewareRunner.runAfter(globalMiddleware, req, res);
|
|
1384
|
-
if (!res.raw.writableEnded) res.raw.end();
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
// Run per-route middlewares if any
|
|
1390
|
-
if (match.middlewares && match.middlewares.length > 0) {
|
|
1391
|
-
const proceed = await runRouteMiddlewares(match.middlewares, req, res);
|
|
1392
|
-
if (!proceed || res.raw.writableEnded) return;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// Auth enforcement: secure routes require a valid token. Extracted into
|
|
1396
|
-
// enforceRouteAuth (authGate.ts) so the in-process TestClient enforces
|
|
1397
|
-
// the EXACT same gate (parity with Python #PY2 — a tokenless write must
|
|
1398
|
-
// 401 in tests too, or a green test hides a live 401). Dev admin routes
|
|
1399
|
-
// (/__dev) are always public. Returns true when a 401 was written.
|
|
1400
|
-
const isDevAdmin = pathname.startsWith("/__dev");
|
|
1401
|
-
if (enforceRouteAuth(req, res, match, isDevAdmin)) {
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// Inject path params by name into handler arguments, then request/response
|
|
1406
|
-
let result: unknown;
|
|
1407
|
-
const routeParams = req.params || {};
|
|
1408
|
-
const fnStr = match.handler.toString();
|
|
1409
|
-
const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
1410
|
-
const argNames = argMatch?.[1]?.split(",").map((s: string) => s.trim().replace(/[:=].*/,"")) ?? [];
|
|
1411
|
-
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
1412
|
-
|
|
1413
|
-
if (filteredArgs.length === 0) {
|
|
1414
|
-
result = await (match.handler as any)();
|
|
1415
|
-
} else {
|
|
1416
|
-
const args = filteredArgs.map((name: string) => {
|
|
1417
|
-
if (name in routeParams) return routeParams[name];
|
|
1418
|
-
if (name === "request" || name === "req") return req;
|
|
1419
|
-
return res;
|
|
1420
|
-
});
|
|
1421
|
-
result = await (match.handler as any)(...args);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// If the route exports a template and the handler returned a plain object,
|
|
1425
|
-
// render it through the template engine instead of sending as JSON.
|
|
1426
|
-
if (
|
|
1427
|
-
!res.raw.writableEnded &&
|
|
1428
|
-
match.template &&
|
|
1429
|
-
result !== null &&
|
|
1430
|
-
result !== undefined &&
|
|
1431
|
-
typeof result === "object" &&
|
|
1432
|
-
!Buffer.isBuffer(result)
|
|
1433
|
-
) {
|
|
1434
|
-
await res.render(match.template, result as Record<string, unknown>);
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
// Global class-based middleware afterX hooks (logging / post-processing).
|
|
1438
|
-
// Header mutations here are no-ops once the response is flushed (Node
|
|
1439
|
-
// sends headers with the body) — set response headers in beforeX.
|
|
1440
|
-
if (globalMiddleware.length > 0) {
|
|
1441
|
-
await MiddlewareRunner.runAfter(globalMiddleware, req, res);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
if (!res.raw.writableEnded) {
|
|
1445
|
-
res.raw.end();
|
|
1446
|
-
}
|
|
1447
|
-
return;
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// Try serving a template file (e.g. /hello -> src/templates/pages/hello.twig)
|
|
1451
|
-
if ((req.method ?? "GET") === "GET") {
|
|
1452
|
-
const tplFile = resolveTemplate(pathname, templatesDir);
|
|
1453
|
-
if (tplFile) {
|
|
1454
|
-
// Render through Frond so {% include %} / {% extends %} work,
|
|
1455
|
-
// not raw readFileSync.
|
|
1456
|
-
if (frondEngine) {
|
|
1457
|
-
const html = frondEngine.render(tplFile, {});
|
|
1458
|
-
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1459
|
-
res.raw.end(html);
|
|
1460
|
-
} else {
|
|
1461
|
-
const html = readFileSync(resolve(templatesDir, tplFile), "utf-8");
|
|
1462
|
-
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1463
|
-
res.raw.end(html);
|
|
1464
|
-
}
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// Landing page renders only at "/" AND only when TINA4_DEBUG=true.
|
|
1469
|
-
// In production "/" with no static index.html and no pages/index.twig
|
|
1470
|
-
// falls through to a clean 404 — the framework's branded welcome,
|
|
1471
|
-
// gallery and version never leak to real users.
|
|
1472
|
-
if (pathname === "/" && isDevMode()) {
|
|
1473
|
-
const allRoutes = router.getRoutes().map((r) => ({
|
|
1474
|
-
method: r.method,
|
|
1475
|
-
pattern: r.pattern,
|
|
1476
|
-
flags: [] as string[],
|
|
1477
|
-
}));
|
|
1478
|
-
const html = renderLandingPage(allRoutes, port);
|
|
1479
|
-
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1480
|
-
res.raw.end(html);
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// RFC 9110 conformance — before falling through to 404, check whether
|
|
1486
|
-
// the PATH is registered under any OTHER method.
|
|
1487
|
-
// - OPTIONS request → 204 with Allow header (§9.3.7)
|
|
1488
|
-
// - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
|
|
1489
|
-
// → 405 with Allow header (§15.5.6 + §10.2.1)
|
|
1490
|
-
const allowedMethods = router.methodsAllowedForPath(pathname);
|
|
1491
|
-
if (allowedMethods.length > 0) {
|
|
1492
|
-
const allowHeader = allowedMethods.join(", ");
|
|
1493
|
-
const requestMethod = (req.method ?? "GET").toUpperCase();
|
|
1494
|
-
if (requestMethod === "OPTIONS") {
|
|
1495
|
-
res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" });
|
|
1496
|
-
res.raw.end();
|
|
1497
|
-
return;
|
|
1498
|
-
}
|
|
1499
|
-
const body = JSON.stringify({
|
|
1500
|
-
error: "Method Not Allowed",
|
|
1501
|
-
path: pathname,
|
|
1502
|
-
method: requestMethod,
|
|
1503
|
-
allow: allowedMethods,
|
|
1504
|
-
statusCode: 405,
|
|
1505
|
-
});
|
|
1506
|
-
res.raw.writeHead(405, httpReason(405), {
|
|
1507
|
-
Allow: allowHeader,
|
|
1508
|
-
"Content-Type": "application/json",
|
|
1509
|
-
"Content-Length": String(Buffer.byteLength(body)),
|
|
1878
|
+
matchedPattern.value = match.pattern;
|
|
1879
|
+
await runMatchedRoute({
|
|
1880
|
+
req, res, pathname, match, postMatchMiddleware,
|
|
1881
|
+
allGlobalMiddleware: [...preMatchMiddleware, ...postMatchMiddleware],
|
|
1510
1882
|
});
|
|
1511
|
-
res.raw.end(body);
|
|
1512
1883
|
return;
|
|
1513
1884
|
}
|
|
1514
1885
|
|
|
1515
|
-
//
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1886
|
+
// NOT-FOUND FALLBACK STAGES. Nothing matched a route, so walk the
|
|
1887
|
+
// fallback chain in order - see FALLBACK_STAGES. Each returns true when
|
|
1888
|
+
// it has answered the request.
|
|
1889
|
+
//
|
|
1890
|
+
// ADR-0010 (routes beat files) is why this chain runs AFTER matching: a
|
|
1891
|
+
// file dropped into public/ by a build step or a careless deploy must
|
|
1892
|
+
// never shadow a reviewed route.
|
|
1893
|
+
const fallback: FallbackContext = {
|
|
1894
|
+
req, res, pathname, router, port, staticDir, srcPublicDir,
|
|
1895
|
+
templatesDir, frondEngine, swaggerAssetsEnabled,
|
|
1896
|
+
};
|
|
1897
|
+
for (const stage of FALLBACK_STAGES) {
|
|
1898
|
+
if (await stage(fallback)) return;
|
|
1522
1899
|
}
|
|
1523
1900
|
} catch (err) {
|
|
1524
|
-
|
|
1525
|
-
// Listeners get the canonical {exception, request} payload mirrored
|
|
1526
|
-
// by Python / PHP / Ruby. Listener errors are swallowed + warning-
|
|
1527
|
-
// logged so a broken listener can't break the 500 page.
|
|
1528
|
-
Log.error(`Route error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, {
|
|
1529
|
-
method: req?.method,
|
|
1530
|
-
path: req?.path,
|
|
1531
|
-
});
|
|
1532
|
-
try {
|
|
1533
|
-
const { Events } = await import("./events.js");
|
|
1534
|
-
Events.emit("tina4.request.error", { exception: err, request: req });
|
|
1535
|
-
} catch (listenerErr) {
|
|
1536
|
-
try {
|
|
1537
|
-
Log.warn(
|
|
1538
|
-
`Listener for tina4.request.error raised: ${
|
|
1539
|
-
listenerErr instanceof Error
|
|
1540
|
-
? `${listenerErr.name}: ${listenerErr.message}`
|
|
1541
|
-
: String(listenerErr)
|
|
1542
|
-
}`
|
|
1543
|
-
);
|
|
1544
|
-
} catch {
|
|
1545
|
-
// Log failures must never block the 500 render.
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
if (!res.raw.writableEnded) {
|
|
1550
|
-
if (isDevMode() && err instanceof Error) {
|
|
1551
|
-
// Rich error overlay with stack trace, source context, and line numbers
|
|
1552
|
-
const { renderErrorOverlay } = await import("./errorOverlay.js");
|
|
1553
|
-
const overlayHtml = renderErrorOverlay(err, req);
|
|
1554
|
-
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1555
|
-
res.raw.end(overlayHtml);
|
|
1556
|
-
} else {
|
|
1557
|
-
// v3.13.7 SECURITY (CWE-209): production response body must NOT
|
|
1558
|
-
// contain the stack trace or exception message. Pass an empty
|
|
1559
|
-
// error_message — the 500.twig template only renders the trace
|
|
1560
|
-
// block when error_message is truthy.
|
|
1561
|
-
const html500 = await renderErrorPage(500, {
|
|
1562
|
-
error_message: "",
|
|
1563
|
-
request_id: `${Date.now().toString(36)}`,
|
|
1564
|
-
path: req.path,
|
|
1565
|
-
}, templatesDir);
|
|
1566
|
-
if (html500) {
|
|
1567
|
-
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1568
|
-
res.raw.end(html500);
|
|
1569
|
-
} else {
|
|
1570
|
-
res({ error: "Internal Server Error", statusCode: 500 }, 500);
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1901
|
+
await renderDispatchError(err, req, res, templatesDir);
|
|
1574
1902
|
}
|
|
1575
1903
|
}
|
|
1576
1904
|
|
|
@@ -1651,7 +1979,30 @@ ${reset}
|
|
|
1651
1979
|
let aiServer: ReturnType<typeof createServer> | null = null;
|
|
1652
1980
|
let testPort = port + 1000;
|
|
1653
1981
|
|
|
1654
|
-
|
|
1982
|
+
// A DERIVED port is still a port. `port + 1000` leaves the legal range as
|
|
1983
|
+
// soon as the base port is above 64535, and Node's listen() validates the
|
|
1984
|
+
// number and throws ERR_SOCKET_BAD_PORT SYNCHRONOUSLY — it is not an
|
|
1985
|
+
// "error" event, so the handler below never sees it. Thrown here it
|
|
1986
|
+
// escapes this listen callback ABOVE the resolvePromise() at the end of
|
|
1987
|
+
// it, and in debug mode devAdmin's ErrorTracker has already installed an
|
|
1988
|
+
// uncaughtException handler that only RECORDS the error. Net effect,
|
|
1989
|
+
// measured: the main port stayed bound and served traffic while
|
|
1990
|
+
// `await startServer(...)` never settled — a half-started server that
|
|
1991
|
+
// hangs the caller with nothing printed. `PORT=65000 TINA4_DEBUG=true`
|
|
1992
|
+
// was enough to trigger it; in the test suite an OS-assigned ephemeral
|
|
1993
|
+
// base port (macOS hands out 49152-65535) hit it about one run in
|
|
1994
|
+
// sixteen and the whole file vanished from the counts.
|
|
1995
|
+
const aiPortInRange = testPort <= 65535;
|
|
1996
|
+
|
|
1997
|
+
if (isDebug && !noAiPort && !aiPortInRange) {
|
|
1998
|
+
Log.warning(
|
|
1999
|
+
`Stable AI/test port ${testPort} is out of range (a port must be <= 65535), ` +
|
|
2000
|
+
`so it is disabled for base port ${port}. Use a base port of 64535 or lower, ` +
|
|
2001
|
+
`or set TINA4_NO_AI_PORT=true to silence this.`,
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
if (isDebug && !noAiPort && aiPortInRange) {
|
|
1655
2006
|
// Stable AI port (port+1000): tag requests so /__dev_reload + toolbar are suppressed.
|
|
1656
2007
|
aiServer = createServer(async (req, res) => {
|
|
1657
2008
|
(req as any)._tina4AiPort = true;
|
|
@@ -1679,8 +2030,12 @@ ${reset}
|
|
|
1679
2030
|
aiServer.listen(testPort, host);
|
|
1680
2031
|
}
|
|
1681
2032
|
|
|
1682
|
-
// Banner goes to stdout via console.log — NOT through the framework logger
|
|
1683
|
-
|
|
2033
|
+
// Banner goes to stdout via console.log — NOT through the framework logger.
|
|
2034
|
+
// Only advertise the test port when one was actually attempted: an
|
|
2035
|
+
// out-of-range derived port is not bound, and printing it would send a
|
|
2036
|
+
// developer to a URL that cannot exist (same rule as the swagger/dashboard
|
|
2037
|
+
// lines below).
|
|
2038
|
+
const dualPortLines = (isDebug && !noAiPort && aiPortInRange)
|
|
1684
2039
|
? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)`
|
|
1685
2040
|
: "";
|
|
1686
2041
|
|
|
@@ -1711,8 +2066,118 @@ ${reset}
|
|
|
1711
2066
|
// Open the browser on the MAIN port — that's the hot-reload port.
|
|
1712
2067
|
openBrowser(`http://${displayHost}:${port}`);
|
|
1713
2068
|
}
|
|
2069
|
+
// ── Graceful shutdown ─────────────────────────────────────────────
|
|
2070
|
+
// A container orchestrator sends SIGTERM and SIGKILLs after a grace
|
|
2071
|
+
// period, so dropping in-flight requests here is a production defect,
|
|
2072
|
+
// not a style question. The order below mirrors Python/PHP/Ruby:
|
|
2073
|
+
// stop accepting -> let in-flight requests finish -> release resources
|
|
2074
|
+
// -> exit 0.
|
|
2075
|
+
//
|
|
2076
|
+
// Two traps this replaces, both measured against a real signal:
|
|
2077
|
+
//
|
|
2078
|
+
// 1. Nothing here trapped the signal at all, so a plain startServer()
|
|
2079
|
+
// app died on SIGTERM's DEFAULT disposition: process gone in ~150ms,
|
|
2080
|
+
// every in-flight response dropped, exit 143.
|
|
2081
|
+
// 2. `server.close()` is ASYNCHRONOUS and, in Node's own words, "keeps
|
|
2082
|
+
// existing connections". The CLI's `server.close(); process.exit(0)`
|
|
2083
|
+
// therefore killed the very requests close() was waiting to drain.
|
|
2084
|
+
// The close CALLBACK is the only honest "everything drained" signal.
|
|
2085
|
+
let shuttingDown = false;
|
|
2086
|
+
|
|
2087
|
+
const closeListeners = (): Promise<void> =>
|
|
2088
|
+
new Promise((done) => {
|
|
2089
|
+
let pending = aiServer ? 2 : 1;
|
|
2090
|
+
const one = (): void => {
|
|
2091
|
+
if (--pending === 0) done();
|
|
2092
|
+
};
|
|
2093
|
+
server.close(one);
|
|
2094
|
+
if (aiServer) aiServer.close(one);
|
|
2095
|
+
// A keep-alive socket with no request on it still counts as an open
|
|
2096
|
+
// connection, so close() would sit on it until the client wandered
|
|
2097
|
+
// off. Without this a fully drained server still burns the whole
|
|
2098
|
+
// shutdown budget.
|
|
2099
|
+
server.closeIdleConnections();
|
|
2100
|
+
aiServer?.closeIdleConnections();
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
const gracefulShutdown = async (signal: string): Promise<void> => {
|
|
2104
|
+
if (shuttingDown) return;
|
|
2105
|
+
shuttingDown = true;
|
|
2106
|
+
Log.info(`Received ${signal}, shutting down gracefully...`);
|
|
2107
|
+
|
|
2108
|
+
stopAllBackgroundTasks();
|
|
2109
|
+
|
|
2110
|
+
// Tell live WebSocket peers we are going away (RFC 6455 s7.4.1 code
|
|
2111
|
+
// 1001) BEFORE closing the listeners. A WS connection never "finishes"
|
|
2112
|
+
// the way a request does, so waiting for one to drain would burn the
|
|
2113
|
+
// whole budget every time; the honest move is a proper close frame so
|
|
2114
|
+
// a tina4-js client reconnects on a schedule instead of erroring on a
|
|
2115
|
+
// socket that simply vanished.
|
|
2116
|
+
const wsClosed =
|
|
2117
|
+
wsRouteManager.closeAll(CLOSE_GOING_AWAY, "server shutting down") +
|
|
2118
|
+
devReloadWs.closeAll(CLOSE_GOING_AWAY, "server shutting down");
|
|
2119
|
+
if (wsClosed > 0) {
|
|
2120
|
+
Log.info(`Closed ${wsClosed} WebSocket connection(s) with 1001 going away`);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Race the drain against the shutdown budget. Whatever is still in
|
|
2124
|
+
// flight when the budget expires gets force-closed: SIGKILL is what
|
|
2125
|
+
// arrives next, so a bounded drain is strictly better than an
|
|
2126
|
+
// unbounded one that the orchestrator truncates anyway.
|
|
2127
|
+
const budgetSeconds = shutdownTimeoutSeconds();
|
|
2128
|
+
let timer: NodeJS.Timeout | undefined;
|
|
2129
|
+
const outcome = await Promise.race([
|
|
2130
|
+
closeListeners().then(() => "drained" as const),
|
|
2131
|
+
new Promise<"timeout">((r) => {
|
|
2132
|
+
timer = setTimeout(() => r("timeout"), budgetSeconds * 1000);
|
|
2133
|
+
timer.unref();
|
|
2134
|
+
}),
|
|
2135
|
+
]);
|
|
2136
|
+
if (timer) clearTimeout(timer);
|
|
2137
|
+
|
|
2138
|
+
if (outcome === "timeout") {
|
|
2139
|
+
Log.warning(
|
|
2140
|
+
`Shutdown timeout (${budgetSeconds}s) reached with requests still in flight - forcing close`,
|
|
2141
|
+
);
|
|
2142
|
+
server.closeAllConnections();
|
|
2143
|
+
aiServer?.closeAllConnections();
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
try {
|
|
2147
|
+
const orm = await import("../../orm/src/index.js");
|
|
2148
|
+
await orm.closeDatabase();
|
|
2149
|
+
} catch {
|
|
2150
|
+
/* ORM never initialised - nothing to close */
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
Log.info("Server stopped.");
|
|
2154
|
+
// Exit 0: this process was ASKED to stop and did so cleanly. 128+signum
|
|
2155
|
+
// is what waitpid reports for a process killed BY a signal, i.e. one
|
|
2156
|
+
// that did NOT handle it - it is a diagnosis, not a target. Gunicorn
|
|
2157
|
+
// and Puma both halt 0 on a handled TERM, and a container exiting 0 is
|
|
2158
|
+
// a clean termination rather than a signal-kill.
|
|
2159
|
+
process.exit(0);
|
|
2160
|
+
};
|
|
2161
|
+
|
|
2162
|
+
const onSigterm = (): void => {
|
|
2163
|
+
void gracefulShutdown("SIGTERM");
|
|
2164
|
+
};
|
|
2165
|
+
const onSigint = (): void => {
|
|
2166
|
+
void gracefulShutdown("SIGINT");
|
|
2167
|
+
};
|
|
2168
|
+
process.on("SIGTERM", onSigterm);
|
|
2169
|
+
process.on("SIGINT", onSigint);
|
|
2170
|
+
|
|
2171
|
+
const loopWatchdog = startLoopWatchdog();
|
|
2172
|
+
|
|
1714
2173
|
resolvePromise({
|
|
1715
2174
|
close: () => {
|
|
2175
|
+
loopWatchdog.stop();
|
|
2176
|
+
// An explicit close() is not a signal shutdown: drop the handlers so
|
|
2177
|
+
// a test that starts many servers in one process does not pile up
|
|
2178
|
+
// listeners (and trip Node's MaxListeners warning).
|
|
2179
|
+
process.off("SIGTERM", onSigterm);
|
|
2180
|
+
process.off("SIGINT", onSigint);
|
|
1716
2181
|
// Clear any registered background timers so graceful shutdown actually exits.
|
|
1717
2182
|
stopAllBackgroundTasks();
|
|
1718
2183
|
if (aiServer) aiServer.close();
|