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.
Files changed (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  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 +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. 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
- const port = config?.port
349
- ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
350
- ?? 7148;
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
- port = findAvailablePort(port);
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
- // 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
- }
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
- await req.parseBody();
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
- let matchedPattern = "";
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 (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
- }
1849
+ // AI port: block /__dev_reload so AI tools never trigger a browser reload.
1850
+ if (blockAiPortReload(res, pathname, isAiPortRequest)) return;
1259
1851
 
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
- }
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
- 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
- }
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
- // 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
- }
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
- 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)),
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
- // 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);
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
- // 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
- }
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
- if (isDebug && !noAiPort) {
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
- const dualPortLines = (isDebug && !noAiPort)
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();