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