tina4-nodejs 3.13.94 → 3.13.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CLAUDE.md +157 -28
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/packages/cli/dist/bin.js +32418 -29638
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +32364 -29501
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/dispatchPipeline.ts +285 -0
  14. package/packages/core/src/dotenv.ts +185 -40
  15. package/packages/core/src/index.ts +5 -4
  16. package/packages/core/src/logger.ts +257 -36
  17. package/packages/core/src/mcp.ts +1 -1
  18. package/packages/core/src/messenger.ts +9 -13
  19. package/packages/core/src/metrics.ts +199 -961
  20. package/packages/core/src/middleware.ts +390 -123
  21. package/packages/core/src/queue.ts +188 -32
  22. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  23. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  24. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  25. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  26. package/packages/core/src/rateLimiter.ts +10 -5
  27. package/packages/core/src/request.ts +6 -9
  28. package/packages/core/src/response.ts +46 -1
  29. package/packages/core/src/router.ts +29 -4
  30. package/packages/core/src/server.ts +751 -414
  31. package/packages/core/src/session.ts +244 -27
  32. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  33. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  34. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  36. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  37. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  38. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  39. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  40. package/packages/core/src/testClient.ts +18 -5
  41. package/packages/core/src/trustedProxy.ts +249 -0
  42. package/packages/core/src/types.ts +29 -5
  43. package/packages/core/src/websocket.ts +66 -0
  44. package/packages/orm/dist/index.js +22367 -19504
  45. package/packages/orm/src/adapters/firebird.ts +183 -56
  46. package/packages/orm/src/adapters/mongodb.ts +25 -4
  47. package/packages/orm/src/adapters/mssql.ts +114 -29
  48. package/packages/orm/src/adapters/mysql.ts +103 -40
  49. package/packages/orm/src/adapters/odbc.ts +44 -21
  50. package/packages/orm/src/adapters/postgres.ts +118 -26
  51. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  52. package/packages/orm/src/adapters/sqlite.ts +60 -24
  53. package/packages/orm/src/baseModel.ts +135 -40
  54. package/packages/orm/src/cachedDatabase.ts +43 -19
  55. package/packages/orm/src/connectTimeout.ts +265 -0
  56. package/packages/orm/src/database.ts +237 -197
  57. package/packages/orm/src/databaseResult.ts +65 -13
  58. package/packages/orm/src/databaseUrl.ts +484 -0
  59. package/packages/orm/src/docstore.ts +386 -145
  60. package/packages/orm/src/index.ts +13 -3
  61. package/packages/orm/src/migration.ts +18 -3
  62. package/packages/orm/src/queryBuilder.ts +38 -4
  63. package/packages/orm/src/sqlTranslator.ts +310 -4
  64. package/packages/orm/src/types.ts +15 -4
  65. package/types/core/src/ai.d.ts +1 -1
  66. package/types/core/src/auth.d.ts +28 -5
  67. package/types/core/src/background.d.ts +3 -3
  68. package/types/core/src/cache.d.ts +15 -12
  69. package/types/core/src/dispatchPipeline.d.ts +117 -0
  70. package/types/core/src/dotenv.d.ts +38 -16
  71. package/types/core/src/index.d.ts +5 -6
  72. package/types/core/src/logger.d.ts +93 -16
  73. package/types/core/src/messenger.d.ts +2 -2
  74. package/types/core/src/metrics.d.ts +25 -61
  75. package/types/core/src/middleware.d.ts +134 -11
  76. package/types/core/src/queue.d.ts +54 -5
  77. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  78. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  79. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  80. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  81. package/types/core/src/router.d.ts +14 -3
  82. package/types/core/src/server.d.ts +15 -0
  83. package/types/core/src/session.d.ts +87 -2
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  85. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  86. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  87. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  88. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  89. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  90. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  91. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  92. package/types/core/src/trustedProxy.d.ts +44 -0
  93. package/types/core/src/types.d.ts +28 -5
  94. package/types/core/src/websocket.d.ts +26 -0
  95. package/types/orm/src/adapters/firebird.d.ts +55 -10
  96. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  97. package/types/orm/src/adapters/mssql.d.ts +18 -11
  98. package/types/orm/src/adapters/mysql.d.ts +11 -10
  99. package/types/orm/src/adapters/odbc.d.ts +9 -12
  100. package/types/orm/src/adapters/postgres.d.ts +11 -10
  101. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  102. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  103. package/types/orm/src/baseModel.d.ts +45 -9
  104. package/types/orm/src/cachedDatabase.d.ts +18 -5
  105. package/types/orm/src/connectTimeout.d.ts +100 -0
  106. package/types/orm/src/database.d.ts +72 -26
  107. package/types/orm/src/databaseResult.d.ts +24 -0
  108. package/types/orm/src/databaseUrl.d.ts +125 -0
  109. package/types/orm/src/docstore.d.ts +102 -43
  110. package/types/orm/src/index.d.ts +5 -2
  111. package/types/orm/src/queryBuilder.d.ts +23 -3
  112. package/types/orm/src/sqlTranslator.d.ts +126 -2
  113. package/types/orm/src/types.d.ts +14 -4
  114. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  115. 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
  *
@@ -754,7 +787,6 @@ let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<v
754
787
  // not installed). Memoised so the dynamic import happens once, then every
755
788
  // request reuses the resolved function — see the request-scoped cache boundary
756
789
  // in dispatch().
757
- let _resetRequestCaches: Promise<(() => void) | null> | undefined;
758
790
 
759
791
  /** Module-level server handle for start()/stop() parity. */
760
792
  let _serverHandle: { close: () => void; router: Router; port: number } | null = null;
@@ -809,6 +841,526 @@ export async function handle(rawReq: IncomingMessage, rawRes: ServerResponse): P
809
841
  return _dispatchFn(rawReq, rawRes);
810
842
  }
811
843
 
844
+ /**
845
+ * Run one global-middleware pass.
846
+ *
847
+ * The pre-match and post-match passes had byte-identical bodies; this is that
848
+ * body, once.
849
+ *
850
+ * AFTER-ON-4xx RULE (M2): the after_* hooks ALWAYS run when a before_*
851
+ * short-circuited (4xx, a clean 500, or the response already ended), so they
852
+ * can still add headers and logging. Consistent across all four frameworks.
853
+ *
854
+ * @returns true when the pass answered the request and the handler must be skipped
855
+ */
856
+ async function runGlobalMiddlewarePass(
857
+ middleware: unknown[],
858
+ req: Tina4Request,
859
+ res: Tina4Response,
860
+ ): Promise<boolean> {
861
+ if (middleware.length === 0) return false;
862
+
863
+ const [, , proceed] = await MiddlewareRunner.runBefore(middleware as never, req, res);
864
+ if (proceed && !res.raw.writableEnded) return false;
865
+
866
+ await MiddlewareRunner.runAfter(middleware as never, req, res);
867
+ if (!res.raw.writableEnded) res.raw.end();
868
+ return true;
869
+ }
870
+
871
+ /**
872
+ * Invoke a matched route's handler, binding path params BY NAME.
873
+ *
874
+ * A handler declares whatever it needs - `(id, request, response)`, `(req, res)`,
875
+ * or nothing - and each parameter is resolved by its name: a path param wins,
876
+ * then `request`/`req`, then the response.
877
+ */
878
+ async function invokeRouteHandler(
879
+ match: { handler: unknown },
880
+ req: Tina4Request,
881
+ res: Tina4Response,
882
+ ): Promise<unknown> {
883
+ const routeParams = req.params || {};
884
+ const fnStr = (match.handler as { toString(): string }).toString();
885
+ const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
886
+ const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
887
+ const filteredArgs = argNames.filter((n: string) => n.length > 0);
888
+
889
+ if (filteredArgs.length === 0) return await (match.handler as any)();
890
+
891
+ const args = filteredArgs.map((name: string) => {
892
+ if (name in routeParams) return routeParams[name];
893
+ if (name === "request" || name === "req") return req;
894
+ return res;
895
+ });
896
+ return await (match.handler as any)(...args);
897
+ }
898
+
899
+ /**
900
+ * Render a template route's return value, when it is one.
901
+ *
902
+ * A route that exports a template AND whose handler returned a plain object
903
+ * renders through the template engine instead of being sent as JSON. Every
904
+ * other shape - a handler that already wrote, no template, null/undefined, a
905
+ * string, a Buffer - is left exactly as it was.
906
+ */
907
+ async function renderIfTemplateRoute(
908
+ match: { template?: string },
909
+ res: Tina4Response,
910
+ result: unknown,
911
+ ): Promise<void> {
912
+ if (res.raw.writableEnded) return;
913
+ if (!match.template) return;
914
+ if (result === null || result === undefined) return;
915
+ if (typeof result !== "object" || Buffer.isBuffer(result)) return;
916
+
917
+ await res.render(match.template, result as Record<string, unknown>);
918
+ }
919
+
920
+ /** State the matched-route pipeline needs. */
921
+ interface MatchedRouteContext {
922
+ req: Tina4Request;
923
+ res: Tina4Response;
924
+ pathname: string;
925
+ match: { params?: Record<string, unknown>; handler: unknown; template?: string; middlewares?: unknown[] };
926
+ postMatchMiddleware: unknown[];
927
+ /**
928
+ * EVERY global middleware, both phases. The after pass runs over all of it,
929
+ * not just the post-match group - see runMatchedRoute.
930
+ */
931
+ allGlobalMiddleware: unknown[];
932
+ }
933
+
934
+ /**
935
+ * Run a matched route end to end.
936
+ *
937
+ * Order, and it is BEHAVIOUR (ADR-0012):
938
+ * post-match globals -> auth gate -> the route's own middleware -> handler
939
+ *
940
+ * The globals run BEFORE the gate so a rate limiter can throttle a brute-force
941
+ * login and an access log records the 401 - neither is possible if they only
942
+ * run on authenticated requests (Django enforces auth in a view decorator after
943
+ * all MIDDLEWARE; Laravel's `web` group runs before the `auth` route
944
+ * middleware; ASP.NET puts UseAuthorization last before the endpoint).
945
+ *
946
+ * The route's OWN middleware runs AFTER, so middleware attached to a secured
947
+ * route never processes an unauthenticated request. Node used to run it first,
948
+ * which meant a body-parsing or audit middleware on a secured route saw traffic
949
+ * that was about to be rejected.
950
+ */
951
+ async function runMatchedRoute(ctx: MatchedRouteContext): Promise<void> {
952
+ const { req, res, match, postMatchMiddleware } = ctx;
953
+ req.params = match.params as never;
954
+
955
+ if (await runGlobalMiddlewarePass(postMatchMiddleware, req, res)) return;
956
+
957
+ // Auth enforcement lives in enforceRouteAuth (authGate.ts) so the in-process
958
+ // TestClient enforces the EXACT same gate - parity with Python #PY2, where a
959
+ // tokenless write must 401 in tests too, or a green test hides a live 401.
960
+ // Dev admin routes (/__dev) are always public.
961
+ if (enforceRouteAuth(req, res, match as never, ctx.pathname.startsWith("/__dev"))) return;
962
+
963
+ // The route's OWN class middleware: its beforeX hooks run inside
964
+ // runRouteMiddlewares, its afterX hooks join the after pass below - one
965
+ // effective list for the response phase, the way Python merges the globals
966
+ // and the route's middleware into `_effective_middleware` for both passes.
967
+ const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
968
+
969
+ let handlerSkipped = false;
970
+ if (match.middlewares && match.middlewares.length > 0) {
971
+ const proceed = await runRouteMiddlewares(match.middlewares as never, req, res);
972
+ handlerSkipped = !proceed || res.raw.writableEnded;
973
+ }
974
+
975
+ if (!handlerSkipped) {
976
+ await renderIfTemplateRoute(match, res, await invokeRouteHandler(match, req, res));
977
+ }
978
+
979
+ // Global afterX hooks (logging / post-processing), over EVERY global
980
+ // middleware - both phases, not just the post-match group - PLUS the route's
981
+ // own middleware classes.
982
+ //
983
+ // The response phase must cover everything the request phase entered, so it
984
+ // also runs when the ROUTE's middleware short-circuited - the after hooks of a
985
+ // middleware whose before hook denied the request are exactly what add the
986
+ // headers and the access-log line for that denial. Dispatch used to return
987
+ // early there, so a cache HIT (a route middleware that answers and ends)
988
+ // skipped every global after hook, and a route middleware that short-circuited
989
+ // WITHOUT ending the response left the request hanging with no end() at all.
990
+ //
991
+ // Running only the post-match group meant a `preMatch` middleware's afterX NEVER ran
992
+ // on a successful request: measured 0 runs in 5 requests. An acquire/release
993
+ // pair leaked one slot per request, unbounded; a timer started in beforeX was
994
+ // never stopped; an access log saw the request and never the response - the
995
+ // very hole ADR-0012 moved the globals ahead of the auth gate to close.
996
+ //
997
+ // Worse, it inverted: the pre-match afterX DID run when the pre-match pass
998
+ // short-circuited, so it fired on the error path and not the happy one.
999
+ //
1000
+ // Django unwinds its single MIDDLEWARE list in reverse, Laravel runs the
1001
+ // response/terminate phase for global, group AND route middleware, Rails runs
1002
+ // every declared after_action, ASP.NET unwinds through every component
1003
+ // entered. Ruby and PHP already did this. Splitting the BEFORE pass by
1004
+ // dependency (ADR-0012) says nothing about the after pass: an after hook adds
1005
+ // headers or logging and needs no route metadata either way.
1006
+ //
1007
+ // No double-run: when the pre-match pass short-circuits, dispatch returns
1008
+ // before ever reaching this.
1009
+ //
1010
+ // Header mutations here are no-ops once the response is flushed - Node sends
1011
+ // headers with the body - so response headers belong in beforeX.
1012
+ const afterMiddleware = [...ctx.allGlobalMiddleware, ...routeMiddlewareClasses];
1013
+ if (afterMiddleware.length > 0) {
1014
+ await MiddlewareRunner.runAfter(afterMiddleware as never, req, res);
1015
+ }
1016
+
1017
+ if (!res.raw.writableEnded) res.raw.end();
1018
+ }
1019
+
1020
+ /** State the response-end wrappers need. */
1021
+ interface ResponseWrapContext {
1022
+ req: Tina4Request;
1023
+ res: Tina4Response;
1024
+ pathname: string;
1025
+ router: Router;
1026
+ reqStartTime: number;
1027
+ requestId: string;
1028
+ /** Holder, because the wrapper reads this at end() time - after route matching. */
1029
+ matchedPattern: { value: string };
1030
+ isAiPortRequest: boolean;
1031
+ }
1032
+
1033
+ /**
1034
+ * Block /__dev_reload on the AI port so AI tools never trigger a browser reload.
1035
+ *
1036
+ * @returns true when the request was answered
1037
+ */
1038
+ function blockAiPortReload(res: Tina4Response, pathname: string, isAiPortRequest: boolean): boolean {
1039
+ if (!isAiPortRequest || pathname !== "/__dev_reload") return false;
1040
+
1041
+ res.raw.writeHead(404, { "Content-Type": "application/json" });
1042
+ res.raw.end(JSON.stringify({ error: "Not available on AI port" }));
1043
+ return true;
1044
+ }
1045
+
1046
+ /**
1047
+ * Rebuild the arguments `end` was called with.
1048
+ *
1049
+ * Node's `end` has three overloads and the wrappers must forward exactly the
1050
+ * shape they were given, or a callback lands in the encoding slot.
1051
+ */
1052
+ function callOriginalEnd(
1053
+ originalEnd: (...args: any[]) => any,
1054
+ chunk: unknown,
1055
+ encodingOrCb?: BufferEncoding | (() => void),
1056
+ cb?: () => void,
1057
+ ): any {
1058
+ if (typeof encodingOrCb === "function") return originalEnd(chunk, encodingOrCb);
1059
+ if (encodingOrCb !== undefined) return originalEnd(chunk, encodingOrCb, cb);
1060
+ return originalEnd(chunk, cb);
1061
+ }
1062
+
1063
+ /** Whether the response is declaring itself as HTML. */
1064
+ function isHtmlResponse(res: Tina4Response): boolean {
1065
+ const contentType = res.raw.getHeader("content-type");
1066
+ return typeof contentType === "string" && contentType.includes("text/html");
1067
+ }
1068
+
1069
+ /** The chunk as an HTML string, or null when it is neither a string nor a Buffer. */
1070
+ function asHtmlString(chunk: unknown): string | null {
1071
+ if (typeof chunk === "string") return chunk;
1072
+ if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
1073
+ return null;
1074
+ }
1075
+
1076
+ /**
1077
+ * Inject the dev toolbar (dev mode only) and the feedback widget into an HTML body.
1078
+ *
1079
+ * The feedback injector re-checks the whitelist, path and html marker itself,
1080
+ * so calling it unconditionally is cheap when it no-ops.
1081
+ */
1082
+ function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: string): string {
1083
+ if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
1084
+
1085
+ const toolbarCtx: DevToolbarContext = {
1086
+ version: TINA4_VERSION,
1087
+ method: ctx.req.method ?? "GET",
1088
+ path: ctx.pathname,
1089
+ matchedPattern: ctx.matchedPattern.value || ctx.pathname,
1090
+ requestId: ctx.requestId,
1091
+ routeCount: ctx.router.getRoutes().length,
1092
+ };
1093
+ return injectFeedbackWidget(ctx.req, injectDevToolbar(html, toolbarCtx));
1094
+ }
1095
+
1096
+ /**
1097
+ * Wrap `res.raw.end` to inject the dev toolbar and/or the feedback widget, and
1098
+ * to capture the request for the dev inspector.
1099
+ *
1100
+ * Two modes, and the distinction is deliberate:
1101
+ * * dev mode (off the AI port, outside /__dev) gets the toolbar, the feedback
1102
+ * widget and inspector capture;
1103
+ * * otherwise a whitelisted user still gets the feedback widget alone. The
1104
+ * injector re-checks the whitelist, path and html marker itself, so the
1105
+ * wrapper is cheap when it no-ops.
1106
+ *
1107
+ * Content-Length is removed on injection because the body size changes.
1108
+ */
1109
+ function wrapResponseEnd(ctx: ResponseWrapContext): void {
1110
+ const { req, res, pathname } = ctx;
1111
+ const devToolbar = isDevMode() && !pathname.startsWith("/__dev") && !ctx.isAiPortRequest;
1112
+ const feedbackOnly =
1113
+ !devToolbar &&
1114
+ feedbackEnabled() &&
1115
+ !pathname.startsWith("/__dev") &&
1116
+ !pathname.startsWith("/__feedback");
1117
+
1118
+ if (!devToolbar && !feedbackOnly) return;
1119
+
1120
+ const originalEnd = res.raw.end.bind(res.raw);
1121
+ res.raw.end = function (
1122
+ chunk?: unknown,
1123
+ encodingOrCb?: BufferEncoding | (() => void),
1124
+ cb?: () => void,
1125
+ ) {
1126
+ if (devToolbar && ctx.reqStartTime > 0) {
1127
+ RequestInspector.capture(
1128
+ req.method ?? "GET",
1129
+ pathname,
1130
+ res.raw.statusCode ?? 200,
1131
+ Date.now() - ctx.reqStartTime,
1132
+ );
1133
+ }
1134
+
1135
+ if (isHtmlResponse(res)) {
1136
+ const html = asHtmlString(chunk);
1137
+ if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
1138
+ // Dropped for ANY html response, not only one carrying a body: that is
1139
+ // what the two original wrappers did, and a refactor does not get to
1140
+ // narrow it. An end() with no chunk on a text/html response still has
1141
+ // its stale content-length removed.
1142
+ if (!res.raw.headersSent) res.raw.removeHeader("content-length");
1143
+ }
1144
+
1145
+ return callOriginalEnd(originalEnd, chunk, encodingOrCb, cb);
1146
+ } as typeof res.raw.end;
1147
+ }
1148
+
1149
+ /**
1150
+ * Turn an uncaught dispatch error into a response, and surface it.
1151
+ *
1152
+ * v3.13.7: log structured + surface to observability BEFORE rendering.
1153
+ * Listeners get the canonical {exception, request} payload mirrored by Python /
1154
+ * PHP / Ruby. Listener errors are swallowed and warning-logged so a broken
1155
+ * listener cannot break the 500 page.
1156
+ *
1157
+ * SECURITY (CWE-209): the production response body must NOT contain the stack
1158
+ * trace or the exception message. `error_message` is passed empty - 500.twig
1159
+ * only renders the trace block when it is truthy. The rich overlay with stack
1160
+ * and source context is dev-only.
1161
+ *
1162
+ * @param err The thrown value (not necessarily an Error)
1163
+ * @param req The request, for the log line and the error page
1164
+ * @param res The response; untouched if it has already ended
1165
+ * @param templatesDir Where to look for 500.twig
1166
+ */
1167
+ async function renderDispatchError(
1168
+ err: unknown,
1169
+ req: Tina4Request,
1170
+ res: Tina4Response,
1171
+ templatesDir: string,
1172
+ ): Promise<void> {
1173
+ Log.error(`Route error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, {
1174
+ method: req?.method,
1175
+ path: req?.path,
1176
+ });
1177
+
1178
+ try {
1179
+ const { Events } = await import("./events.js");
1180
+ Events.emit("tina4.request.error", { exception: err, request: req });
1181
+ } catch (listenerErr) {
1182
+ try {
1183
+ Log.warn(
1184
+ `Listener for tina4.request.error raised: ${
1185
+ listenerErr instanceof Error
1186
+ ? `${listenerErr.name}: ${listenerErr.message}`
1187
+ : String(listenerErr)
1188
+ }`
1189
+ );
1190
+ } catch {
1191
+ // Log failures must never block the 500 render.
1192
+ }
1193
+ }
1194
+
1195
+ if (res.raw.writableEnded) return;
1196
+
1197
+ if (isDevMode() && err instanceof Error) {
1198
+ // Rich error overlay with stack trace, source context, and line numbers
1199
+ const { renderErrorOverlay } = await import("./errorOverlay.js");
1200
+ res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1201
+ res.raw.end(renderErrorOverlay(err, req));
1202
+ return;
1203
+ }
1204
+
1205
+ const html500 = await renderErrorPage(500, {
1206
+ error_message: "",
1207
+ request_id: `${Date.now().toString(36)}`,
1208
+ path: req.path,
1209
+ }, templatesDir);
1210
+ if (html500) {
1211
+ res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1212
+ res.raw.end(html500);
1213
+ } else {
1214
+ res({ error: "Internal Server Error", statusCode: 500 }, 500);
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * State the not-found fallback stages need.
1220
+ *
1221
+ * Passed explicitly rather than closed over, so each stage is callable on its
1222
+ * own instead of only from inside `dispatch`. That coupling is what the
1223
+ * extraction removes.
1224
+ */
1225
+ interface FallbackContext {
1226
+ req: Tina4Request;
1227
+ res: Tina4Response;
1228
+ pathname: string;
1229
+ router: Router;
1230
+ port: number;
1231
+ staticDir: string;
1232
+ srcPublicDir: string;
1233
+ templatesDir: string;
1234
+ frondEngine: { render(file: string, data: Record<string, unknown>): string } | null;
1235
+ swaggerAssetsEnabled: boolean;
1236
+ }
1237
+
1238
+ /**
1239
+ * Serve a template file for a GET (e.g. /hello -> src/templates/pages/hello.twig).
1240
+ *
1241
+ * Rendered through Frond so {% include %} / {% extends %} work, rather than a
1242
+ * raw readFileSync.
1243
+ */
1244
+ function serveTemplateFallback(ctx: FallbackContext): boolean {
1245
+ if ((ctx.req.method ?? "GET") !== "GET") return false;
1246
+
1247
+ const tplFile = resolveTemplate(ctx.pathname, ctx.templatesDir);
1248
+ if (!tplFile) return false;
1249
+
1250
+ const html = ctx.frondEngine
1251
+ ? ctx.frondEngine.render(tplFile, {})
1252
+ : readFileSync(resolve(ctx.templatesDir, tplFile), "utf-8");
1253
+ ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
1254
+ ctx.res.raw.end(html);
1255
+ return true;
1256
+ }
1257
+
1258
+ /**
1259
+ * The branded landing page.
1260
+ *
1261
+ * Renders only at "/" AND only when TINA4_DEBUG=true. In production "/" with no
1262
+ * static index.html and no pages/index.twig falls through to a clean 404, so
1263
+ * the framework's welcome, gallery and version never leak to real users.
1264
+ */
1265
+ function serveLandingPage(ctx: FallbackContext): boolean {
1266
+ if ((ctx.req.method ?? "GET") !== "GET") return false;
1267
+ if (ctx.pathname !== "/" || !isDevMode()) return false;
1268
+
1269
+ const allRoutes = ctx.router.getRoutes().map((r) => ({
1270
+ method: r.method,
1271
+ pattern: r.pattern,
1272
+ flags: [] as string[],
1273
+ }));
1274
+ ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
1275
+ ctx.res.raw.end(renderLandingPage(allRoutes, ctx.port));
1276
+ return true;
1277
+ }
1278
+
1279
+ /**
1280
+ * RFC 9110 conformance - before falling through to 404, check whether the PATH
1281
+ * is registered under any OTHER method.
1282
+ * - OPTIONS -> 204 with Allow (s9.3.7)
1283
+ * - Any other method (PUT on GET-only, TRACE, CONNECT) -> 405 with Allow
1284
+ * (s15.5.6 + s10.2.1)
1285
+ */
1286
+ function serveMethodNotAllowed(ctx: FallbackContext): boolean {
1287
+ const allowedMethods = ctx.router.methodsAllowedForPath(ctx.pathname);
1288
+ if (allowedMethods.length === 0) return false;
1289
+
1290
+ const allowHeader = allowedMethods.join(", ");
1291
+ const requestMethod = (ctx.req.method ?? "GET").toUpperCase();
1292
+
1293
+ if (requestMethod === "OPTIONS") {
1294
+ ctx.res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" });
1295
+ ctx.res.raw.end();
1296
+ return true;
1297
+ }
1298
+
1299
+ const body = JSON.stringify({
1300
+ error: "Method Not Allowed",
1301
+ path: ctx.pathname,
1302
+ method: requestMethod,
1303
+ allow: allowedMethods,
1304
+ statusCode: 405,
1305
+ });
1306
+ ctx.res.raw.writeHead(405, httpReason(405), {
1307
+ Allow: allowHeader,
1308
+ "Content-Type": "application/json",
1309
+ "Content-Length": String(Buffer.byteLength(body)),
1310
+ });
1311
+ ctx.res.raw.end(body);
1312
+ return true;
1313
+ }
1314
+
1315
+ /**
1316
+ * No route claimed the path, so NOW try the filesystem (ADR-0010).
1317
+ *
1318
+ * Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work.
1319
+ * The framework-bundled directory holds the Swagger UI (public/swagger/), so
1320
+ * that lookup MUST honour the swagger gate or /swagger is served in production
1321
+ * regardless of TINA4_SWAGGER_ENABLED / TINA4_DEBUG.
1322
+ */
1323
+ function serveStaticAsset(ctx: FallbackContext): boolean {
1324
+ if (existsSync(ctx.staticDir) && tryServeStatic(ctx.staticDir, ctx.req, ctx.res)) return true;
1325
+ if (existsSync(ctx.srcPublicDir) && tryServeStatic(ctx.srcPublicDir, ctx.req, ctx.res)) return true;
1326
+
1327
+ if (ctx.swaggerAssetsEnabled || !isSwaggerAssetPath(ctx.pathname)) {
1328
+ if (tryServeStatic(BUILTIN_PUBLIC_DIR, ctx.req, ctx.res)) return true;
1329
+ }
1330
+ return false;
1331
+ }
1332
+
1333
+ /** Terminal stage: 404, with the canonical reason phrase so the status line is well-formed. */
1334
+ async function serveNotFound(ctx: FallbackContext): Promise<boolean> {
1335
+ const html404 = await renderErrorPage(404, { path: ctx.pathname }, ctx.templatesDir);
1336
+ if (html404) {
1337
+ ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
1338
+ ctx.res.raw.end(html404);
1339
+ } else {
1340
+ ctx.res(
1341
+ { error: "Not Found", statusCode: 404, message: `No route found for ${ctx.req.method} ${ctx.pathname}` },
1342
+ 404,
1343
+ );
1344
+ }
1345
+ return true;
1346
+ }
1347
+
1348
+ /**
1349
+ * The not-found fallback chain, in order. Data, so the pipeline can be read and
1350
+ * compared across frameworks without reading an implementation.
1351
+ *
1352
+ * Order is BEHAVIOUR: a template beats the landing page (so a project's own
1353
+ * pages/index.twig wins at "/"), 405 beats static (a known path with the wrong
1354
+ * method is not a missing file), and the 404 is terminal.
1355
+ */
1356
+ const FALLBACK_STAGES: Array<(ctx: FallbackContext) => boolean | Promise<boolean>> = [
1357
+ serveTemplateFallback,
1358
+ serveLandingPage,
1359
+ serveMethodNotAllowed,
1360
+ serveStaticAsset,
1361
+ serveNotFound,
1362
+ ];
1363
+
812
1364
  export async function startServer(config?: Tina4Config): Promise<{
813
1365
  close: () => void;
814
1366
  router: Router;
@@ -1128,108 +1680,25 @@ ${reset}
1128
1680
  const req = createRequest(rawReq);
1129
1681
  const res = createResponse(rawRes);
1130
1682
 
1131
- // Request-scoped DB query cache boundary: clear the request-scoped cache on
1132
- // every live connection at the START of each request so it never serves
1133
- // rows across requests (persistent-mode connections are left alone). The
1134
- // ORM is loaded lazily and may be absent failures here must never break a
1135
- // request, so this is best-effort. Mirrors Python's dispatcher calling
1136
- // Database.reset_request_caches(). The cached() promise resolves once on
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
- }
1683
+ // PROLOGUE STAGES. Extracted to dispatchPipeline.ts - see PROLOGUE_STAGES
1684
+ // there for the ordered list and why the order is behaviour, not taste.
1685
+ // These three close over nothing from startServer, which is why they went
1686
+ // first: no context object is needed for them at all.
1687
+ await resetRequestCaches();
1688
+ headStripIntercept(rawReq, rawRes);
1229
1689
 
1230
1690
  // res.render() is handled natively by response.ts via Frond
1231
1691
 
1232
1692
  try {
1693
+ // sessionAutoStart is the one prologue stage INSIDE the try. It degrades
1694
+ // on its own (ADR-0021), so the only thing that escapes it is a
1695
+ // TINA4_SESSION_STRICT refusal - and that must become a 500 through the
1696
+ // normal error renderer, like Python's raise becomes a 500 in the ASGI
1697
+ // server. Outside the try it rejected `dispatch`, and nothing awaits the
1698
+ // listener http.createServer() calls: an unhandled rejection that takes
1699
+ // the whole worker down is not "refuse this request".
1700
+ await sessionAutoStart(rawReq, rawRes, req);
1701
+
1233
1702
  // Run middleware chain
1234
1703
  await middleware.run(req, res);
1235
1704
  if (res.raw.writableEnded) return;
@@ -1243,334 +1712,68 @@ ${reset}
1243
1712
  const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
1244
1713
 
1245
1714
  // Mutable ref so wrappedEnd can read the matched pattern after route matching
1246
- let matchedPattern = "";
1715
+ // A HOLDER, not a plain string: the end-wrapper below reads it at end()
1716
+ // time, after route matching has assigned it.
1717
+ const matchedPattern = { value: "" };
1247
1718
  const requestId = Date.now().toString(36);
1248
1719
 
1249
1720
  // Wrap res.raw.end to inject dev toolbar and capture requests
1250
1721
  // Skip toolbar injection on the AI port (no-reload behaviour)
1251
1722
  const isAiPortRequest = !!(rawReq as any)._tina4AiPort;
1252
1723
 
1253
- // AI port: block /__dev_reload so AI tools never trigger a browser reload
1254
- if (isAiPortRequest && pathname === "/__dev_reload") {
1255
- res.raw.writeHead(404, { "Content-Type": "application/json" });
1256
- res.raw.end(JSON.stringify({ error: "Not available on AI port" }));
1257
- return;
1258
- }
1724
+ // AI port: block /__dev_reload so AI tools never trigger a browser reload.
1725
+ if (blockAiPortReload(res, pathname, isAiPortRequest)) return;
1259
1726
 
1260
- if (isDevMode() && !pathname.startsWith("/__dev") && !isAiPortRequest) {
1261
- const originalEnd = res.raw.end.bind(res.raw);
1262
-
1263
- const wrappedEnd: typeof res.raw.end = function (
1264
- chunk?: unknown,
1265
- encodingOrCb?: BufferEncoding | (() => void),
1266
- cb?: () => void,
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
- }
1727
+ // Wrap res.raw.end so the dev toolbar / feedback widget can be injected
1728
+ // and the request captured for the inspector. Extracted - see
1729
+ // wrapResponseEnd. matchedPattern is a HOLDER because the wrapper reads
1730
+ // it at end() time, long after route matching has assigned it.
1731
+ wrapResponseEnd({
1732
+ req, res, pathname, router,
1733
+ reqStartTime, requestId, matchedPattern, isAiPortRequest,
1734
+ });
1274
1735
 
1275
- const contentType = res.raw.getHeader("content-type");
1276
- if (typeof contentType === "string" && contentType.includes("text/html")) {
1277
- const toolbarCtx: DevToolbarContext = {
1278
- version: TINA4_VERSION,
1279
- method: req.method ?? "GET",
1280
- path: pathname,
1281
- matchedPattern: matchedPattern || pathname,
1282
- requestId,
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
- }
1736
+ // Global middleware, split by what it depends on (ADR-0012). The
1737
+ // PRE-match set runs before a route is even looked up, so CORS and
1738
+ // anything else that must survive a short-circuit can set headers that
1739
+ // outlive a 401/403; opt in with `static preMatch = true`.
1740
+ const { pre: preMatchMiddleware, post: postMatchMiddleware } =
1741
+ MiddlewareRunner.partitionByMatchPhase([
1742
+ ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
1743
+ ]);
1344
1744
 
1345
- // Try static files first (project public dir, src/public dir, then framework built-in)
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
- }
1745
+ if (await runGlobalMiddlewarePass(preMatchMiddleware, req, res)) return;
1362
1746
 
1363
- // Match route
1747
+ // Match route. ROUTES BEAT FILES (ADR-0010): static assets resolve in
1748
+ // the not-found fallback below, only once no route has claimed the path.
1749
+ // A file in public/ can arrive from a build step, an upload directory or
1750
+ // a careless deploy, and it must never silently shadow a reviewed route.
1364
1751
  const match = router.match(req.method ?? "GET", pathname);
1365
1752
  if (match) {
1366
- req.params = match.params;
1367
- matchedPattern = match.pattern;
1368
-
1369
- // Global class-based middleware registered via Router.use(...) /
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)),
1753
+ matchedPattern.value = match.pattern;
1754
+ await runMatchedRoute({
1755
+ req, res, pathname, match, postMatchMiddleware,
1756
+ allGlobalMiddleware: [...preMatchMiddleware, ...postMatchMiddleware],
1510
1757
  });
1511
- res.raw.end(body);
1512
1758
  return;
1513
1759
  }
1514
1760
 
1515
- // 404 pass canonical reason phrase so the status line is well-formed
1516
- const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
1517
- if (html404) {
1518
- res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
1519
- res.raw.end(html404);
1520
- } else {
1521
- res({ error: "Not Found", statusCode: 404, message: `No route found for ${req.method} ${pathname}` }, 404);
1761
+ // NOT-FOUND FALLBACK STAGES. Nothing matched a route, so walk the
1762
+ // fallback chain in order - see FALLBACK_STAGES. Each returns true when
1763
+ // it has answered the request.
1764
+ //
1765
+ // ADR-0010 (routes beat files) is why this chain runs AFTER matching: a
1766
+ // file dropped into public/ by a build step or a careless deploy must
1767
+ // never shadow a reviewed route.
1768
+ const fallback: FallbackContext = {
1769
+ req, res, pathname, router, port, staticDir, srcPublicDir,
1770
+ templatesDir, frondEngine, swaggerAssetsEnabled,
1771
+ };
1772
+ for (const stage of FALLBACK_STAGES) {
1773
+ if (await stage(fallback)) return;
1522
1774
  }
1523
1775
  } catch (err) {
1524
- // v3.13.7: log structured + surface to observability BEFORE rendering.
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
- }
1776
+ await renderDispatchError(err, req, res, templatesDir);
1574
1777
  }
1575
1778
  }
1576
1779
 
@@ -1651,7 +1854,30 @@ ${reset}
1651
1854
  let aiServer: ReturnType<typeof createServer> | null = null;
1652
1855
  let testPort = port + 1000;
1653
1856
 
1654
- if (isDebug && !noAiPort) {
1857
+ // A DERIVED port is still a port. `port + 1000` leaves the legal range as
1858
+ // soon as the base port is above 64535, and Node's listen() validates the
1859
+ // number and throws ERR_SOCKET_BAD_PORT SYNCHRONOUSLY — it is not an
1860
+ // "error" event, so the handler below never sees it. Thrown here it
1861
+ // escapes this listen callback ABOVE the resolvePromise() at the end of
1862
+ // it, and in debug mode devAdmin's ErrorTracker has already installed an
1863
+ // uncaughtException handler that only RECORDS the error. Net effect,
1864
+ // measured: the main port stayed bound and served traffic while
1865
+ // `await startServer(...)` never settled — a half-started server that
1866
+ // hangs the caller with nothing printed. `PORT=65000 TINA4_DEBUG=true`
1867
+ // was enough to trigger it; in the test suite an OS-assigned ephemeral
1868
+ // base port (macOS hands out 49152-65535) hit it about one run in
1869
+ // sixteen and the whole file vanished from the counts.
1870
+ const aiPortInRange = testPort <= 65535;
1871
+
1872
+ if (isDebug && !noAiPort && !aiPortInRange) {
1873
+ Log.warning(
1874
+ `Stable AI/test port ${testPort} is out of range (a port must be <= 65535), ` +
1875
+ `so it is disabled for base port ${port}. Use a base port of 64535 or lower, ` +
1876
+ `or set TINA4_NO_AI_PORT=true to silence this.`,
1877
+ );
1878
+ }
1879
+
1880
+ if (isDebug && !noAiPort && aiPortInRange) {
1655
1881
  // Stable AI port (port+1000): tag requests so /__dev_reload + toolbar are suppressed.
1656
1882
  aiServer = createServer(async (req, res) => {
1657
1883
  (req as any)._tina4AiPort = true;
@@ -1679,8 +1905,12 @@ ${reset}
1679
1905
  aiServer.listen(testPort, host);
1680
1906
  }
1681
1907
 
1682
- // Banner goes to stdout via console.log — NOT through the framework logger
1683
- const dualPortLines = (isDebug && !noAiPort)
1908
+ // Banner goes to stdout via console.log — NOT through the framework logger.
1909
+ // Only advertise the test port when one was actually attempted: an
1910
+ // out-of-range derived port is not bound, and printing it would send a
1911
+ // developer to a URL that cannot exist (same rule as the swagger/dashboard
1912
+ // lines below).
1913
+ const dualPortLines = (isDebug && !noAiPort && aiPortInRange)
1684
1914
  ? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)`
1685
1915
  : "";
1686
1916
 
@@ -1711,8 +1941,115 @@ ${reset}
1711
1941
  // Open the browser on the MAIN port — that's the hot-reload port.
1712
1942
  openBrowser(`http://${displayHost}:${port}`);
1713
1943
  }
1944
+ // ── Graceful shutdown ─────────────────────────────────────────────
1945
+ // A container orchestrator sends SIGTERM and SIGKILLs after a grace
1946
+ // period, so dropping in-flight requests here is a production defect,
1947
+ // not a style question. The order below mirrors Python/PHP/Ruby:
1948
+ // stop accepting -> let in-flight requests finish -> release resources
1949
+ // -> exit 0.
1950
+ //
1951
+ // Two traps this replaces, both measured against a real signal:
1952
+ //
1953
+ // 1. Nothing here trapped the signal at all, so a plain startServer()
1954
+ // app died on SIGTERM's DEFAULT disposition: process gone in ~150ms,
1955
+ // every in-flight response dropped, exit 143.
1956
+ // 2. `server.close()` is ASYNCHRONOUS and, in Node's own words, "keeps
1957
+ // existing connections". The CLI's `server.close(); process.exit(0)`
1958
+ // therefore killed the very requests close() was waiting to drain.
1959
+ // The close CALLBACK is the only honest "everything drained" signal.
1960
+ let shuttingDown = false;
1961
+
1962
+ const closeListeners = (): Promise<void> =>
1963
+ new Promise((done) => {
1964
+ let pending = aiServer ? 2 : 1;
1965
+ const one = (): void => {
1966
+ if (--pending === 0) done();
1967
+ };
1968
+ server.close(one);
1969
+ if (aiServer) aiServer.close(one);
1970
+ // A keep-alive socket with no request on it still counts as an open
1971
+ // connection, so close() would sit on it until the client wandered
1972
+ // off. Without this a fully drained server still burns the whole
1973
+ // shutdown budget.
1974
+ server.closeIdleConnections();
1975
+ aiServer?.closeIdleConnections();
1976
+ });
1977
+
1978
+ const gracefulShutdown = async (signal: string): Promise<void> => {
1979
+ if (shuttingDown) return;
1980
+ shuttingDown = true;
1981
+ Log.info(`Received ${signal}, shutting down gracefully...`);
1982
+
1983
+ stopAllBackgroundTasks();
1984
+
1985
+ // Tell live WebSocket peers we are going away (RFC 6455 s7.4.1 code
1986
+ // 1001) BEFORE closing the listeners. A WS connection never "finishes"
1987
+ // the way a request does, so waiting for one to drain would burn the
1988
+ // whole budget every time; the honest move is a proper close frame so
1989
+ // a tina4-js client reconnects on a schedule instead of erroring on a
1990
+ // socket that simply vanished.
1991
+ const wsClosed =
1992
+ wsRouteManager.closeAll(CLOSE_GOING_AWAY, "server shutting down") +
1993
+ devReloadWs.closeAll(CLOSE_GOING_AWAY, "server shutting down");
1994
+ if (wsClosed > 0) {
1995
+ Log.info(`Closed ${wsClosed} WebSocket connection(s) with 1001 going away`);
1996
+ }
1997
+
1998
+ // Race the drain against the shutdown budget. Whatever is still in
1999
+ // flight when the budget expires gets force-closed: SIGKILL is what
2000
+ // arrives next, so a bounded drain is strictly better than an
2001
+ // unbounded one that the orchestrator truncates anyway.
2002
+ const budgetSeconds = shutdownTimeoutSeconds();
2003
+ let timer: NodeJS.Timeout | undefined;
2004
+ const outcome = await Promise.race([
2005
+ closeListeners().then(() => "drained" as const),
2006
+ new Promise<"timeout">((r) => {
2007
+ timer = setTimeout(() => r("timeout"), budgetSeconds * 1000);
2008
+ timer.unref();
2009
+ }),
2010
+ ]);
2011
+ if (timer) clearTimeout(timer);
2012
+
2013
+ if (outcome === "timeout") {
2014
+ Log.warning(
2015
+ `Shutdown timeout (${budgetSeconds}s) reached with requests still in flight - forcing close`,
2016
+ );
2017
+ server.closeAllConnections();
2018
+ aiServer?.closeAllConnections();
2019
+ }
2020
+
2021
+ try {
2022
+ const orm = await import("../../orm/src/index.js");
2023
+ await orm.closeDatabase();
2024
+ } catch {
2025
+ /* ORM never initialised - nothing to close */
2026
+ }
2027
+
2028
+ Log.info("Server stopped.");
2029
+ // Exit 0: this process was ASKED to stop and did so cleanly. 128+signum
2030
+ // is what waitpid reports for a process killed BY a signal, i.e. one
2031
+ // that did NOT handle it - it is a diagnosis, not a target. Gunicorn
2032
+ // and Puma both halt 0 on a handled TERM, and a container exiting 0 is
2033
+ // a clean termination rather than a signal-kill.
2034
+ process.exit(0);
2035
+ };
2036
+
2037
+ const onSigterm = (): void => {
2038
+ void gracefulShutdown("SIGTERM");
2039
+ };
2040
+ const onSigint = (): void => {
2041
+ void gracefulShutdown("SIGINT");
2042
+ };
2043
+ process.on("SIGTERM", onSigterm);
2044
+ process.on("SIGINT", onSigint);
2045
+
1714
2046
  resolvePromise({
1715
2047
  close: () => {
2048
+ // An explicit close() is not a signal shutdown: drop the handlers so
2049
+ // a test that starts many servers in one process does not pile up
2050
+ // listeners (and trip Node's MaxListeners warning).
2051
+ process.off("SIGTERM", onSigterm);
2052
+ process.off("SIGINT", onSigint);
1716
2053
  // Clear any registered background timers so graceful shutdown actually exits.
1717
2054
  stopAllBackgroundTasks();
1718
2055
  if (aiServer) aiServer.close();