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.
- package/CLAUDE.md +60 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- 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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
??
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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()
|
|
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.
|
|
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
|