tina4-nodejs 3.13.97 → 3.13.99

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 (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -1,4 +1,5 @@
1
1
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { resolve, dirname, join, relative } from "node:path";
3
4
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
5
  import { isatty } from "node:tty";
@@ -11,24 +12,36 @@ import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
11
12
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
12
13
  import { enforceRouteAuth } from "./authGate.js";
13
14
  import { discoverRoutes } from "./routeDiscovery.js";
15
+ import {
16
+ takeOverPort,
17
+ isDev as isTakeoverDev,
18
+ noTakeoverOptedOut,
19
+ writePidfile,
20
+ removePidfile,
21
+ TAKEOVER_KILLED,
22
+ TAKEOVER_REFUSALS,
23
+ } from "./portTakeover.js";
14
24
  import { createRequest } from "./request.js";
15
25
  import {
16
26
  resetRequestCaches,
17
27
  headStripIntercept,
28
+ compressionEtagIntercept,
18
29
  sessionAutoStart,
19
30
  } from "./dispatchPipeline.js";
20
- import { createResponse, setDefaultTemplatesDir } from "./response.js";
21
- import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass } from "./middleware.js";
31
+ import { createResponse, setDefaultTemplatesDir, wantsJson, negotiatedErrorBody } from "./response.js";
32
+ import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass, attachCsrfFromEnv, SecurityHeadersMiddleware } from "./middleware.js";
22
33
  import { tryServeStatic } from "./static.js";
23
34
  import { loadEnv, isTruthy } from "./dotenv.js";
35
+ import { isDebugMode } from "./errorOverlay.js";
24
36
  import { createHealthRoutes } from "./health.js";
25
37
  import { rateLimiter } from "./rateLimiter.js";
26
38
  import { Log } from "./logger.js";
27
- import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
39
+ import { DevAdmin, RequestInspector, WsTracker, devMutationDenial, DEV_SAFE_METHODS } from "./devAdmin.js";
28
40
  import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
29
41
  import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
30
42
  import { I18n } from "./i18n.js";
31
43
  import { stopAllBackgroundTasks } from "./background.js";
44
+ import { TINA4_VERSION } from "./version.js";
32
45
 
33
46
  const __filename = fileURLToPath(import.meta.url);
34
47
  const __dirname = dirname(__filename);
@@ -200,18 +213,12 @@ export async function autoMigrateOnStartup(
200
213
  }
201
214
  }
202
215
 
203
- /** Read version from root package.json so the banner always matches the published version. */
204
- function readPackageVersion(): string {
205
- try {
206
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
207
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
208
- return pkg.version ?? "0.0.0";
209
- } catch {
210
- return "0.0.0";
211
- }
212
- }
213
-
214
- const TINA4_VERSION = readPackageVersion();
216
+ // VERSION-DEC-01 (feature 130): TINA4_VERSION is the ONE shared resolver in
217
+ // ./version.ts (a walk-up to the nearest package.json, not this file's old
218
+ // fixed depth-3 read, which silently returned "0.0.0" once @tina4/core was
219
+ // relocated out of the monorepo layout). devAdmin.ts and mcp.ts's default dev
220
+ // server import the SAME constant, so the banner, health, dashboard, and MCP
221
+ // serverInfo can never drift from each other.
215
222
 
216
223
  /** Cache Frond instances by template directory to avoid repeated instantiation. */
217
224
  const frondCache = new Map<string, InstanceType<any>>();
@@ -303,39 +310,30 @@ export function _checkLegacyEnvVars(): void {
303
310
  * Uses lsof on macOS/Linux and netstat + taskkill on Windows.
304
311
  * Throws if the port cannot be freed.
305
312
  */
306
- function killPort(port: number): void {
307
- // execFileSync is imported at the top of the file
308
- console.log(` Port ${port} in use — killing existing process...`);
309
- try {
310
- if (process.platform === "win32") {
311
- const out = execFileSync("netstat", ["-ano"], { encoding: "utf-8", timeout: 5000 });
312
- for (const line of out.split("\n")) {
313
- if (line.includes(`:${port}`) && (line.includes("LISTENING") || line.includes("ESTABLISHED"))) {
314
- const parts = line.trim().split(/\s+/);
315
- const pid = parts[parts.length - 1];
316
- if (/^\d+$/.test(pid)) {
317
- execFileSync("taskkill", ["/PID", pid, "/F"], { timeout: 5000 });
318
- break;
319
- }
320
- }
321
- }
322
- } else {
323
- const pids = execFileSync("lsof", ["-ti", `:${port}`], { encoding: "utf-8", timeout: 5000 })
324
- .trim()
325
- .split("\n")
326
- .filter(Boolean);
327
- for (const pid of pids) {
328
- if (/^\d+$/.test(pid.trim())) {
329
- process.kill(parseInt(pid.trim(), 10), "SIGTERM");
330
- }
331
- }
332
- }
333
- // Brief pause for the OS to reclaim the port
334
- execFileSync(process.execPath, ["-e", "setTimeout(() => {}, 500)"], { timeout: 2000 });
335
- console.log(` Port ${port} freed`);
336
- } catch (err) {
337
- throw new Error(`Could not free port ${port}: ${err}`);
313
+ /**
314
+ * Reclaim `port` from a stale Tina4 dev server via the shared, guarded path.
315
+ *
316
+ * This is the runtime bind-failure fallback. It used to SIGTERM whatever held
317
+ * the port with NONE of the CLI's guards -- no identity check, no container
318
+ * guard, no PID-safety filter -- so a foreign holder (another dev server, a
319
+ * database) was killed on any bind failure. It now routes through the SAME
320
+ * identity-checked helper the CLI uses (TAKEOVER-DEC-02), so only a
321
+ * PID-file-confirmed Tina4 dev server is ever signalled.
322
+ *
323
+ * Throws when the port is held by a non-Tina4 process (or takeover is opted out
324
+ * / disabled outside dev), so the bind fails loudly with a clear message instead
325
+ * of killing an innocent process.
326
+ */
327
+ export function killPort(port: number): void {
328
+ const result = takeOverPort(port, isTakeoverDev(), noTakeoverOptedOut());
329
+ if (result.status === TAKEOVER_KILLED) {
330
+ console.log(` ${result.message}`);
331
+ return;
338
332
  }
333
+ if (TAKEOVER_REFUSALS.includes(result.status)) {
334
+ throw new Error(result.message);
335
+ }
336
+ // NOTHING / container: nothing to reclaim -- let the real bind decide.
339
337
  }
340
338
 
341
339
  /**
@@ -401,10 +399,16 @@ export function resolvePortAndHost(config?: { port?: number; host?: string }): {
401
399
  port = 7148;
402
400
  }
403
401
 
402
+ // DEVADMIN-DEC-02: in dev/serve mode (TINA4_DEBUG) the /__dev dashboard exposes
403
+ // an unauthenticated file/SQL/RCE surface, so the DEFAULT bind is loopback, not
404
+ // 0.0.0.0. Only the default changes: an explicit config.host / TINA4_HOST / HOST
405
+ // still wins (production passes one and does not set TINA4_DEBUG), so a developer
406
+ // who WANTS network exposure sets TINA4_HOST=0.0.0.0 to override deliberately.
407
+ const defaultHost = isTruthy(process.env.TINA4_DEBUG) ? "127.0.0.1" : "0.0.0.0";
404
408
  const host = config?.host
405
409
  ?? process.env.TINA4_HOST
406
410
  ?? process.env.HOST
407
- ?? "0.0.0.0";
411
+ ?? defaultHost;
408
412
  return { port, host };
409
413
  }
410
414
 
@@ -435,7 +439,10 @@ export function isBannerSuppressed(): boolean {
435
439
  }
436
440
 
437
441
  function isDevMode(): boolean {
438
- return isTruthy(process.env.TINA4_DEBUG);
442
+ // OVERLAY-DEC-04: unify the debug gate on the overlay module's isDebugMode() so the
443
+ // error-overlay gate (and every other dev gate that calls isDevMode) has ONE
444
+ // definition, instead of recomputing isTruthy(TINA4_DEBUG) separately. Same value.
445
+ return isDebugMode();
439
446
  }
440
447
 
441
448
  /**
@@ -654,7 +661,7 @@ export function resolveTemplate(pathname: string, templatesDir: string): string
654
661
  return templateCache.get(cleanPath) ?? null;
655
662
  }
656
663
 
657
- function renderLandingPage(routes: Array<{ method: string; pattern: string; flags?: string[] }>, port: number = 7148): string {
664
+ function renderLandingPage(port: number = 7148): string {
658
665
  const version = TINA4_VERSION;
659
666
 
660
667
  const galleryItems = [
@@ -819,6 +826,24 @@ function deployGallery(name) {
819
826
  // Allows handle() to route requests without requiring a reference to the server.
820
827
  let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<void>) | null = null;
821
828
 
829
+ // The DispatchContext startServer() built for the currently-running server (if
830
+ // any) — see getLiveDispatchContext() below. `null` until startServer() runs.
831
+ let _liveDispatchContext: DispatchContext | null = null;
832
+
833
+ /**
834
+ * The DispatchContext the currently-running server (if any) built at
835
+ * startServer() time — the FULL context (real ORM/Swagger/DevAdmin/CSRF
836
+ * wiring included), not the lighter one buildDispatchContext() makes on its
837
+ * own. `null` when startServer() has not run yet in this process.
838
+ *
839
+ * TestClient prefers this over a freshly-built context so a no-argument
840
+ * `new TestClient()` gets maximum fidelity to whatever server is actually
841
+ * live — mirroring Ruby's `RackApp.current` (feature 131, TC-DEC-01).
842
+ */
843
+ export function getLiveDispatchContext(): DispatchContext | null {
844
+ return _liveDispatchContext;
845
+ }
846
+
822
847
  // Lazily-resolved Database.resetRequestCaches binding (or null if the ORM is
823
848
  // not installed). Memoised so the dynamic import happens once, then every
824
849
  // request reuses the resolved function — see the request-scoped cache boundary
@@ -1049,6 +1074,21 @@ async function runMatchedRoute(ctx: MatchedRouteContext): Promise<void> {
1049
1074
  const { req, res, match, postMatchMiddleware } = ctx;
1050
1075
  req.params = match.params as never;
1051
1076
 
1077
+ // DEVADMIN-DEC-01/02: fail-closed same-origin + loopback gate on every /__dev
1078
+ // write (POST/PUT/PATCH/DELETE), BEFORE the handler runs. Closes drive-by CSRF
1079
+ // (a cross-origin page POSTing to /file/save then /reload) and a network-exposed
1080
+ // debug box. Scoped to /__dev so /__feedback + /ai are unaffected; GET/HEAD/
1081
+ // OPTIONS are safe and skip the gate. The MCP endpoints keep their own 404 gate
1082
+ // (devMutationDenial skips the mcp prefixes for the loopback part).
1083
+ const devMethod = (req.method ?? "GET").toUpperCase();
1084
+ if (ctx.pathname.startsWith("/__dev") && !DEV_SAFE_METHODS.has(devMethod)) {
1085
+ const denial = devMutationDenial(req);
1086
+ if (denial) {
1087
+ res.json({ ok: false, error: denial.error }, denial.status);
1088
+ return;
1089
+ }
1090
+ }
1091
+
1052
1092
  if (await runGlobalMiddlewarePass(postMatchMiddleware, req, res)) return;
1053
1093
 
1054
1094
  // Auth enforcement lives in enforceRouteAuth (authGate.ts) so the in-process
@@ -1277,7 +1317,7 @@ async function renderDispatchError(
1277
1317
  Events.emit("tina4.request.error", { exception: err, request: req });
1278
1318
  } catch (listenerErr) {
1279
1319
  try {
1280
- Log.warn(
1320
+ Log.warning(
1281
1321
  `Listener for tina4.request.error raised: ${
1282
1322
  listenerErr instanceof Error
1283
1323
  ? `${listenerErr.name}: ${listenerErr.message}`
@@ -1292,23 +1332,56 @@ async function renderDispatchError(
1292
1332
  if (res.raw.writableEnded) return;
1293
1333
 
1294
1334
  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));
1335
+ // OVERLAY-DEC-03: guard the dev-overlay render. This call site sits INSIDE the
1336
+ // dispatch catch, so if the overlay itself throws (a malformed frame, an
1337
+ // unrenderable request value) it would double-fault out of dispatch. Wrap it and
1338
+ // fall through to the same safe production page, so a broken overlay still yields a
1339
+ // bounded 500 — never a crash.
1340
+ try {
1341
+ const { renderErrorOverlay } = await import("./errorOverlay.js");
1342
+ const overlayHtml = renderErrorOverlay(err, req);
1343
+ res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1344
+ res.raw.end(overlayHtml);
1345
+ return;
1346
+ } catch (overlayErr) {
1347
+ try {
1348
+ Log.warning(
1349
+ `Error overlay render failed, serving the safe page: ${
1350
+ overlayErr instanceof Error ? `${overlayErr.name}: ${overlayErr.message}` : String(overlayErr)
1351
+ }`
1352
+ );
1353
+ } catch {
1354
+ // Log failures must never block the 500 render.
1355
+ }
1356
+ // fall through to the safe production page below
1357
+ }
1358
+ }
1359
+
1360
+ // The canonical per-request id (set at the top of dispatch), so the id a
1361
+ // user reports off the 500 page matches the log lines and the X-Request-ID
1362
+ // response header - not a throwaway base36 clock value.
1363
+ const requestId = Log.getRequestId() ?? randomBytes(4).toString("hex");
1364
+
1365
+ // ERR-DEC-02: a JSON API client gets the JSON error body directly. The
1366
+ // message is ALWAYS the generic "Internal Server Error" here - CWE-209 -
1367
+ // never the real exception (same guarantee as error_message='' below).
1368
+ if (wantsJson(req)) {
1369
+ const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
1370
+ res.raw.writeHead(500, { "Content-Type": "application/json" });
1371
+ res.raw.end(JSON.stringify(body));
1299
1372
  return;
1300
1373
  }
1301
1374
 
1302
1375
  const html500 = await renderErrorPage(500, {
1303
1376
  error_message: "",
1304
- request_id: `${Date.now().toString(36)}`,
1377
+ request_id: requestId,
1305
1378
  path: req.path,
1306
1379
  }, templatesDir);
1307
1380
  if (html500) {
1308
1381
  res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1309
1382
  res.raw.end(html500);
1310
1383
  } else {
1311
- res({ error: "Internal Server Error", statusCode: 500 }, 500);
1384
+ res(negotiatedErrorBody(500, "Internal Server Error", requestId), 500);
1312
1385
  }
1313
1386
  }
1314
1387
 
@@ -1332,6 +1405,33 @@ interface FallbackContext {
1332
1405
  swaggerAssetsEnabled: boolean;
1333
1406
  }
1334
1407
 
1408
+ /**
1409
+ * Everything one request's dispatch needs beyond req/res: the resolved
1410
+ * router, middleware chain, filesystem roots and template engine a boot
1411
+ * resolved once.
1412
+ *
1413
+ * Passed explicitly — never closed over — so the SAME dispatch function can
1414
+ * be driven from TWO places: the live socket server (startServer() builds
1415
+ * the full context, wired to real ORM/Swagger/DevAdmin/CSRF registrations)
1416
+ * and the in-process TestClient (buildDispatchContext(), below, when no live
1417
+ * server is running in this process, or when the caller wants an isolated
1418
+ * router). Both run the identical runDispatch()/dispatchInner() — no stage
1419
+ * is ever skipped for one caller and not the other (feature 131, TC-DEC-01
1420
+ * — this was a closure trapped inside startServer() until now, which is
1421
+ * exactly why TestClient could not call it and grew its own competing
1422
+ * dispatch instead).
1423
+ */
1424
+ export interface DispatchContext {
1425
+ router: Router;
1426
+ middleware: MiddlewareChain;
1427
+ port: number;
1428
+ staticDir: string;
1429
+ srcPublicDir: string;
1430
+ templatesDir: string;
1431
+ frondEngine: { render(file: string, data: Record<string, unknown>): string } | null;
1432
+ swaggerAssetsEnabled: boolean;
1433
+ }
1434
+
1335
1435
  /**
1336
1436
  * Serve a template file for a GET (e.g. /hello -> src/templates/pages/hello.twig).
1337
1437
  *
@@ -1363,13 +1463,8 @@ function serveLandingPage(ctx: FallbackContext): boolean {
1363
1463
  if ((ctx.req.method ?? "GET") !== "GET") return false;
1364
1464
  if (ctx.pathname !== "/" || !isDevMode()) return false;
1365
1465
 
1366
- const allRoutes = ctx.router.getRoutes().map((r) => ({
1367
- method: r.method,
1368
- pattern: r.pattern,
1369
- flags: [] as string[],
1370
- }));
1371
1466
  ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
1372
- ctx.res.raw.end(renderLandingPage(allRoutes, ctx.port));
1467
+ ctx.res.raw.end(renderLandingPage(ctx.port));
1373
1468
  return true;
1374
1469
  }
1375
1470
 
@@ -1418,6 +1513,11 @@ function serveMethodNotAllowed(ctx: FallbackContext): boolean {
1418
1513
  * regardless of TINA4_SWAGGER_ENABLED / TINA4_DEBUG.
1419
1514
  */
1420
1515
  function serveStaticAsset(ctx: FallbackContext): boolean {
1516
+ // ONE search order across the four frameworks (ST-SEARCHDIR-DIVERGE):
1517
+ // TINA4_PUBLIC_DIR override first (ST-PUBLICDIR-ENV-PARTIAL), then the app's
1518
+ // public then src/public, then the framework built-in public last.
1519
+ const custom = process.env.TINA4_PUBLIC_DIR;
1520
+ if (custom && existsSync(custom) && tryServeStatic(custom, ctx.req, ctx.res)) return true;
1421
1521
  if (existsSync(ctx.staticDir) && tryServeStatic(ctx.staticDir, ctx.req, ctx.res)) return true;
1422
1522
  if (existsSync(ctx.srcPublicDir) && tryServeStatic(ctx.srcPublicDir, ctx.req, ctx.res)) return true;
1423
1523
 
@@ -1429,15 +1529,26 @@ function serveStaticAsset(ctx: FallbackContext): boolean {
1429
1529
 
1430
1530
  /** Terminal stage: 404, with the canonical reason phrase so the status line is well-formed. */
1431
1531
  async function serveNotFound(ctx: FallbackContext): Promise<boolean> {
1432
- const html404 = await renderErrorPage(404, { path: ctx.pathname }, ctx.templatesDir);
1532
+ // The canonical per-request id (set at the top of dispatch), so the id a
1533
+ // user reports off the 404 matches the log lines and the X-Request-ID
1534
+ // response header - not a throwaway (ERR-404-REQUESTID).
1535
+ const requestId = Log.getRequestId() ?? randomBytes(4).toString("hex");
1536
+
1537
+ // ERR-DEC-02: a JSON API client gets the JSON error body directly - no
1538
+ // need to even try the HTML template.
1539
+ if (wantsJson(ctx.req)) {
1540
+ const body = negotiatedErrorBody(404, "Not Found", requestId);
1541
+ ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
1542
+ ctx.res.raw.end(JSON.stringify(body));
1543
+ return true;
1544
+ }
1545
+
1546
+ const html404 = await renderErrorPage(404, { path: ctx.pathname, request_id: requestId }, ctx.templatesDir);
1433
1547
  if (html404) {
1434
1548
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
1435
1549
  ctx.res.raw.end(html404);
1436
1550
  } else {
1437
- ctx.res(
1438
- { error: "Not Found", statusCode: 404, message: `No route found for ${ctx.req.method} ${ctx.pathname}` },
1439
- 404,
1440
- );
1551
+ ctx.res(negotiatedErrorBody(404, `No route found for ${ctx.req.method} ${ctx.pathname}`, requestId), 404);
1441
1552
  }
1442
1553
  return true;
1443
1554
  }
@@ -1458,6 +1569,225 @@ const FALLBACK_STAGES: Array<(ctx: FallbackContext) => boolean | Promise<boolean
1458
1569
  serveNotFound,
1459
1570
  ];
1460
1571
 
1572
+ /**
1573
+ * Build a standalone DispatchContext bound to `router`, without booting a
1574
+ * real server — no port bind, no route discovery, no ORM/Swagger/DevAdmin/
1575
+ * CSRF wiring (those are startServer()'s job, and they mutate global/process
1576
+ * state a lightweight caller should not trigger just by dispatching one
1577
+ * request).
1578
+ *
1579
+ * For the in-process TestClient (and any other embedder) to reach the REAL
1580
+ * pipeline when no `startServer()` has run yet in this process, or when the
1581
+ * caller wants an ISOLATED router independent of whatever live server might
1582
+ * be running — parity with the existing `new TestClient(router)`
1583
+ * test-isolation contract (see testClientFrontController.test.ts, which
1584
+ * builds a fresh Router precisely so it never races with concurrent
1585
+ * TestClient suites on the shared defaultRouter).
1586
+ *
1587
+ * Best-effort, like startServer()'s own setup: Frond and @tina4/swagger are
1588
+ * optional dependencies of @tina4/core, so a missing package degrades to
1589
+ * `null` / `false` rather than throwing — a template route or a swagger
1590
+ * asset request simply falls through the fallback chain the same way it
1591
+ * would if those packages were never installed.
1592
+ */
1593
+ export async function buildDispatchContext(router: Router, base?: string): Promise<DispatchContext> {
1594
+ const root = base ? resolve(base) : process.cwd();
1595
+ const staticDir = resolve(root, "public");
1596
+ const srcPublicDir = resolve(root, "src/public");
1597
+ const templatesDir = resolve(root, "src/templates");
1598
+
1599
+ let frondEngine: DispatchContext["frondEngine"] = null;
1600
+ try {
1601
+ const { Frond } = await import("../../frond/src/engine.js");
1602
+ frondEngine = new Frond(templatesDir);
1603
+ } catch {
1604
+ // Frond not available — template-route fallback stays inert, same guard startServer() uses.
1605
+ }
1606
+
1607
+ let swaggerAssetsEnabled = false;
1608
+ try {
1609
+ const swagger = await import("../../swagger/src/index.js");
1610
+ swaggerAssetsEnabled = swagger.swaggerEnabled();
1611
+ } catch {
1612
+ // Swagger not available — bundled swagger assets stay ungated-but-absent, same as startServer().
1613
+ }
1614
+
1615
+ return {
1616
+ router,
1617
+ middleware: new MiddlewareChain(),
1618
+ port: 7148,
1619
+ staticDir,
1620
+ srcPublicDir,
1621
+ templatesDir,
1622
+ frondEngine,
1623
+ swaggerAssetsEnabled,
1624
+ };
1625
+ }
1626
+
1627
+ /**
1628
+ * Drive one request through PROLOGUE_STAGES -> REQUEST_STAGES -> route match
1629
+ * -> ROUTE_STAGES (or FALLBACK_STAGES on a miss) -> ERROR_STAGES. This is the
1630
+ * function every stage list in dispatchPipeline.ts describes; ctx supplies
1631
+ * everything a boot resolved once (router, middleware, filesystem roots,
1632
+ * Frond) so this function itself closes over nothing from a particular
1633
+ * server instance.
1634
+ */
1635
+ async function dispatchInner(
1636
+ ctx: DispatchContext,
1637
+ rawReq: IncomingMessage,
1638
+ rawRes: ServerResponse,
1639
+ requestId: string,
1640
+ ): Promise<void> {
1641
+ const req = createRequest(rawReq);
1642
+ const res = createResponse(rawRes);
1643
+
1644
+ // PROLOGUE STAGES. Extracted to dispatchPipeline.ts - see PROLOGUE_STAGES
1645
+ // there for the ordered list and why the order is behaviour, not taste.
1646
+ // These four close over nothing but the raw req/res (never ctx), which is
1647
+ // why they take no context object at all.
1648
+ await resetRequestCaches();
1649
+ headStripIntercept(rawReq, rawRes);
1650
+ // Feature 40 (CE-DEC-01/02): gzip + ETag + conditional-GET for every
1651
+ // dynamic response. Installed right after headStripIntercept so it runs
1652
+ // (execution order is the REVERSE of installation for these monkey-patch
1653
+ // interceptors) AFTER the dev-toolbar/feedback injection but BEFORE the
1654
+ // HEAD body-strip - see compressionEtagIntercept's docblock.
1655
+ compressionEtagIntercept(rawReq, rawRes);
1656
+
1657
+ // res.render() is handled natively by response.ts via Frond
1658
+
1659
+ try {
1660
+ // sessionAutoStart is the one prologue stage INSIDE the try. It degrades
1661
+ // on its own (ADR-0021), so the only thing that escapes it is a
1662
+ // TINA4_SESSION_STRICT refusal - and that must become a 500 through the
1663
+ // normal error renderer, like Python's raise becomes a 500 in the ASGI
1664
+ // server. Outside the try it rejected `dispatch`, and nothing awaits the
1665
+ // listener http.createServer() calls: an unhandled rejection that takes
1666
+ // the whole worker down is not "refuse this request".
1667
+ await sessionAutoStart(rawReq, rawRes, req);
1668
+
1669
+ // Run middleware chain
1670
+ await ctx.middleware.run(req, res);
1671
+ if (res.raw.writableEnded) return;
1672
+
1673
+ // Parse request body.
1674
+ //
1675
+ // A body that breaks a documented limit is the client's error, not the
1676
+ // server's. PayloadTooLargeError already carried `statusCode = 413` and
1677
+ // nothing read it, so an oversized upload answered 500 - which tells the
1678
+ // caller to retry the exact request that will fail again.
1679
+ try {
1680
+ await req.parseBody();
1681
+ } catch (err) {
1682
+ const status = (err as { statusCode?: number })?.statusCode;
1683
+ if (typeof status === "number" && status >= 400 && status < 500) {
1684
+ if (!rawRes.writableEnded) {
1685
+ rawRes.statusCode = status;
1686
+ rawRes.setHeader("content-type", "application/json");
1687
+ rawRes.end(JSON.stringify({ error: (err as Error).message }));
1688
+ }
1689
+ return;
1690
+ }
1691
+ throw err;
1692
+ }
1693
+
1694
+ const pathname = req.path;
1695
+
1696
+ // Track request start time for dev inspector
1697
+ const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
1698
+
1699
+ // Mutable ref so wrappedEnd can read the matched pattern after route matching
1700
+ // A HOLDER, not a plain string: the end-wrapper below reads it at end()
1701
+ // time, after route matching has assigned it.
1702
+ const matchedPattern = { value: "" };
1703
+
1704
+ // Wrap res.raw.end to inject dev toolbar and capture requests
1705
+ // Skip toolbar injection on the AI port (no-reload behaviour)
1706
+ const isAiPortRequest = !!(rawReq as any)._tina4AiPort;
1707
+
1708
+ // AI port: block /__dev_reload so AI tools never trigger a browser reload.
1709
+ if (blockAiPortReload(res, pathname, isAiPortRequest)) return;
1710
+
1711
+ // Wrap res.raw.end so the dev toolbar / feedback widget can be injected
1712
+ // and the request captured for the inspector. Extracted - see
1713
+ // wrapResponseEnd. matchedPattern is a HOLDER because the wrapper reads
1714
+ // it at end() time, long after route matching has assigned it.
1715
+ wrapResponseEnd({
1716
+ req, res, pathname, router: ctx.router,
1717
+ reqStartTime, requestId, matchedPattern, isAiPortRequest,
1718
+ });
1719
+
1720
+ // Global middleware, split by what it depends on (ADR-0012). The
1721
+ // PRE-match set runs before a route is even looked up, so CORS and
1722
+ // anything else that must survive a short-circuit can set headers that
1723
+ // outlive a 401/403; opt in with `static preMatch = true`.
1724
+ const { pre: preMatchMiddleware, post: postMatchMiddleware } =
1725
+ MiddlewareRunner.partitionByMatchPhase([
1726
+ ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
1727
+ ]);
1728
+
1729
+ if (await runGlobalMiddlewarePass(preMatchMiddleware, req, res)) return;
1730
+
1731
+ // Match route. ROUTES BEAT FILES (ADR-0010): static assets resolve in
1732
+ // the not-found fallback below, only once no route has claimed the path.
1733
+ // A file in public/ can arrive from a build step, an upload directory or
1734
+ // a careless deploy, and it must never silently shadow a reviewed route.
1735
+ const match = ctx.router.match(req.method ?? "GET", pathname);
1736
+ if (match) {
1737
+ matchedPattern.value = match.pattern;
1738
+ await runMatchedRoute({
1739
+ req, res, pathname, match, postMatchMiddleware,
1740
+ allGlobalMiddleware: [...preMatchMiddleware, ...postMatchMiddleware],
1741
+ });
1742
+ return;
1743
+ }
1744
+
1745
+ // NOT-FOUND FALLBACK STAGES. Nothing matched a route, so walk the
1746
+ // fallback chain in order - see FALLBACK_STAGES. Each returns true when
1747
+ // it has answered the request.
1748
+ //
1749
+ // ADR-0010 (routes beat files) is why this chain runs AFTER matching: a
1750
+ // file dropped into public/ by a build step or a careless deploy must
1751
+ // never shadow a reviewed route.
1752
+ const fallback: FallbackContext = {
1753
+ req, res, pathname, router: ctx.router, port: ctx.port, staticDir: ctx.staticDir, srcPublicDir: ctx.srcPublicDir,
1754
+ templatesDir: ctx.templatesDir, frondEngine: ctx.frondEngine, swaggerAssetsEnabled: ctx.swaggerAssetsEnabled,
1755
+ };
1756
+ for (const stage of FALLBACK_STAGES) {
1757
+ if (await stage(fallback)) return;
1758
+ }
1759
+ } catch (err) {
1760
+ await renderDispatchError(err, req, res, ctx.templatesDir);
1761
+ }
1762
+ }
1763
+
1764
+ /**
1765
+ * Dispatch one request through the REAL Tina4 pipeline (stamps the
1766
+ * per-request correlation id, then hands off to dispatchInner). This is the
1767
+ * exact function startServer() wires to every live socket connection AND to
1768
+ * the module-level handle() — an in-process caller (TestClient) that builds
1769
+ * its own DispatchContext (buildDispatchContext(), or the live one via
1770
+ * getLiveDispatchContext()) runs the IDENTICAL function, so no stage is ever
1771
+ * skipped for one caller and not the other (feature 131, TC-DEC-01).
1772
+ */
1773
+ export async function runDispatch(
1774
+ ctx: DispatchContext,
1775
+ rawReq: IncomingMessage,
1776
+ rawRes: ServerResponse,
1777
+ ): Promise<void> {
1778
+ // Feature 43: PER-REQUEST correlation id. Honour a sanitized inbound
1779
+ // X-Request-ID so a client or upstream service can thread its own id
1780
+ // through - a CR/LF, over-long or illegal-charset value is rejected (never
1781
+ // echoed) - else generate one. Echo it on the response by stamping the raw
1782
+ // response NOW, so every outcome (200/404/500/413, Tina4Response OR a raw
1783
+ // rawRes.end) carries it. Then establish it in an AsyncLocalStorage so every
1784
+ // log line for this request - across every await - carries it, and two
1785
+ // requests interleaving on the one event loop never read each other's id.
1786
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes(4).toString("hex");
1787
+ if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
1788
+ return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
1789
+ }
1790
+
1461
1791
  export async function startServer(config?: Tina4Config): Promise<{
1462
1792
  close: () => void;
1463
1793
  router: Router;
@@ -1649,6 +1979,13 @@ ${reset}
1649
1979
  middleware.use(requestLogger());
1650
1980
  middleware.use(rateLimiter());
1651
1981
 
1982
+ // Security headers: register in the default chain UNCONDITIONALLY
1983
+ // (secure-by-default, SECHDR-DEC-01). It is CLASS middleware (a beforeSecurity
1984
+ // hook), so it goes in the MiddlewareRunner registry like CsrfMiddleware — no
1985
+ // opt-in: a default app ships X-Frame-Options/X-Content-Type-Options/CSP/etc.
1986
+ // HSTS stays HTTPS-only. Idempotent (MiddlewareRunner.use de-dupes).
1987
+ MiddlewareRunner.use(SecurityHeadersMiddleware);
1988
+
1652
1989
  // Discover file-based routes
1653
1990
  if (existsSync(routesDir)) {
1654
1991
  const routes = await discoverRoutes(routesDir);
@@ -1663,6 +2000,14 @@ ${reset}
1663
2000
  console.log(`\n No routes directory found at ${routesDir}`);
1664
2001
  }
1665
2002
 
2003
+ // Auto-attach CSRF when TINA4_CSRF is enabled — AFTER route discovery, BEFORE
2004
+ // listen. OFF by default: unset means no CSRF gate; TINA4_CSRF=true/1/yes/on
2005
+ // attaches CsrfMiddleware globally so every write is gated (CSRF-DEC-02).
2006
+ // Idempotent. Mirrors Python's attach_csrf_from_env in server boot.
2007
+ if (attachCsrfFromEnv()) {
2008
+ console.log(`\n \x1b[36mCSRF\x1b[0m protection enabled (TINA4_CSRF)`);
2009
+ }
2010
+
1666
2011
  // Initialize ORM if models directory exists (check src/orm/ first, then src/models/)
1667
2012
  const hasOrmDir = existsSync(ormDir);
1668
2013
  const hasModelsDir = existsSync(modelsDir);
@@ -1740,7 +2085,6 @@ ${reset}
1740
2085
  // Skip the rest of the swagger block when disabled.
1741
2086
  throw new Error("__swagger_disabled__");
1742
2087
  }
1743
- const allRoutes = router.getRoutes();
1744
2088
 
1745
2089
  // Collect model definitions for schema generation
1746
2090
  let modelDefs: Array<{ tableName: string; fields: Record<string, unknown> }> = [];
@@ -1761,7 +2105,14 @@ ${reset}
1761
2105
  // ORM not available, swagger will work without model schemas
1762
2106
  }
1763
2107
 
1764
- const getSpec = () => swagger.generate(allRoutes, modelDefs as any);
2108
+ // Read router.getRoutes() LIVE on every call — never a captured snapshot
2109
+ // (SWAG-NODE-BOOT-SNAPSHOT, ADR-0004). This used to close over a
2110
+ // boot-time `allRoutes` array, so the spec regenerated per request but
2111
+ // over a FROZEN route list: a route registered after boot (hot-reload, or
2112
+ // DevAdmin.register running after this block — see the /__feedback
2113
+ // exclusion above) never appeared. Python/PHP/Ruby always read the live
2114
+ // route table inside their generate() call; this is that same shape.
2115
+ const getSpec = () => swagger.generate(router.getRoutes(), modelDefs as any);
1765
2116
  const swaggerRoutes = swagger.createSwaggerRoutes(getSpec);
1766
2117
  for (const route of swaggerRoutes) {
1767
2118
  router.addRoute(route);
@@ -1783,127 +2134,22 @@ ${reset}
1783
2134
  }
1784
2135
  }
1785
2136
 
1786
- async function dispatch(rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> {
1787
- const req = createRequest(rawReq);
1788
- const res = createResponse(rawRes);
1789
-
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);
1796
-
1797
- // res.render() is handled natively by response.ts via Frond
1798
-
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
-
1809
- // Run middleware chain
1810
- await middleware.run(req, res);
1811
- if (res.raw.writableEnded) return;
1812
-
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
- }
1833
-
1834
- const pathname = req.path;
1835
-
1836
- // Track request start time for dev inspector
1837
- const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
1838
-
1839
- // Mutable ref so wrappedEnd can read the matched pattern after route matching
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: "" };
1843
- const requestId = Date.now().toString(36);
1844
-
1845
- // Wrap res.raw.end to inject dev toolbar and capture requests
1846
- // Skip toolbar injection on the AI port (no-reload behaviour)
1847
- const isAiPortRequest = !!(rawReq as any)._tina4AiPort;
1848
-
1849
- // AI port: block /__dev_reload so AI tools never trigger a browser reload.
1850
- if (blockAiPortReload(res, pathname, isAiPortRequest)) return;
1851
-
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
- });
1860
-
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
- ]);
1869
-
1870
- if (await runGlobalMiddlewarePass(preMatchMiddleware, req, res)) return;
1871
-
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.
1876
- const match = router.match(req.method ?? "GET", pathname);
1877
- if (match) {
1878
- matchedPattern.value = match.pattern;
1879
- await runMatchedRoute({
1880
- req, res, pathname, match, postMatchMiddleware,
1881
- allGlobalMiddleware: [...preMatchMiddleware, ...postMatchMiddleware],
1882
- });
1883
- return;
1884
- }
1885
-
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;
1899
- }
1900
- } catch (err) {
1901
- await renderDispatchError(err, req, res, templatesDir);
1902
- }
1903
- }
2137
+ // The one DispatchContext this server instance dispatches every request
2138
+ // through router/middleware/filesystem roots/Frond, resolved above.
2139
+ // runDispatch() (module-level, exported) is the SAME function TestClient
2140
+ // calls for an in-process request; wiring both to one function is the
2141
+ // fix for feature 131 TC-DEC-01 (Node used to re-implement the dispatch
2142
+ // order in TestClient instead of calling this).
2143
+ const dispatchCtx: DispatchContext = {
2144
+ router, middleware, port, staticDir, srcPublicDir, templatesDir, frondEngine, swaggerAssetsEnabled,
2145
+ };
2146
+ const dispatch = (rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> =>
2147
+ runDispatch(dispatchCtx, rawReq, rawRes);
1904
2148
 
1905
- // Assign to module-level so handle() can dispatch without a server reference
2149
+ // Assign to module-level so handle() (and TestClient, via
2150
+ // getLiveDispatchContext()) can dispatch without a server reference
1906
2151
  _dispatchFn = dispatch;
2152
+ _liveDispatchContext = dispatchCtx;
1907
2153
 
1908
2154
  // Dual-port (debug + no TINA4_NO_AI_PORT): the MAIN port hot-reloads for the human
1909
2155
  // dev; the stable AI port (port+1000, created below) suppresses reload/toolbar so an
@@ -1960,6 +2206,10 @@ ${reset}
1960
2206
 
1961
2207
  return new Promise((resolvePromise) => {
1962
2208
  server.listen(port, host, () => {
2209
+ // Record THIS process as the Tina4 dev server on this port, so a later
2210
+ // `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01). Only the
2211
+ // single dev process needs it; takeover is dev-gated off in cluster/prod.
2212
+ if (!cluster.isWorker) writePidfile(port);
1963
2213
  const displayHost = host === "0.0.0.0" ? "localhost" : host;
1964
2214
  const isDebug = isTruthy(process.env.TINA4_DEBUG);
1965
2215
  const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
@@ -2022,7 +2272,7 @@ ${reset}
2022
2272
 
2023
2273
  aiServer.on("error", (err: any) => {
2024
2274
  if (err.code === "EADDRINUSE") {
2025
- Log.warn(`Test port ${testPort} in use — skipping`);
2275
+ Log.warning(`Test port ${testPort} in use — skipping`);
2026
2276
  aiServer = null;
2027
2277
  }
2028
2278
  });
@@ -2105,6 +2355,9 @@ ${reset}
2105
2355
  shuttingDown = true;
2106
2356
  Log.info(`Received ${signal}, shutting down gracefully...`);
2107
2357
 
2358
+ // Drop our identity marker so a later takeover does not match a dead PID.
2359
+ if (!cluster.isWorker) removePidfile(port);
2360
+
2108
2361
  stopAllBackgroundTasks();
2109
2362
 
2110
2363
  // Tell live WebSocket peers we are going away (RFC 6455 s7.4.1 code
@@ -2151,6 +2404,13 @@ ${reset}
2151
2404
  }
2152
2405
 
2153
2406
  Log.info("Server stopped.");
2407
+ // Graceful shutdown owns the final call to reset() (Decision 24 /
2408
+ // LOG-I02): flush the shutdown record above, then clear the resolved
2409
+ // snapshot, exactly once. process.exit() below makes this moot for a
2410
+ // real process, but a test harness that drives gracefulShutdown()
2411
+ // without letting exit() actually kill the process (see
2412
+ // test/gracefulShutdown.test.ts) must not inherit a stale snapshot.
2413
+ Log.reset();
2154
2414
  // Exit 0: this process was ASKED to stop and did so cleanly. 128+signum
2155
2415
  // is what waitpid reports for a process killed BY a signal, i.e. one
2156
2416
  // that did NOT handle it - it is a diagnosis, not a target. Gunicorn