import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { randomBytes } from "node:crypto"; import { resolve, dirname, join, relative } from "node:path"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { isatty } from "node:tty"; import { fileURLToPath } from "node:url"; import { execFileSync, exec } from "node:child_process"; import cluster from "node:cluster"; import os from "node:os"; import type { Socket } from "node:net"; import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js"; import { Router, defaultRouter, runRouteMiddlewares } from "./router.js"; import { enforceRouteAuth } from "./authGate.js"; import { discoverRoutes } from "./routeDiscovery.js"; import { takeOverPort, isDev as isTakeoverDev, noTakeoverOptedOut, writePidfile, removePidfile, TAKEOVER_KILLED, TAKEOVER_REFUSALS, } from "./portTakeover.js"; import { createRequest } from "./request.js"; import { resetRequestCaches, headStripIntercept, compressionEtagIntercept, sessionAutoStart, } from "./dispatchPipeline.js"; import { createResponse, setDefaultTemplatesDir, wantsJson, negotiatedErrorBody } from "./response.js"; import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass, attachCsrfFromEnv, SecurityHeadersMiddleware } from "./middleware.js"; import { tryServeStatic } from "./static.js"; import { loadEnv, isTruthy } from "./dotenv.js"; import { isDebugMode } from "./errorOverlay.js"; import { createHealthRoutes } from "./health.js"; import { rateLimiter } from "./rateLimiter.js"; import { Log } from "./logger.js"; import { DevAdmin, RequestInspector, WsTracker, devMutationDenial, DEV_SAFE_METHODS } from "./devAdmin.js"; import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js"; import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js"; import { I18n } from "./i18n.js"; import { stopAllBackgroundTasks } from "./background.js"; import { TINA4_VERSION } from "./version.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** Built-in error templates directory (ships with @tina4/core). */ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates"); /** Built-in public directory for framework-bundled static assets. */ const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public"); /** * Whether the framework's bundled Swagger UI assets (public/swagger/*) may be * served. * * Static files are resolved BEFORE routes, so the shipped * public/swagger/index.html answered `GET /swagger` even when * `swaggerEnabled()` was false -- serving the Swagger UI in production and * bypassing the documented TINA4_SWAGGER_ENABLED / TINA4_DEBUG gate entirely. * The symptom was a 200 on /swagger with a 404 on /swagger/openapi.json (the * gated route never registered, the static file still won). * * Set from `swaggerEnabled()` at boot. Stays false when swagger is disabled OR * when the swagger module fails to load -- fail closed, never expose. */ let swaggerAssetsEnabled = false; /** Bundled Swagger UI asset paths that must honour the swagger gate. */ function isSwaggerAssetPath(pathname: string): boolean { return pathname === "/swagger" || pathname.startsWith("/swagger/"); } /** How long a graceful shutdown waits for in-flight requests, in seconds. */ export const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30; /** * Resolve the shutdown budget from `TINA4_SHUTDOWN_TIMEOUT` (seconds). * * 30s matches Kubernetes' default `terminationGracePeriodSeconds` and * Gunicorn's `graceful_timeout`, so the drain finishes just BEFORE the * orchestrator's SIGKILL rather than being truncated by it. Same env var and * same default as tina4-ruby's `Tina4::Shutdown`. * * A non-numeric or negative value falls back to the default rather than * silently disabling the drain - a typo must not turn shutdown into a * zero-second force-kill. */ export function shutdownTimeoutSeconds(): number { const raw = process.env.TINA4_SHUTDOWN_TIMEOUT; if (raw === undefined || raw.trim() === "") return DEFAULT_SHUTDOWN_TIMEOUT_SECONDS; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 0) { Log.warning( `TINA4_SHUTDOWN_TIMEOUT="${raw}" is not a valid number of seconds - using ${DEFAULT_SHUTDOWN_TIMEOUT_SECONDS}`, ); return DEFAULT_SHUTDOWN_TIMEOUT_SECONDS; } return parsed; } /** * Build the startup banner's optional surface lines (issue #99). * * Only advertise a surface that is actually REACHABLE. In production, or with * TINA4_DEBUG off, /swagger and /__dev return 404 -- printing them anyway both * misleads an operator into believing a dev surface is exposed and sends a * developer to a dead link. * * Kept as a pure function of (port, two booleans) so the contract is unit * testable without booting a server and grepping stdout. Parity: Python * banner_surface_lines, PHP App::bannerSurfaceLines, Ruby * Tina4.banner_surface_lines. * * @returns [swaggerLine, dashboardLine] -- each empty, or a newline plus the * banner row, ready to interpolate. */ export function bannerSurfaceLines( port: number, opts: { swaggerEnabled: boolean; devAdminEnabled: boolean }, ): [string, string] { return [ opts.swaggerEnabled ? `\n Swagger: http://localhost:${port}/swagger` : "", opts.devAdminEnabled ? `\n Dashboard: http://localhost:${port}/__dev` : "", ]; } /** * Whether the startup banner should ADVERTISE /swagger (issue #99). * * Mirrors `swaggerEnabled()` in packages/swagger/src/ui.ts: an explicit * TINA4_SWAGGER_ENABLED wins, otherwise fall back to TINA4_DEBUG. * * Read from env here rather than importing the swagger package, because the * CLUSTER PRIMARY prints its banner before any optional module is loaded (and a * dynamic import for one banner line is not worth the boot cost). Keep this in * sync with ui.ts -- it is the same two-line contract. */ function swaggerAdvertised(): boolean { const TRUTHY = ["true", "1", "yes", "on"]; const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase(); if (raw === "") { return TRUTHY.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase()); } return TRUTHY.includes(raw); } /** * Apply pending DB migrations on startup — NON-BREAKING. * * When a `migrations/` folder exists (with at least one `.sql` file, excluding * `.down.sql`) and `TINA4_AUTO_MIGRATE` is not disabled (default "true"; * false/0/no/off disable), pending migrations are applied during boot so the * schema is current with no manual `tina4 migrate` step. A failure here is * logged LOUD via `Log.error` and the service STILL starts — a bad migration * must never take the backend down. (The explicit `tina4 migrate` CLI stays * fail-fast so CI still gets a non-zero exit. Only this startup hook swallows.) * * Disable with `TINA4_AUTO_MIGRATE=false` — e.g. multi-instance production that * migrates as a separate deploy step (concurrent first-apply can race). * * @param migrationDir - migrations directory (default "migrations", relative to base) * @param base - project root used to resolve the migrations directory */ export async function autoMigrateOnStartup( migrationDir = "migrations", base = process.cwd(), ): Promise { const dir = resolve(base, migrationDir); // Gate 1: a migrations/ folder with at least one .sql (non-down) file. if (!existsSync(dir)) return; let hasSql = false; try { hasSql = readdirSync(dir).some((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")); } catch { return; // unreadable dir → nothing to do (silent) } if (!hasSql) return; // Gate 2: TINA4_AUTO_MIGRATE not falsy (default "true"). const flag = process.env.TINA4_AUTO_MIGRATE; if (flag != null && !isTruthy(flag)) { Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations"); return; } // Gate 3: a DB adapter must be resolvable. (initDatabase() has already run by // the time this is called from startServer.) let orm: typeof import("../../orm/src/index.js"); try { orm = await import("../../orm/src/index.js"); orm.getAdapter(); // throws if no adapter configured } catch (err) { Log.debug(`Startup migrations skipped (no database configured): ${err instanceof Error ? err.message : String(err)}`); return; } // Run the EXISTING migrate runner inside try/catch — NEVER re-raise out of // the startup hook (non-breaking). try { const result = await orm.migrate(undefined, { migrationsDir: dir }); if (result.applied.length > 0) { Log.info(`Applied ${result.applied.length} pending migration(s) on startup`); } } catch (err) { Log.error( `Startup auto-migration failed: ${err instanceof Error ? err.message : String(err)} — ` + "the service is starting anyway. Run `tina4 migrate` to retry.", ); } } // VERSION-DEC-01 (feature 130): TINA4_VERSION is the ONE shared resolver in // ./version.ts (a walk-up to the nearest package.json, not this file's old // fixed depth-3 read, which silently returned "0.0.0" once @tina4/core was // relocated out of the monorepo layout). devAdmin.ts and mcp.ts's default dev // server import the SAME constant, so the banner, health, dashboard, and MCP // serverInfo can never drift from each other. /** Cache Frond instances by template directory to avoid repeated instantiation. */ const frondCache = new Map>(); // ─── Legacy env var guard (v3.12 hard rename) ──────────────────────────── // All framework env vars now require the TINA4_ prefix. If any of these // pre-3.12 names are present in the environment we refuse to boot — // silently ignoring them would cause auth/db/mail to fall back to // defaults with no warning. Each maps to its new TINA4_-prefixed // canonical name. const _LEGACY_ENV_VARS: Record = { DATABASE_URL: "TINA4_DATABASE_URL", DATABASE_USERNAME: "TINA4_DATABASE_USERNAME", DATABASE_PASSWORD: "TINA4_DATABASE_PASSWORD", DB_URL: "TINA4_DATABASE_URL", SECRET: "TINA4_SECRET", API_KEY: "TINA4_API_KEY", JWT_ALGORITHM: "TINA4_JWT_ALGORITHM", SMTP_HOST: "TINA4_MAIL_HOST", SMTP_PORT: "TINA4_MAIL_PORT", SMTP_USERNAME: "TINA4_MAIL_USERNAME", SMTP_PASSWORD: "TINA4_MAIL_PASSWORD", SMTP_FROM: "TINA4_MAIL_FROM", SMTP_FROM_NAME: "TINA4_MAIL_FROM_NAME", IMAP_HOST: "TINA4_MAIL_IMAP_HOST", IMAP_PORT: "TINA4_MAIL_IMAP_PORT", IMAP_USER: "TINA4_MAIL_IMAP_USERNAME", IMAP_PASS: "TINA4_MAIL_IMAP_PASSWORD", HOST_NAME: "TINA4_HOST_NAME", SWAGGER_TITLE: "TINA4_SWAGGER_TITLE", SWAGGER_DESCRIPTION: "TINA4_SWAGGER_DESCRIPTION", SWAGGER_VERSION: "TINA4_SWAGGER_VERSION", ORM_PLURAL_TABLE_NAMES: "TINA4_ORM_PLURAL_TABLE_NAMES", }; /** * Refuse to boot if pre-3.12 un-prefixed env vars are still set. * * Tina4 v3.12 hard-renamed every framework-specific env var to use the * `TINA4_` prefix. Booting silently with a legacy `DATABASE_URL` or * `SECRET` would let auth, DB, or mail fall back to insecure defaults * while the user thought their config was being read. Better to die * loudly with a list of names to fix. * * Bypass with `TINA4_ALLOW_LEGACY_ENV=true` in CI / migration scripts * that genuinely need both names set during a transition window. */ export function _checkLegacyEnvVars(): void { if (isTruthy(process.env.TINA4_ALLOW_LEGACY_ENV)) { return; } const found = Object.keys(_LEGACY_ENV_VARS) .filter((name) => process.env[name] !== undefined) .sort(); if (found.length === 0) { return; } const bar = "─".repeat(72); const lines: string[] = [ "", bar, "Tina4 v3.12 requires TINA4_ prefix on all framework env vars.", "Your environment still has these legacy names:", "", ]; for (const old of found) { const next = _LEGACY_ENV_VARS[old]; lines.push(` ${old.padEnd(28)} → ${next}`); } lines.push( "", "Note: these may come from a .env file loaded by dotenv, not just", "the runtime environment — check your image / build context (a .env", "baked into a Docker image is loaded at startup) as well as k8s/CI env.", "", "FIX: run `tina4 env --migrate` to rewrite your .env automatically", "(it renames every legacy name to its TINA4_ form in place).", "Or rename manually. See https://tina4.com/release/3.12.0", "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.", bar, "", ); process.stderr.write(lines.join("\n") + "\n"); process.exit(2); } /** * Kill whatever process is listening on *port*. * Uses lsof on macOS/Linux and netstat + taskkill on Windows. * Throws if the port cannot be freed. */ /** * Reclaim `port` from a stale Tina4 dev server via the shared, guarded path. * * This is the runtime bind-failure fallback. It used to SIGTERM whatever held * the port with NONE of the CLI's guards -- no identity check, no container * guard, no PID-safety filter -- so a foreign holder (another dev server, a * database) was killed on any bind failure. It now routes through the SAME * identity-checked helper the CLI uses (TAKEOVER-DEC-02), so only a * PID-file-confirmed Tina4 dev server is ever signalled. * * Throws when the port is held by a non-Tina4 process (or takeover is opted out * / disabled outside dev), so the bind fails loudly with a clear message instead * of killing an innocent process. */ export function killPort(port: number): void { const result = takeOverPort(port, isTakeoverDev(), noTakeoverOptedOut()); if (result.status === TAKEOVER_KILLED) { console.log(` ${result.message}`); return; } if (TAKEOVER_REFUSALS.includes(result.status)) { throw new Error(result.message); } // NOTHING / container: nothing to reclaim -- let the real bind decide. } /** * Check if *port* is available; if not, kill the process on it and return *port*. * The auto-increment behaviour is intentionally removed — the server always * claims the requested port. */ function findAvailablePort(start: number): number { try { execFileSync(process.execPath, ["-e", ` const s = require("net").createServer(); s.listen(${start}, "0.0.0.0", () => { s.close(); process.exit(0); }); s.on("error", () => process.exit(1)); `], { timeout: 1000 }); return start; } catch { killPort(start); return start; } } /** * Open the user's default browser after a short delay so the server is ready. */ function openBrowser(url: string) { // exec imported at top of file (ESM) setTimeout(() => { if (process.platform === "darwin") exec(`open ${url}`); else if (process.platform === "win32") exec(`start "" "${url}"`); else exec(`xdg-open ${url}`); }, 2000); } /** * Resolve port and host with priority: explicit config > ENV var > default. * Exported for testability. * * Host resolution prefers `TINA4_HOST` (the framework-prefixed name — * matches Python parity) and falls back to the unprefixed `HOST` env var * for backwards compatibility. */ export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } { // Port: explicit config > TINA4_PORT > PORT (deprecated) > default. // // This read PORT and nothing else, so TINA4_PORT - the name the CLI // documents and prefers, and the one devAdmin.ts itself reads first - was // IGNORED on the path that binds the socket. Setting it did nothing and said // nothing. // // Bare PORT stays honoured so no deployment breaks, and warns so the // migration happens. Removal is 3.14. const tina4Port = process.env.TINA4_PORT; const legacyPort = process.env.PORT; let port: number; if (config?.port !== undefined) { port = config.port; } else if (tina4Port && /^\d+$/.test(tina4Port)) { port = parseInt(tina4Port, 10); } else if (legacyPort && /^\d+$/.test(legacyPort)) { port = parseInt(legacyPort, 10); warnDeprecatedPort(port); } else { port = 7148; } // DEVADMIN-DEC-02: in dev/serve mode (TINA4_DEBUG) the /__dev dashboard exposes // an unauthenticated file/SQL/RCE surface, so the DEFAULT bind is loopback, not // 0.0.0.0. Only the default changes: an explicit config.host / TINA4_HOST / HOST // still wins (production passes one and does not set TINA4_DEBUG), so a developer // who WANTS network exposure sets TINA4_HOST=0.0.0.0 to override deliberately. const defaultHost = isTruthy(process.env.TINA4_DEBUG) ? "127.0.0.1" : "0.0.0.0"; const host = config?.host ?? process.env.TINA4_HOST ?? process.env.HOST ?? defaultHost; return { port, host }; } /** * Warn ONCE that bare PORT was used instead of TINA4_PORT. * * Once, because resolvePortAndHost can be called more than once per process * and a warning repeated on every call is a warning people filter out. */ let portDeprecationWarned = false; function warnDeprecatedPort(port: number): void { if (portDeprecationWarned) return; portDeprecationWarned = true; Log.warning( `PORT is deprecated and will be removed in 3.14 - use TINA4_PORT instead ` + `(binding port ${port} from PORT)`, ); } /** * Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to * silence the ASCII-art banner and route table on startup — useful in CI, * test runners, and embedded contexts where stdout is consumed by another * process. */ export function isBannerSuppressed(): boolean { return isTruthy(process.env.TINA4_SUPPRESS); } function isDevMode(): boolean { // OVERLAY-DEC-04: unify the debug gate on the overlay module's isDebugMode() so the // error-overlay gate (and every other dev gate that calls isDevMode) has ONE // definition, instead of recomputing isTruthy(TINA4_DEBUG) separately. Same value. return isDebugMode(); } /** * Render an error page using Twig templates via Frond. * Priority: user override (src/templates/errors/{code}.twig) > built-in default > JSON fallback. */ async function renderErrorPage( code: number, data: Record, templatesDir: string, ): Promise { try { const { Frond } = await import("../../frond/src/engine.js"); const templateFile = `errors/${code}.twig`; // Helper: get-or-create a cached Frond instance for a directory const getCachedFrond = (dir: string) => { let instance = frondCache.get(dir); if (!instance) { instance = new Frond(dir); frondCache.set(dir, instance); } return instance; }; // 1. Try user override in the project's templates directory const userTemplatePath = join(templatesDir, templateFile); if (existsSync(userTemplatePath)) { return getCachedFrond(templatesDir).render(templateFile, data); } // 2. Try built-in framework default const builtinTemplatePath = join(BUILTIN_ERROR_TEMPLATES_DIR, templateFile); if (existsSync(builtinTemplatePath)) { return getCachedFrond(BUILTIN_ERROR_TEMPLATES_DIR).render(templateFile, data); } // 3. No template found return null; } catch { // Frond not available or template rendering failed — fall back to JSON return null; } } interface DevToolbarContext { version: string; method: string; path: string; matchedPattern: string; requestId: string; routeCount: number; /** When false, the toolbar sets data-reload="0" and its JS suppresses the reloader (AI/stable port). */ reload?: boolean; } function injectDevToolbar(html: string, ctx: DevToolbarContext): string { const toolbar = DevAdmin.renderToolbarHtml(ctx); if (html.includes("")) { return html.replace("", toolbar + "\n"); } return html + toolbar; } function walkGalleryFiles(dir: string): string[] { const results: string[] = []; if (!existsSync(dir)) return results; for (const f of readdirSync(dir)) { const full = join(dir, f); if (statSync(full).isDirectory()) results.push(...walkGalleryFiles(full)); else results.push(full); } return results; } function getGalleryDeployedState(): Record { const galleryDir = resolve(__dirname, "..", "gallery"); const state: Record = {}; if (!existsSync(galleryDir)) return state; try { const entries = readdirSync(galleryDir).sort(); for (const entry of entries) { const entryPath = join(galleryDir, entry); const metaFile = join(entryPath, "meta.json"); if (statSync(entryPath).isDirectory() && existsSync(metaFile)) { const srcDir = join(entryPath, "src"); if (existsSync(srcDir)) { const files = walkGalleryFiles(srcDir); const projectSrc = resolve(process.cwd(), "src"); state[entry] = files.every((f) => existsSync(join(projectSrc, relative(srcDir, f)))); } else { state[entry] = false; } } } } catch { /* ignore */ } return state; } /** * Auto-routing scans this single subdirectory of src/templates/. Only files * in src/templates/pages/ become URLs — everything else (partials, layouts, * base.twig, errors, components, macros) is never URL-exposed and remains * renderable only via {% include %} / {% extends %} / res.render(). * * Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder. * Explicit, secure by default, no skip lists to maintain. */ const TEMPLATE_PAGES_DIR = "pages"; /** * Honour TINA4_TEMPLATE_ROUTING=off|false|0|no|disabled as an explicit kill * switch. Default: enabled. Drop a file in src/templates/pages/ and it serves * at the matching URL — the zero-config Tina4 convention. Operators who want * explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL * must be registered via get() / post() (or be a static file). */ export function templateAutoRoutingEnabled(): boolean { const val = (process.env.TINA4_TEMPLATE_ROUTING ?? "on").trim().toLowerCase(); return !["off", "false", "0", "no", "disabled"].includes(val); } /** * RFC 7231 / RFC 9110 status reason phrases. Used to write a correct HTTP * status line — previously some paths wrote "HTTP/1.1 404 OK" because the * canonical phrase wasn't being looked up per code. */ const HTTP_REASON_PHRASES: Record = { 100: "Continue", 101: "Switching Protocols", 200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 206: "Partial Content", 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable", 409: "Conflict", 410: "Gone", 413: "Content Too Large", 415: "Unsupported Media Type", 422: "Unprocessable Content", 429: "Too Many Requests", 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout", }; /** * Return the canonical HTTP reason phrase for `status`. Falls back to a * sensible label when an exotic status is used. Never returns an empty string. */ export function httpReason(status: number): string { const phrase = HTTP_REASON_PHRASES[status]; if (phrase) return phrase; return status >= 200 && status < 300 ? "OK" : "Error"; } /** Template cache: url_path -> template_file. Null until first production lookup. */ let templateCache: Map | null = null; /** * Reset the production template cache. Tests use this between scenarios so * a fresh scan picks up fixture files in a tmp project. */ export function resetTemplateCache(): void { templateCache = null; } /** * Resolve a URL path to a template file in src/templates/pages/. * * Only files inside `src/templates/pages/` auto-route from a URL. Anything * in `src/templates/` outside `pages/` (partials, layouts, base.twig, * errors, components) is never served standalone. * * Dev mode: checks filesystem every time for live changes. * Production: uses a cached lookup built once at startup. * * The whole feature can be turned off with `TINA4_TEMPLATE_ROUTING=off`. */ export function resolveTemplate(pathname: string, templatesDir: string): string | null { if (!templateAutoRoutingEnabled()) return null; const cleanPath = pathname.replace(/^\/+/, "").replace(/\/+$/, "") || "index"; const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true"; if (isDev) { // Skip underscore-prefixed files even within pages/ — they're private // by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve. if (cleanPath.split("/").some((seg) => seg.startsWith("_"))) return null; const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR); for (const ext of [".twig", ".html"]) { if (existsSync(resolve(pagesDir, cleanPath + ext))) { return `${TEMPLATE_PAGES_DIR}/${cleanPath}${ext}`; } } return null; } // Production: cached lookup if (!templateCache) { templateCache = new Map(); const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR); if (existsSync(pagesDir)) { const scan = (dir: string, prefix: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { // Skip private files even within pages/ (e.g. pages/_helper.twig) if (entry.name.startsWith("_")) continue; const rel = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { scan(resolve(dir, entry.name), rel); } else if (entry.name.endsWith(".twig") || entry.name.endsWith(".html")) { const urlPath = rel.replace(/\.(twig|html)$/, ""); if (!templateCache!.has(urlPath)) { templateCache!.set(urlPath, `${TEMPLATE_PAGES_DIR}/${rel}`); } } } }; scan(pagesDir, ""); } } return templateCache.get(cleanPath) ?? null; } function renderLandingPage(port: number = 7148): string { const version = TINA4_VERSION; const galleryItems = [ { id: "rest-api", icon: "🚀", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", accent: "accent-blue", tryUrl: "/api/gallery/hello" }, { id: "orm", icon: "🗃", name: "ORM", desc: "Product model with CRUD endpoints", accent: "accent-green", tryUrl: "/api/gallery/products" }, { id: "auth", icon: "🔒", name: "Auth", desc: "JWT login form with token display", accent: "accent-purple", tryUrl: "/gallery/auth" }, { id: "queue", icon: "⚡", name: "Queue", desc: "Background job producer and consumer", accent: "accent-blue", tryUrl: "/api/gallery/queue/produce" }, { id: "templates", icon: "📄", name: "Templates", desc: "Twig template with dynamic data", accent: "accent-green", tryUrl: "/gallery/page" }, { id: "database", icon: "📡", name: "Database", desc: "Raw SQL queries with the Database class", accent: "accent-purple", tryUrl: "/api/gallery/db/tables" }, { id: "error-overlay", icon: "💥", name: "Error Overlay", desc: "See the rich debug error page with stack trace", accent: "accent-blue", tryUrl: "/api/gallery/crash" }, ]; const deployed = getGalleryDeployedState(); const galleryCards = galleryItems.map((item) => { const isDeployed = deployed[item.id] === true; const tryBtn = isDeployed ? `Try It` : ""; const viewBtn = isDeployed ? `View` : ""; const deployBtn = isDeployed ? `Deployed` : ``; return ``; }).join("\n "); return ` Tina4NodeJs

Tina4NodeJs

The Intelligent Native Application 4ramework

Server running Port ${port} v${version}

Getting Started

// app.ts
import { startServer, Router } from "tina4-nodejs";

Router.get("/hello", async (req, res) => {
    return res.json({ message: "Hello World!" });
});

startServer({ port: 7148 });  // starts on port 7148
`; } // Module-level dispatch function — assigned when startServer() is called. // Allows handle() to route requests without requiring a reference to the server. let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise) | null = null; // The DispatchContext startServer() built for the currently-running server (if // any) — see getLiveDispatchContext() below. `null` until startServer() runs. let _liveDispatchContext: DispatchContext | null = null; /** * The DispatchContext the currently-running server (if any) built at * startServer() time — the FULL context (real ORM/Swagger/DevAdmin/CSRF * wiring included), not the lighter one buildDispatchContext() makes on its * own. `null` when startServer() has not run yet in this process. * * TestClient prefers this over a freshly-built context so a no-argument * `new TestClient()` gets maximum fidelity to whatever server is actually * live — mirroring Ruby's `RackApp.current` (feature 131, TC-DEC-01). */ export function getLiveDispatchContext(): DispatchContext | null { return _liveDispatchContext; } // Lazily-resolved Database.resetRequestCaches binding (or null if the ORM is // not installed). Memoised so the dynamic import happens once, then every // request reuses the resolved function — see the request-scoped cache boundary // in dispatch(). /** Module-level server handle for start()/stop() parity. */ let _serverHandle: { close: () => void; router: Router; port: number } | null = null; /** * Start the Tina4 HTTP server. * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby. */ /** * Watch for a handler that occupies the event loop, and say so. * * Node runs ONE loop. An `await`ing handler yields it and blocks nobody - * measured, /fast answers in 0.030s while a route awaits a 2s timer. A * CPU-BOUND handler does not yield, and everything else waits: the same /fast * took 1.575s during a 2s busy loop. * * That is inherent to a single-loop runtime, not a bug to engineer away. PHP * fixed its equivalent by forking per request because `sleep()` is the obvious * thing to write there and it blocks; in JavaScript the obvious thing is * `await`, which does not. So the exposure here is narrower - CPU-bound work * and synchronous I/O - and the honest fix is to make it VISIBLE rather than * to move handlers onto threads a closure cannot cross. * * The mechanism is loop lag: a timer set for TICK_MS fires late by however * long the loop was blocked. If that lateness passes the threshold, something * held the loop and the developer wants to know which. * * A 100ms repeating timer is the classic way to pin a process open forever, so * there are two guards against it: close() stops the timer, and the timer is * unref'd. Measured: either one alone is enough, and the signal path exits * regardless of both. They are kept together because they cost nothing and * cover different exits - close() covers the in-process handle, unref() covers * a path that never reaches close() at all. */ const LOOP_WATCHDOG_TICK_MS = 100; function startLoopWatchdog(): { stop: () => void } { const raw = (process.env.TINA4_LOOP_LAG_WARN_MS ?? "").trim(); // 0 or a negative value disables it; a non-numeric value falls to the // default rather than silently disabling a diagnostic. const threshold = /^\d+$/.test(raw) ? parseInt(raw, 10) : 250; if (threshold <= 0) { return { stop: () => {} }; } let last = Date.now(); let warned = 0; const timer = setInterval(() => { const now = Date.now(); const lag = now - last - LOOP_WATCHDOG_TICK_MS; last = now; if (lag < threshold) return; // Rate-limited: a handler that blocks on every request would otherwise // produce a wall of identical warnings, which people filter out. warned++; if (warned > 5 && warned % 20 !== 0) return; Log.warning( `Event loop blocked for ${lag}ms. Node serves every request on one loop, ` + `so a handler doing CPU-bound work or synchronous I/O stalls all the ` + `others for that long. Move the work to Tina4's queue, or await it. ` + `Set TINA4_LOOP_LAG_WARN_MS to change the ${threshold}ms threshold, or 0 to silence.`, ); }, LOOP_WATCHDOG_TICK_MS); timer.unref(); return { stop: () => clearInterval(timer) }; } export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> { const isManaged = process.argv.includes('--managed'); if (!isManaged && process.env.TINA4_OVERRIDE_CLIENT !== 'true') { console.log(); console.log('='.repeat(60)); console.log(); console.log(' Tina4 must be started with the tina4 CLI:'); console.log(); console.log(' tina4 serve (development)'); console.log(' tina4 serve --production (production)'); console.log(); console.log(' Install: cargo install tina4'); console.log(' Docs: https://tina4.com'); console.log(); console.log(' To run directly, add to .env:'); console.log(' TINA4_OVERRIDE_CLIENT=true'); console.log(); console.log('='.repeat(60)); console.log(); process.exit(1); } _serverHandle = await startServer(config); return _serverHandle; } /** * Stop the running Tina4 server gracefully. */ export function stop(): void { if (_serverHandle) { _serverHandle.close(); _serverHandle = null; } } /** * Dispatch a raw Node.js request through the Tina4 router and write the response. * Requires startServer() to have been called first. * Useful for testing and embedding. */ export async function handle(rawReq: IncomingMessage, rawRes: ServerResponse): Promise { if (!_dispatchFn) throw new Error("Tina4 server not started — call startServer() first"); return _dispatchFn(rawReq, rawRes); } /** * Run one global-middleware pass. * * The pre-match and post-match passes had byte-identical bodies; this is that * body, once. * * AFTER-ON-4xx RULE (M2): the after_* hooks ALWAYS run when a before_* * short-circuited (4xx, a clean 500, or the response already ended), so they * can still add headers and logging. Consistent across all four frameworks. * * @returns true when the pass answered the request and the handler must be skipped */ async function runGlobalMiddlewarePass( middleware: unknown[], req: Tina4Request, res: Tina4Response, ): Promise { if (middleware.length === 0) return false; const [, , proceed] = await MiddlewareRunner.runBefore(middleware as never, req, res); if (proceed && !res.raw.writableEnded) return false; await MiddlewareRunner.runAfter(middleware as never, req, res); if (!res.raw.writableEnded) res.raw.end(); return true; } /** * Invoke a matched route's handler, binding path params BY NAME. * * A handler declares whatever it needs - `(id, request, response)`, `(req, res)`, * or nothing - and each parameter is resolved by its name: a path param wins, * then `request`/`req`, then the response. */ /** * Resolve a handler's argument list to `[reqOrParam, resOrParam, ...]` values. * * By-name path preserved (route-param names, `req`/`request`, `res`/`response`) * so existing code keeps its DX. Any remaining unmatched name falls back to * POSITIONAL binding: first unmatched -> request, rest -> response. That makes * dispatch bundler-safe by construction: Bun `--compile` and terser/esbuild * identifier-mangling rename `(req, res)` to `(req2, r$0)` — the by-name path * misses, the positional fallback catches, and the handler still receives the * request as its first argument. Fixes #56. * * Exported so the regression test can pin the behaviour directly, without a * live HTTP server. */ export function resolveHandlerArgs( handler: unknown, req: Tina4Request, res: Tina4Response, routeParams: Record, ): unknown[] { const fnStr = (handler as { toString(): string }).toString(); const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/); const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? []; const filteredArgs = argNames.filter((n: string) => n.length > 0); if (filteredArgs.length === 0) return []; let unmatchedPos = 0; return filteredArgs.map((name: string) => { if (name in routeParams) return routeParams[name]; if (name === "request" || name === "req") return req; if (name === "response" || name === "res") return res; return (unmatchedPos++ === 0) ? req : res; }); } async function invokeRouteHandler( match: { handler: unknown }, req: Tina4Request, res: Tina4Response, ): Promise { const routeParams = req.params || {}; const args = resolveHandlerArgs(match.handler, req, res, routeParams); if (args.length === 0) return await (match.handler as any)(); return await (match.handler as any)(...args); } /** * Render a template route's return value, when it is one. * * A route that exports a template AND whose handler returned a plain object * renders through the template engine instead of being sent as JSON. Every * other shape - a handler that already wrote, no template, null/undefined, a * string, a Buffer - is left exactly as it was. */ async function renderIfTemplateRoute( match: { template?: string }, res: Tina4Response, result: unknown, ): Promise { if (res.raw.writableEnded) return; if (!match.template) return; if (result === null || result === undefined) return; if (typeof result !== "object" || Buffer.isBuffer(result)) return; await res.render(match.template, result as Record); } /** State the matched-route pipeline needs. */ interface MatchedRouteContext { req: Tina4Request; res: Tina4Response; pathname: string; match: { params?: Record; handler: unknown; template?: string; middlewares?: unknown[] }; postMatchMiddleware: unknown[]; /** * EVERY global middleware, both phases. The after pass runs over all of it, * not just the post-match group - see runMatchedRoute. */ allGlobalMiddleware: unknown[]; } /** * Run a matched route end to end. * * Order, and it is BEHAVIOUR (ADR-0012): * post-match globals -> auth gate -> the route's own middleware -> handler * * The globals run BEFORE the gate so a rate limiter can throttle a brute-force * login and an access log records the 401 - neither is possible if they only * run on authenticated requests (Django enforces auth in a view decorator after * all MIDDLEWARE; Laravel's `web` group runs before the `auth` route * middleware; ASP.NET puts UseAuthorization last before the endpoint). * * The route's OWN middleware runs AFTER, so middleware attached to a secured * route never processes an unauthenticated request. Node used to run it first, * which meant a body-parsing or audit middleware on a secured route saw traffic * that was about to be rejected. */ async function runMatchedRoute(ctx: MatchedRouteContext): Promise { const { req, res, match, postMatchMiddleware } = ctx; req.params = match.params as never; // DEVADMIN-DEC-01/02: fail-closed same-origin + loopback gate on every /__dev // write (POST/PUT/PATCH/DELETE), BEFORE the handler runs. Closes drive-by CSRF // (a cross-origin page POSTing to /file/save then /reload) and a network-exposed // debug box. Scoped to /__dev so /__feedback + /ai are unaffected; GET/HEAD/ // OPTIONS are safe and skip the gate. The MCP endpoints keep their own 404 gate // (devMutationDenial skips the mcp prefixes for the loopback part). const devMethod = (req.method ?? "GET").toUpperCase(); if (ctx.pathname.startsWith("/__dev") && !DEV_SAFE_METHODS.has(devMethod)) { const denial = devMutationDenial(req); if (denial) { res.json({ ok: false, error: denial.error }, denial.status); return; } } if (await runGlobalMiddlewarePass(postMatchMiddleware, req, res)) return; // Auth enforcement lives in enforceRouteAuth (authGate.ts) so the in-process // TestClient enforces the EXACT same gate - parity with Python #PY2, where a // tokenless write must 401 in tests too, or a green test hides a live 401. // Dev admin routes (/__dev) are always public. if (enforceRouteAuth(req, res, match as never, ctx.pathname.startsWith("/__dev"))) return; // The route's OWN class middleware: its beforeX hooks run inside // runRouteMiddlewares, its afterX hooks join the after pass below - one // effective list for the response phase, the way Python merges the globals // and the route's middleware into `_effective_middleware` for both passes. const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass); let handlerSkipped = false; if (match.middlewares && match.middlewares.length > 0) { const proceed = await runRouteMiddlewares(match.middlewares as never, req, res); handlerSkipped = !proceed || res.raw.writableEnded; } if (!handlerSkipped) { await renderIfTemplateRoute(match, res, await invokeRouteHandler(match, req, res)); } // Global afterX hooks (logging / post-processing), over EVERY global // middleware - both phases, not just the post-match group - PLUS the route's // own middleware classes. // // The response phase must cover everything the request phase entered, so it // also runs when the ROUTE's middleware short-circuited - the after hooks of a // middleware whose before hook denied the request are exactly what add the // headers and the access-log line for that denial. Dispatch used to return // early there, so a cache HIT (a route middleware that answers and ends) // skipped every global after hook, and a route middleware that short-circuited // WITHOUT ending the response left the request hanging with no end() at all. // // Running only the post-match group meant a `preMatch` middleware's afterX NEVER ran // on a successful request: measured 0 runs in 5 requests. An acquire/release // pair leaked one slot per request, unbounded; a timer started in beforeX was // never stopped; an access log saw the request and never the response - the // very hole ADR-0012 moved the globals ahead of the auth gate to close. // // Worse, it inverted: the pre-match afterX DID run when the pre-match pass // short-circuited, so it fired on the error path and not the happy one. // // Django unwinds its single MIDDLEWARE list in reverse, Laravel runs the // response/terminate phase for global, group AND route middleware, Rails runs // every declared after_action, ASP.NET unwinds through every component // entered. Ruby and PHP already did this. Splitting the BEFORE pass by // dependency (ADR-0012) says nothing about the after pass: an after hook adds // headers or logging and needs no route metadata either way. // // No double-run: when the pre-match pass short-circuits, dispatch returns // before ever reaching this. // // Header mutations here are no-ops once the response is flushed - Node sends // headers with the body - so response headers belong in beforeX. const afterMiddleware = [...ctx.allGlobalMiddleware, ...routeMiddlewareClasses]; if (afterMiddleware.length > 0) { await MiddlewareRunner.runAfter(afterMiddleware as never, req, res); } if (!res.raw.writableEnded) res.raw.end(); } /** State the response-end wrappers need. */ interface ResponseWrapContext { req: Tina4Request; res: Tina4Response; pathname: string; router: Router; reqStartTime: number; requestId: string; /** Holder, because the wrapper reads this at end() time - after route matching. */ matchedPattern: { value: string }; isAiPortRequest: boolean; } /** * Block /__dev_reload on the AI port so AI tools never trigger a browser reload. * * @returns true when the request was answered */ function blockAiPortReload(res: Tina4Response, pathname: string, isAiPortRequest: boolean): boolean { if (!isAiPortRequest || pathname !== "/__dev_reload") return false; res.raw.writeHead(404, { "Content-Type": "application/json" }); res.raw.end(JSON.stringify({ error: "Not available on AI port" })); return true; } /** * Rebuild the arguments `end` was called with. * * Node's `end` has three overloads and the wrappers must forward exactly the * shape they were given, or a callback lands in the encoding slot. */ function callOriginalEnd( originalEnd: (...args: any[]) => any, chunk: unknown, encodingOrCb?: BufferEncoding | (() => void), cb?: () => void, ): any { if (typeof encodingOrCb === "function") return originalEnd(chunk, encodingOrCb); if (encodingOrCb !== undefined) return originalEnd(chunk, encodingOrCb, cb); return originalEnd(chunk, cb); } /** Whether the response is declaring itself as HTML. */ function isHtmlResponse(res: Tina4Response): boolean { const contentType = res.raw.getHeader("content-type"); return typeof contentType === "string" && contentType.includes("text/html"); } /** The chunk as an HTML string, or null when it is neither a string nor a Buffer. */ function asHtmlString(chunk: unknown): string | null { if (typeof chunk === "string") return chunk; if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8"); return null; } /** * Whether this response's body can still have HTML spliced into it. * * `text/html` is NOT enough on its own. A static-file response (static.ts) gzips * itself and sets Content-Encoding BEFORE it calls `res.raw.end()` - and that * `end()` is the intercepted one below, so the chunk arriving there is * COMPRESSED BYTES, not markup. Reading them back as UTF-8 to inject a toolbar * replaces every byte outside ASCII with U+FFFD, and the browser is handed a * gzip stream whose header is `1f ef bf bd` instead of `1f 8b`. Chrome answers * ERR_CONTENT_DECODING_FAILED and the page does not load at all. * * That is not a corner case: in dev mode it corrupted EVERY static .html file * over the 1024-byte compression threshold, which is most real pages, so * `tina4 serve` served an unloadable page while curl (which asks for no * encoding by default) looked perfectly healthy. * * dispatchPipeline.ts already guards the sibling half of this - it refuses to * gzip a body some earlier stage has already encoded - with the same test. This * is the other half: do not TEXT-EDIT a body some earlier stage has encoded. */ function isInjectableHtml(res: Tina4Response): boolean { return isHtmlResponse(res) && !res.raw.getHeader("content-encoding"); } /** * Inject the dev toolbar (dev mode only) and the feedback widget into an HTML body. * * The feedback injector re-checks the whitelist, path and html marker itself, * so calling it unconditionally is cheap when it no-ops. */ function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: string): string { if (!devToolbar) return injectFeedbackWidget(ctx.req, html); const toolbarCtx: DevToolbarContext = { version: TINA4_VERSION, method: ctx.req.method ?? "GET", path: ctx.pathname, matchedPattern: ctx.matchedPattern.value || ctx.pathname, requestId: ctx.requestId, routeCount: ctx.router.getRoutes().length, // Suppress the live reloader on the AI/stable port (data-reload="0"); the // toolbar JS early-returns when data-reload !== "1". Mirrors PHP's // suppressReload flag. // Also suppress on the dev-admin dashboard (any /__dev page): its SPA reloads // itself gently, so the toolbar's full-page reloader must not fire there. reload: !ctx.isAiPortRequest && !ctx.pathname.startsWith("/__dev"), }; return injectFeedbackWidget(ctx.req, injectDevToolbar(html, toolbarCtx)); } /** * Wrap `res.raw.end` to inject the dev toolbar and/or the feedback widget, and * to capture the request for the dev inspector. * * Two modes, and the distinction is deliberate: * * dev mode (off the AI port, outside /__dev) gets the toolbar, the feedback * widget and inspector capture; * * otherwise a whitelisted user still gets the feedback widget alone. The * injector re-checks the whitelist, path and html marker itself, so the * wrapper is cheap when it no-ops. * * Content-Length is removed on injection because the body size changes. */ function wrapResponseEnd(ctx: ResponseWrapContext): void { const { req, res, pathname } = ctx; const devToolbar = isDevMode() && !pathname.startsWith("/__dev") && !ctx.isAiPortRequest; const feedbackOnly = !devToolbar && feedbackEnabled() && !pathname.startsWith("/__dev") && !pathname.startsWith("/__feedback"); if (!devToolbar && !feedbackOnly) return; const originalEnd = res.raw.end.bind(res.raw); res.raw.end = function ( chunk?: unknown, encodingOrCb?: BufferEncoding | (() => void), cb?: () => void, ) { if (devToolbar && ctx.reqStartTime > 0) { RequestInspector.capture( req.method ?? "GET", pathname, res.raw.statusCode ?? 200, Date.now() - ctx.reqStartTime, ); } // An ENCODED body is passed straight through, untouched and with its // Content-Length intact: the length static.ts set describes the compressed // bytes and is correct, and there is nothing here we could inject into // without destroying them. See isInjectableHtml. if (isInjectableHtml(res)) { const html = asHtmlString(chunk); if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html); // Dropped for ANY html response, not only one carrying a body: that is // what the two original wrappers did, and a refactor does not get to // narrow it. An end() with no chunk on a text/html response still has // its stale content-length removed. if (!res.raw.headersSent) res.raw.removeHeader("content-length"); } return callOriginalEnd(originalEnd, chunk, encodingOrCb, cb); } as typeof res.raw.end; } /** * Turn an uncaught dispatch error into a response, and surface it. * * v3.13.7: log structured + surface to observability BEFORE rendering. * Listeners get the canonical {exception, request} payload mirrored by Python / * PHP / Ruby. Listener errors are swallowed and warning-logged so a broken * listener cannot break the 500 page. * * SECURITY (CWE-209): the production response body must NOT contain the stack * trace or the exception message. `error_message` is passed empty - 500.twig * only renders the trace block when it is truthy. The rich overlay with stack * and source context is dev-only. * * @param err The thrown value (not necessarily an Error) * @param req The request, for the log line and the error page * @param res The response; untouched if it has already ended * @param templatesDir Where to look for 500.twig */ async function renderDispatchError( err: unknown, req: Tina4Request, res: Tina4Response, templatesDir: string, ): Promise { Log.error(`Route error: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, { method: req?.method, path: req?.path, }); try { const { Events } = await import("./events.js"); Events.emit("tina4.request.error", { exception: err, request: req }); } catch (listenerErr) { try { Log.warning( `Listener for tina4.request.error raised: ${ listenerErr instanceof Error ? `${listenerErr.name}: ${listenerErr.message}` : String(listenerErr) }` ); } catch { // Log failures must never block the 500 render. } } if (res.raw.writableEnded) return; if (isDevMode() && err instanceof Error) { // OVERLAY-DEC-03: guard the dev-overlay render. This call site sits INSIDE the // dispatch catch, so if the overlay itself throws (a malformed frame, an // unrenderable request value) it would double-fault out of dispatch. Wrap it and // fall through to the same safe production page, so a broken overlay still yields a // bounded 500 — never a crash. try { const { renderErrorOverlay } = await import("./errorOverlay.js"); const overlayHtml = renderErrorOverlay(err, req); res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); res.raw.end(overlayHtml); return; } catch (overlayErr) { try { Log.warning( `Error overlay render failed, serving the safe page: ${ overlayErr instanceof Error ? `${overlayErr.name}: ${overlayErr.message}` : String(overlayErr) }` ); } catch { // Log failures must never block the 500 render. } // fall through to the safe production page below } } // The canonical per-request id (set at the top of dispatch), so the id a // user reports off the 500 page matches the log lines and the X-Request-ID // response header - not a throwaway base36 clock value. const requestId = Log.getRequestId() ?? randomBytes(4).toString("hex"); // ERR-DEC-02: a JSON API client gets the JSON error body directly. The // message is ALWAYS the generic "Internal Server Error" here - CWE-209 - // never the real exception (same guarantee as error_message='' below). if (wantsJson(req)) { const body = negotiatedErrorBody(500, "Internal Server Error", requestId); res.raw.writeHead(500, { "Content-Type": "application/json" }); res.raw.end(JSON.stringify(body)); return; } const html500 = await renderErrorPage(500, { error_message: "", request_id: requestId, path: req.path, }, templatesDir); if (html500) { res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); res.raw.end(html500); } else { res(negotiatedErrorBody(500, "Internal Server Error", requestId), 500); } } /** * State the not-found fallback stages need. * * Passed explicitly rather than closed over, so each stage is callable on its * own instead of only from inside `dispatch`. That coupling is what the * extraction removes. */ interface FallbackContext { req: Tina4Request; res: Tina4Response; pathname: string; router: Router; port: number; staticDir: string; srcPublicDir: string; templatesDir: string; frondEngine: { render(file: string, data: Record): string } | null; swaggerAssetsEnabled: boolean; } /** * Everything one request's dispatch needs beyond req/res: the resolved * router, middleware chain, filesystem roots and template engine a boot * resolved once. * * Passed explicitly — never closed over — so the SAME dispatch function can * be driven from TWO places: the live socket server (startServer() builds * the full context, wired to real ORM/Swagger/DevAdmin/CSRF registrations) * and the in-process TestClient (buildDispatchContext(), below, when no live * server is running in this process, or when the caller wants an isolated * router). Both run the identical runDispatch()/dispatchInner() — no stage * is ever skipped for one caller and not the other (feature 131, TC-DEC-01 * — this was a closure trapped inside startServer() until now, which is * exactly why TestClient could not call it and grew its own competing * dispatch instead). */ export interface DispatchContext { router: Router; middleware: MiddlewareChain; port: number; staticDir: string; srcPublicDir: string; templatesDir: string; frondEngine: { render(file: string, data: Record): string } | null; swaggerAssetsEnabled: boolean; } /** * Serve a template file for a GET (e.g. /hello -> src/templates/pages/hello.twig). * * Rendered through Frond so {% include %} / {% extends %} work, rather than a * raw readFileSync. */ function serveTemplateFallback(ctx: FallbackContext): boolean { if ((ctx.req.method ?? "GET") !== "GET") return false; const tplFile = resolveTemplate(ctx.pathname, ctx.templatesDir); if (!tplFile) return false; const html = ctx.frondEngine ? ctx.frondEngine.render(tplFile, {}) : readFileSync(resolve(ctx.templatesDir, tplFile), "utf-8"); ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" }); ctx.res.raw.end(html); return true; } /** * The branded landing page. * * Renders only at "/" AND only when TINA4_DEBUG=true. In production "/" with no * static index.html and no pages/index.twig falls through to a clean 404, so * the framework's welcome, gallery and version never leak to real users. */ function serveLandingPage(ctx: FallbackContext): boolean { if ((ctx.req.method ?? "GET") !== "GET") return false; if (ctx.pathname !== "/" || !isDevMode()) return false; ctx.res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" }); ctx.res.raw.end(renderLandingPage(ctx.port)); return true; } /** * RFC 9110 conformance - before falling through to 404, check whether the PATH * is registered under any OTHER method. * - OPTIONS -> 204 with Allow (s9.3.7) * - Any other method (PUT on GET-only, TRACE, CONNECT) -> 405 with Allow * (s15.5.6 + s10.2.1) */ function serveMethodNotAllowed(ctx: FallbackContext): boolean { const allowedMethods = ctx.router.methodsAllowedForPath(ctx.pathname); if (allowedMethods.length === 0) return false; const allowHeader = allowedMethods.join(", "); const requestMethod = (ctx.req.method ?? "GET").toUpperCase(); if (requestMethod === "OPTIONS") { ctx.res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" }); ctx.res.raw.end(); return true; } const body = JSON.stringify({ error: "Method Not Allowed", path: ctx.pathname, method: requestMethod, allow: allowedMethods, statusCode: 405, }); ctx.res.raw.writeHead(405, httpReason(405), { Allow: allowHeader, "Content-Type": "application/json", "Content-Length": String(Buffer.byteLength(body)), }); ctx.res.raw.end(body); return true; } /** * No route claimed the path, so NOW try the filesystem (ADR-0010). * * Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work. * The framework-bundled directory holds the Swagger UI (public/swagger/), so * that lookup MUST honour the swagger gate or /swagger is served in production * regardless of TINA4_SWAGGER_ENABLED / TINA4_DEBUG. */ function serveStaticAsset(ctx: FallbackContext): boolean { // ONE search order across the four frameworks (ST-SEARCHDIR-DIVERGE): // TINA4_PUBLIC_DIR override first (ST-PUBLICDIR-ENV-PARTIAL), then the app's // public then src/public, then the framework built-in public last. const custom = process.env.TINA4_PUBLIC_DIR; if (custom && existsSync(custom) && tryServeStatic(custom, ctx.req, ctx.res)) return true; if (existsSync(ctx.staticDir) && tryServeStatic(ctx.staticDir, ctx.req, ctx.res)) return true; if (existsSync(ctx.srcPublicDir) && tryServeStatic(ctx.srcPublicDir, ctx.req, ctx.res)) return true; if (ctx.swaggerAssetsEnabled || !isSwaggerAssetPath(ctx.pathname)) { if (tryServeStatic(BUILTIN_PUBLIC_DIR, ctx.req, ctx.res)) return true; } return false; } /** Terminal stage: 404, with the canonical reason phrase so the status line is well-formed. */ async function serveNotFound(ctx: FallbackContext): Promise { // The canonical per-request id (set at the top of dispatch), so the id a // user reports off the 404 matches the log lines and the X-Request-ID // response header - not a throwaway (ERR-404-REQUESTID). const requestId = Log.getRequestId() ?? randomBytes(4).toString("hex"); // ERR-DEC-02: a JSON API client gets the JSON error body directly - no // need to even try the HTML template. if (wantsJson(ctx.req)) { const body = negotiatedErrorBody(404, "Not Found", requestId); ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" }); ctx.res.raw.end(JSON.stringify(body)); return true; } const html404 = await renderErrorPage(404, { path: ctx.pathname, request_id: requestId }, ctx.templatesDir); if (html404) { ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" }); ctx.res.raw.end(html404); } else { ctx.res(negotiatedErrorBody(404, `No route found for ${ctx.req.method} ${ctx.pathname}`, requestId), 404); } return true; } /** * The not-found fallback chain, in order. Data, so the pipeline can be read and * compared across frameworks without reading an implementation. * * Order is BEHAVIOUR: a template beats the landing page (so a project's own * pages/index.twig wins at "/"), 405 beats static (a known path with the wrong * method is not a missing file), and the 404 is terminal. */ const FALLBACK_STAGES: Array<(ctx: FallbackContext) => boolean | Promise> = [ serveTemplateFallback, serveLandingPage, serveMethodNotAllowed, serveStaticAsset, serveNotFound, ]; /** * Build a standalone DispatchContext bound to `router`, without booting a * real server — no port bind, no route discovery, no ORM/Swagger/DevAdmin/ * CSRF wiring (those are startServer()'s job, and they mutate global/process * state a lightweight caller should not trigger just by dispatching one * request). * * For the in-process TestClient (and any other embedder) to reach the REAL * pipeline when no `startServer()` has run yet in this process, or when the * caller wants an ISOLATED router independent of whatever live server might * be running — parity with the existing `new TestClient(router)` * test-isolation contract (see testClientFrontController.test.ts, which * builds a fresh Router precisely so it never races with concurrent * TestClient suites on the shared defaultRouter). * * Best-effort, like startServer()'s own setup: Frond and @tina4/swagger are * optional dependencies of @tina4/core, so a missing package degrades to * `null` / `false` rather than throwing — a template route or a swagger * asset request simply falls through the fallback chain the same way it * would if those packages were never installed. */ export async function buildDispatchContext(router: Router, base?: string): Promise { const root = base ? resolve(base) : process.cwd(); const staticDir = resolve(root, "public"); const srcPublicDir = resolve(root, "src/public"); const templatesDir = resolve(root, "src/templates"); let frondEngine: DispatchContext["frondEngine"] = null; try { const { Frond } = await import("../../frond/src/engine.js"); frondEngine = new Frond(templatesDir); } catch { // Frond not available — template-route fallback stays inert, same guard startServer() uses. } let swaggerAssetsEnabled = false; try { const swagger = await import("../../swagger/src/index.js"); swaggerAssetsEnabled = swagger.swaggerEnabled(); } catch { // Swagger not available — bundled swagger assets stay ungated-but-absent, same as startServer(). } return { router, middleware: new MiddlewareChain(), port: 7148, staticDir, srcPublicDir, templatesDir, frondEngine, swaggerAssetsEnabled, }; } /** * Drive one request through PROLOGUE_STAGES -> REQUEST_STAGES -> route match * -> ROUTE_STAGES (or FALLBACK_STAGES on a miss) -> ERROR_STAGES. This is the * function every stage list in dispatchPipeline.ts describes; ctx supplies * everything a boot resolved once (router, middleware, filesystem roots, * Frond) so this function itself closes over nothing from a particular * server instance. */ async function dispatchInner( ctx: DispatchContext, rawReq: IncomingMessage, rawRes: ServerResponse, requestId: string, ): Promise { const req = createRequest(rawReq); const res = createResponse(rawRes); // PROLOGUE STAGES. Extracted to dispatchPipeline.ts - see PROLOGUE_STAGES // there for the ordered list and why the order is behaviour, not taste. // These four close over nothing but the raw req/res (never ctx), which is // why they take no context object at all. await resetRequestCaches(); headStripIntercept(rawReq, rawRes); // Feature 40 (CE-DEC-01/02): gzip + ETag + conditional-GET for every // dynamic response. Installed right after headStripIntercept so it runs // (execution order is the REVERSE of installation for these monkey-patch // interceptors) AFTER the dev-toolbar/feedback injection but BEFORE the // HEAD body-strip - see compressionEtagIntercept's docblock. compressionEtagIntercept(rawReq, rawRes); // res.render() is handled natively by response.ts via Frond try { // sessionAutoStart is the one prologue stage INSIDE the try. It degrades // on its own (ADR-0021), so the only thing that escapes it is a // TINA4_SESSION_STRICT refusal - and that must become a 500 through the // normal error renderer, like Python's raise becomes a 500 in the ASGI // server. Outside the try it rejected `dispatch`, and nothing awaits the // listener http.createServer() calls: an unhandled rejection that takes // the whole worker down is not "refuse this request". await sessionAutoStart(rawReq, rawRes, req); // Run middleware chain await ctx.middleware.run(req, res); if (res.raw.writableEnded) return; // Parse request body. // // A body that breaks a documented limit is the client's error, not the // server's. PayloadTooLargeError already carried `statusCode = 413` and // nothing read it, so an oversized upload answered 500 - which tells the // caller to retry the exact request that will fail again. try { await req.parseBody(); } catch (err) { const status = (err as { statusCode?: number })?.statusCode; if (typeof status === "number" && status >= 400 && status < 500) { if (!rawRes.writableEnded) { rawRes.statusCode = status; rawRes.setHeader("content-type", "application/json"); rawRes.end(JSON.stringify({ error: (err as Error).message })); } return; } throw err; } const pathname = req.path; // Track request start time for dev inspector const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0; // Mutable ref so wrappedEnd can read the matched pattern after route matching // A HOLDER, not a plain string: the end-wrapper below reads it at end() // time, after route matching has assigned it. const matchedPattern = { value: "" }; // Wrap res.raw.end to inject dev toolbar and capture requests // Skip toolbar injection on the AI port (no-reload behaviour) const isAiPortRequest = !!(rawReq as any)._tina4AiPort; // AI port: block /__dev_reload so AI tools never trigger a browser reload. if (blockAiPortReload(res, pathname, isAiPortRequest)) return; // Wrap res.raw.end so the dev toolbar / feedback widget can be injected // and the request captured for the inspector. Extracted - see // wrapResponseEnd. matchedPattern is a HOLDER because the wrapper reads // it at end() time, long after route matching has assigned it. wrapResponseEnd({ req, res, pathname, router: ctx.router, reqStartTime, requestId, matchedPattern, isAiPortRequest, }); // Global middleware, split by what it depends on (ADR-0012). The // PRE-match set runs before a route is even looked up, so CORS and // anything else that must survive a short-circuit can set headers that // outlive a 401/403; opt in with `static preMatch = true`. const { pre: preMatchMiddleware, post: postMatchMiddleware } = MiddlewareRunner.partitionByMatchPhase([ ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]), ]); if (await runGlobalMiddlewarePass(preMatchMiddleware, req, res)) return; // Match route. ROUTES BEAT FILES (ADR-0010): static assets resolve in // the not-found fallback below, only once no route has claimed the path. // A file in public/ can arrive from a build step, an upload directory or // a careless deploy, and it must never silently shadow a reviewed route. const match = ctx.router.match(req.method ?? "GET", pathname); if (match) { matchedPattern.value = match.pattern; await runMatchedRoute({ req, res, pathname, match, postMatchMiddleware, allGlobalMiddleware: [...preMatchMiddleware, ...postMatchMiddleware], }); return; } // NOT-FOUND FALLBACK STAGES. Nothing matched a route, so walk the // fallback chain in order - see FALLBACK_STAGES. Each returns true when // it has answered the request. // // ADR-0010 (routes beat files) is why this chain runs AFTER matching: a // file dropped into public/ by a build step or a careless deploy must // never shadow a reviewed route. const fallback: FallbackContext = { req, res, pathname, router: ctx.router, port: ctx.port, staticDir: ctx.staticDir, srcPublicDir: ctx.srcPublicDir, templatesDir: ctx.templatesDir, frondEngine: ctx.frondEngine, swaggerAssetsEnabled: ctx.swaggerAssetsEnabled, }; for (const stage of FALLBACK_STAGES) { if (await stage(fallback)) return; } } catch (err) { await renderDispatchError(err, req, res, ctx.templatesDir); } } /** * Dispatch one request through the REAL Tina4 pipeline (stamps the * per-request correlation id, then hands off to dispatchInner). This is the * exact function startServer() wires to every live socket connection AND to * the module-level handle() — an in-process caller (TestClient) that builds * its own DispatchContext (buildDispatchContext(), or the live one via * getLiveDispatchContext()) runs the IDENTICAL function, so no stage is ever * skipped for one caller and not the other (feature 131, TC-DEC-01). */ export async function runDispatch( ctx: DispatchContext, rawReq: IncomingMessage, rawRes: ServerResponse, ): Promise { // Feature 43: PER-REQUEST correlation id. Honour a sanitized inbound // X-Request-ID so a client or upstream service can thread its own id // through - a CR/LF, over-long or illegal-charset value is rejected (never // echoed) - else generate one. Echo it on the response by stamping the raw // response NOW, so every outcome (200/404/500/413, Tina4Response OR a raw // rawRes.end) carries it. Then establish it in an AsyncLocalStorage so every // log line for this request - across every await - carries it, and two // requests interleaving on the one event loop never read each other's id. const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes(4).toString("hex"); if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId); return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId)); } /** * Sibling loopback addresses to ALSO listen on, so `localhost` reaches this * server whether the OS resolves it to IPv4 (127.0.0.1) or IPv6 (::1). * * `localhost` resolves to `::1` (IPv6) FIRST on Windows, so a server bound only * to `127.0.0.1` — or `0.0.0.0`, the IPv4 wildcard, which does NOT cover IPv6 — * refuses the browser with ERR_CONNECTION_REFUSED even though it is serving, * because nothing listens on `::1`. Binding the sibling family closes that gap. * * Returns only the families a direct bind of `host` does not already cover, as * UNBRACKETED addresses (Node's net/http take a bare "::1"). A host that is * neither loopback nor a wildcard yields an empty list — an explicit LAN * address is bound exactly as asked. Mirrors PHP `Server::loopbackBindHosts`. */ export function loopbackBindHosts(host: string): string[] { const normalized = host.trim().replace(/^\[|\]$/g, "").toLowerCase(); switch (normalized) { case "localhost": return ["127.0.0.1", "::1"]; case "127.0.0.1": case "0.0.0.0": return ["::1"]; case "::1": case "::": return ["127.0.0.1"]; default: return []; } } /** * Best-effort extra listeners on the SAME port for the sibling loopback family * (see loopbackBindHosts), each REUSING the primary `dispatch` handler so a * request on `::1` is served identically to one on `127.0.0.1`. * * CRITICAL Node gotcha: a listen failure is an ASYNCHRONOUS `'error'` EVENT, * never a thrown exception, so every sibling attaches an `'error'` handler * BEFORE it can fire. A sibling is pure convenience — the primary is the source * of truth — so ANY bind error is swallowed and that sibling skipped: * EADDRINUSE (the family the primary already answers, e.g. the `localhost` * case), EADDRNOTAVAIL / EAFNOSUPPORT (no IPv6 loopback here) are the expected * ones and stay silent; anything else is logged at debug. It never rejects, * never bubbles to `uncaughtException`, and never fails the primary startup. * The handler stays attached for the life of the socket, so a late error on a * kept sibling is swallowed too and cannot crash the process. * * Resolves to the siblings that actually reached `'listening'`, so the shutdown * path closes exactly those. */ function startLoopbackSiblings( port: number, host: string, dispatch: (req: IncomingMessage, res: ServerResponse) => Promise, ): Promise[]> { const siblingHosts = loopbackBindHosts(host); if (siblingHosts.length === 0) return Promise.resolve([]); return Promise.all( siblingHosts.map( (siblingHost) => new Promise | null>((resolveSibling) => { const sibling = createServer(dispatch); let settled = false; sibling.on("error", (err: NodeJS.ErrnoException) => { const expected = err.code === "EADDRINUSE" || err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT"; if (!expected) { Log.debug( `Loopback sibling ${siblingHost}:${port} not bound ` + `(${err.code ?? err.message}) — skipped, primary already serves`, ); } if (!settled) { settled = true; resolveSibling(null); } // Already kept/settled: swallow so a late error never reaches // uncaughtException and never disturbs the primary listener. }); sibling.listen(port, siblingHost, () => { settled = true; resolveSibling(sibling); }); }), ), ).then((results) => results.filter((s): s is ReturnType => s !== null), ); } function startProductionCluster(port: number, host: string): { close: () => void; router: Router; port: number; } { const numCPUs = os.cpus().length; const displayHost = host === "0.0.0.0" ? "localhost" : host; const isTty = isatty(1); const color = isTty ? "\x1b[32m" : ""; const reset = isTty ? "\x1b[0m" : ""; const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase(); if (!isBannerSuppressed()) { const [swaggerLine] = bannerSurfaceLines(port, { swaggerEnabled: swaggerAdvertised(), devAdminEnabled: false }); console.log(`${color} Tina4 Node.js v${TINA4_VERSION} — The Intelligent Native Application 4ramework Server: http://${displayHost}:${port} (cluster, ${numCPUs} workers)${swaggerLine} Debug: OFF (Log level: ${logLevel}) ${reset}`); } for (let i = 0; i < numCPUs; i++) cluster.fork(); cluster.on("exit", (worker, code) => { if (code !== 0) { console.log(` Worker ${worker.process.pid} exited (code ${code}), restarting...`); cluster.fork(); } }); return { close: () => { stopAllBackgroundTasks(); for (const id in cluster.workers) cluster.workers[id]?.kill(); }, router: new Router(), port, }; } function wireLocaleGlobal(frondEngine: any, base: string): void { if (!frondEngine) return; const localeDir = resolve(base, process.env.TINA4_LOCALE_DIR ?? "src/locales"); if (!existsSync(localeDir)) return; try { const localeFiles = readdirSync(localeDir).filter((file: string) => file.endsWith(".json")); if (localeFiles.length === 0 || frondEngine.globals?.t) return; const i18nInstance = new I18n(process.env.TINA4_LOCALE ?? "en", localeDir); frondEngine.addGlobal("t", (key: string, params?: Record) => i18nInstance.t(key, params)); } catch { // Locale directory unreadable — skip auto-wire. } } export async function startServer(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number; }> { // Load env early so TINA4_DEBUG is available for the cluster decision. // Precedence MUST be: real environment (set before boot) > .env.local > .env. // loadEnv(override=false) is first-wins (only sets a key not already present), // so load .env.local BEFORE .env — both with override=false. A real env var // set before boot is already present and wins over both; .env.local fills // local-only keys; .env fills whatever neither set. Loading .env.local with // override=true would let a stray gitignored .env.local clobber an explicitly // set real env var (e.g. a production TINA4_SECRET) — never do that. loadEnv(".env.local"); loadEnv(); // Auto-generate a per-machine dev secret to a gitignored .env.local when one // is missing (dev only, never CI/prod). Must run after env load and before // any auth use. Local import avoids a load-time cycle through auth. const { ensureDevSecret } = await import("./auth.js"); ensureDevSecret(); // Refuse to boot with pre-3.12 un-prefixed env vars set. _checkLegacyEnvVars(); const resolved = resolvePortAndHost(config); const host = resolved.host; let port = resolved.port; // Claim the requested port — kill whatever is on it if needed. // // NOT in a cluster worker. A worker does not own the port: the primary binds // it once and hands the handle down through cluster's IPC. A worker running // this finds the port "in use" (the primary is holding it) and KILLS the // process holding it, which is its own parent. Every worker did that, then // died itself with `write EPIPE` from cluster._getServer because the primary // it needed to ask for the socket was gone. Cluster mode never served a // single request. if (!cluster.isWorker) { port = findAvailablePort(port); } // Cluster mode for production: fork workers based on CPU count // Only when --production is explicitly set (via TINA4_PRODUCTION env var) const isProduction = (process.env.TINA4_PRODUCTION ?? "").toLowerCase() === "true"; if (cluster.isPrimary && isProduction && os.cpus().length > 1) { return startProductionCluster(port, host); } const base = config?.basePath ? resolve(config.basePath) : process.cwd(); const routesDir = resolve(base, config?.routesDir ?? "src/routes"); const modelsDir = resolve(base, config?.modelsDir ?? "src/models"); const ormDir = resolve(base, "src/orm"); const staticDir = resolve(base, config?.staticDir ?? "public"); // src/public is the second-tier static dir (Python parity). When the user // ships a Vite/SPA build there, src/public/index.html auto-serves at "/". const srcPublicDir = resolve(base, "src/public"); const templatesDir = resolve(base, config?.templatesDir ?? "src/templates"); // .env already loaded above for cluster decision const router = new Router(); const middleware = new MiddlewareChain(); // Expose the active server router globally so dev tools (e.g. the MCP // route_list tool) can introspect the real, fully-populated route table — // startServer builds a fresh Router rather than using defaultRouter, so // file-discovered routes only live here. Mirrors the globalThis.__tina4_db // hook other dev tools read. (globalThis as any).__tina4_router = router; // Merge routes registered via top-level get(), post(), etc. for (const route of defaultRouter.getRoutes()) { router.addRoute(route); } // Register health check endpoint(s). createHealthRoutes returns both the // env-configured path (default /__health) and a /health legacy alias. for (const healthRoute of createHealthRoutes(TINA4_VERSION)) { router.addRoute(healthRoute); } // Initialize Frond template engine let frondEngine: any = null; setDefaultTemplatesDir(templatesDir); try { const { Frond } = await import("../../frond/src/engine.js"); frondEngine = new Frond(templatesDir); // Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered // live block on demand (poll / sse), re-running its provider with the live // request so auth re-applies. Parity with Python/PHP/Ruby /__frond/live/{name}. router.addRoute({ method: "GET", pattern: "/__frond/live/{name}", handler: (req: any, res: any) => { const { status, body } = Frond.respondLive(req, String(req.params?.name ?? "")); return res.html(body, status); }, meta: { summary: "Frond live block refresh", description: "Re-render a server-rendered {% live %} block by name.", tags: ["System"], }, }); // Wire the WebSocket broadcaster so Frond.pushLive can push live-block // updates to connected clients (best-effort). Broadcasts to the block's // declared data-ws path, else falls back to a path named after the block. Frond.setLiveBroadcaster((wsPath: string | null, name: string, envelope: string) => { wsRouteManager.broadcastPath(wsPath || name, envelope); }); } catch { // Frond not available } // Auto-wire i18n → template global t() when locale files exist wireLocaleGlobal(frondEngine, base); // Built-in middleware middleware.use(cors()); middleware.use(requestLogger()); middleware.use(rateLimiter()); // Security headers: register in the default chain UNCONDITIONALLY // (secure-by-default, SECHDR-DEC-01). It is CLASS middleware (a beforeSecurity // hook), so it goes in the MiddlewareRunner registry like CsrfMiddleware — no // opt-in: a default app ships X-Frame-Options/X-Content-Type-Options/CSP/etc. // HSTS stays HTTPS-only. Idempotent (MiddlewareRunner.use de-dupes). MiddlewareRunner.use(SecurityHeadersMiddleware); // Discover file-based routes if (existsSync(routesDir)) { const routes = await discoverRoutes(routesDir); for (const route of routes) { router.addRoute(route); } console.log(`\n Routes discovered:`); for (const route of routes) { console.log(` \x1b[36m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`); } } else { console.log(`\n No routes directory found at ${routesDir}`); } // Configuration-first OIDC mounts after app discovery so canonical-path // collisions fail loudly rather than being overwritten. const { Sso } = await import("./sso.js"); await Sso.mountConfigured(router); // Auto-attach CSRF when TINA4_CSRF is enabled — AFTER route discovery, BEFORE // listen. OFF by default: unset means no CSRF gate; TINA4_CSRF=true/1/yes/on // attaches CsrfMiddleware globally so every write is gated (CSRF-DEC-02). // Idempotent. Mirrors Python's attach_csrf_from_env in server boot. if (attachCsrfFromEnv()) { console.log(`\n \x1b[36mCSRF\x1b[0m protection enabled (TINA4_CSRF)`); } await initializeOrm(config, router, base, ormDir, modelsDir); swaggerAssetsEnabled = await configureSwagger(router, ormDir, modelsDir); // Register dev admin dashboard routes if (DevAdmin.isEnabled()) { DevAdmin.register(router); console.log(` Dev dashboard at \x1b[36mhttp://localhost:${port}/__dev\x1b[0m`); // Live Docs MCP discovery — write .tina4/mcp.json so AI tools find this server. try { const { writeMcpDiscovery } = await import("./docsAutoDiscovery.js"); writeMcpDiscovery(process.cwd(), Number(port)); } catch (e) { console.log(` (mcp discovery skipped: ${(e as Error).message})`); } } // The one DispatchContext this server instance dispatches every request // through — router/middleware/filesystem roots/Frond, resolved above. // runDispatch() (module-level, exported) is the SAME function TestClient // calls for an in-process request; wiring both to one function is the // fix for feature 131 TC-DEC-01 (Node used to re-implement the dispatch // order in TestClient instead of calling this). const dispatchCtx: DispatchContext = { router, middleware, port, staticDir, srcPublicDir, templatesDir, frondEngine, swaggerAssetsEnabled, }; const dispatch = (rawReq: IncomingMessage, rawRes: ServerResponse): Promise => runDispatch(dispatchCtx, rawReq, rawRes); // Assign to module-level so handle() (and TestClient, via // getLiveDispatchContext()) can dispatch without a server reference _dispatchFn = dispatch; _liveDispatchContext = dispatchCtx; // Dual-port (debug + no TINA4_NO_AI_PORT): the MAIN port hot-reloads for the human // dev; the stable AI port (port+1000, created below) suppresses reload/toolbar so an // AI tool can drive it without its own edits triggering refreshes. The tina4 client // posts /__dev/api/reload to the MAIN port. Matches Python (master). const server = createServer(dispatch); // WebSocket upgrade handling on the MAIN port. Two responsibilities: // // 1. WebSocket-primary DevReload (debug only): accept and hold /__dev_reload // upgrades so POST /__dev/api/reload can push an instant reload. Mirrors // Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…). // // 2. USER WS ROUTES (always): a route registered via Router.websocket() / // WebSocketServer.route() is dispatched to a live open/message/close // lifecycle on the real connection — parity with Python/PHP/Ruby. Per-route // auth is enforced here on the upgrade (serveWebSocketRoute): a @secured / // .secure() route rejects a missing/invalid JWT before accepting; public // routes pass. Previously the integrated server only handled /__dev_reload, // so user WS routes never reached a live connection. // // Tracking goes through WsTracker so connections show in the dev-admin list. if (isDevMode()) { devReloadWs.setTracker( (remoteAddress, p) => WsTracker.add(remoteAddress, p), (id) => { WsTracker.remove(id); }, ); wsRouteManager.setTracker( (remoteAddress, p) => WsTracker.add(remoteAddress, p), (id) => { WsTracker.remove(id); }, ); } server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => { const upPath = (req.url ?? "/").split("?")[0]; // Dev-reload channel (debug only). if (isDevMode() && upPath === "/__dev_reload") { devReloadWs.handleUpgrade(req, socket, head); return; } // User-registered WS route — enforces per-route auth, then drives the // open/message/close lifecycle on this connection. Returns false only when // no WS route matches this path. if (serveWebSocketRoute(req, socket, head)) { return; } // No dev-reload and no matching user route — refuse cleanly. try { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); } catch { /* socket already gone */ } }); return new Promise((resolvePromise) => { server.listen(port, host, async () => { // Record THIS process as the Tina4 dev server on this port, so a later // `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01). Only the // single dev process needs it; takeover is dev-gated off in cluster/prod. if (!cluster.isWorker) writePidfile(port); // Dual-stack loopback: ALSO listen on the sibling loopback family on the // SAME port, so `localhost` is reachable whether the OS resolves it to // IPv4 or IPv6 (the Windows `localhost` -> `::1` gap). Best-effort and // async-error-guarded — a sibling that cannot bind is skipped and never // fails the primary startup (see startLoopbackSiblings). Parity with PHP // PR #206 / Python / Ruby. Awaited so it is settled before the caller's // promise resolves (a client can then rely on both families being up). const siblingServers = await startLoopbackSiblings(port, host, dispatch); const displayHost = host === "0.0.0.0" ? "localhost" : host; const isDebug = isTruthy(process.env.TINA4_DEBUG); const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase(); // Green color for Node.js, only when stdout is a TTY const isTty = isatty(1); const color = isTty ? "\x1b[32m" : ""; const reset = isTty ? "\x1b[0m" : ""; // Determine server mode label const serverMode = isDebug ? "single" : (cluster.isWorker ? "cluster-worker" : "single"); // AI dual-port: main port = hot-reload (human dev); port+1000 = stable AI port // (reload/toolbar suppressed) so an AI tool can drive it without its edits // triggering refreshes. The tina4 client fires reloads at the MAIN port. Matches Python. const noAiPort = isTruthy(process.env.TINA4_NO_AI_PORT ?? ""); let aiServer: ReturnType | null = null; let testPort = port + 1000; // A DERIVED port is still a port. `port + 1000` leaves the legal range as // soon as the base port is above 64535, and Node's listen() validates the // number and throws ERR_SOCKET_BAD_PORT SYNCHRONOUSLY — it is not an // "error" event, so the handler below never sees it. Thrown here it // escapes this listen callback ABOVE the resolvePromise() at the end of // it, and in debug mode devAdmin's ErrorTracker has already installed an // uncaughtException handler that only RECORDS the error. Net effect, // measured: the main port stayed bound and served traffic while // `await startServer(...)` never settled — a half-started server that // hangs the caller with nothing printed. `PORT=65000 TINA4_DEBUG=true` // was enough to trigger it; in the test suite an OS-assigned ephemeral // base port (macOS hands out 49152-65535) hit it about one run in // sixteen and the whole file vanished from the counts. const aiPortInRange = testPort <= 65535; if (isDebug && !noAiPort && !aiPortInRange) { Log.warning( `Stable AI/test port ${testPort} is out of range (a port must be <= 65535), ` + `so it is disabled for base port ${port}. Use a base port of 64535 or lower, ` + `or set TINA4_NO_AI_PORT=true to silence this.`, ); } if (isDebug && !noAiPort && aiPortInRange) { // Stable AI port (port+1000): tag requests so /__dev_reload + toolbar are suppressed. aiServer = createServer(async (req, res) => { (req as any)._tina4AiPort = true; await dispatch(req, res); }); // Stable AI port never accepts /__dev_reload (or any) WS upgrade — an AI // tool driving it must never get a reload channel that its own edits trip. aiServer.on("upgrade", (_req: IncomingMessage, socket) => { try { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); } catch { /* socket already gone */ } }); aiServer.on("error", (err: any) => { if (err.code === "EADDRINUSE") { Log.warning(`Test port ${testPort} in use — skipping`); aiServer = null; } }); aiServer.listen(testPort, host); } // Banner goes to stdout via console.log — NOT through the framework logger. // Only advertise the test port when one was actually attempted: an // out-of-range derived port is not bound, and printing it would send a // developer to a URL that cannot exist (same rule as the swagger/dashboard // lines below). const dualPortLines = (isDebug && !noAiPort && aiPortInRange) ? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)` : ""; if (!isBannerSuppressed()) { // Only advertise a surface that is actually reachable (issue #99). With // debug off / in production these endpoints 404, and printing a dead URL // both misleads an operator into believing a dev surface is exposed and // sends a developer to a 404. const [swaggerLine, dashboardLine] = bannerSurfaceLines(port, { swaggerEnabled: swaggerAdvertised(), devAdminEnabled: isDebug, }); console.log(`${color} ______ _ __ __ /_ __/(_)___ ____ _/ // / / / / / __ \\/ __ \`/ // /_ / / / / / / / /_/ /__ __/ /_/ /_/_/ /_/\\__,_/ /_/ ${reset} Tina4 Node.js v${TINA4_VERSION} — The Intelligent Native Application 4ramework Server: http://${displayHost}:${port} (${serverMode})${swaggerLine}${dashboardLine} Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})${dualPortLines} `); } const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER); if (!noBrowser) { // Open the browser on the MAIN port — that's the hot-reload port. openBrowser(`http://${displayHost}:${port}`); } // ── Graceful shutdown ───────────────────────────────────────────── // A container orchestrator sends SIGTERM and SIGKILLs after a grace // period, so dropping in-flight requests here is a production defect, // not a style question. The order below mirrors Python/PHP/Ruby: // stop accepting -> let in-flight requests finish -> release resources // -> exit 0. // // Two traps this replaces, both measured against a real signal: // // 1. Nothing here trapped the signal at all, so a plain startServer() // app died on SIGTERM's DEFAULT disposition: process gone in ~150ms, // every in-flight response dropped, exit 143. // 2. `server.close()` is ASYNCHRONOUS and, in Node's own words, "keeps // existing connections". The CLI's `server.close(); process.exit(0)` // therefore killed the very requests close() was waiting to drain. // The close CALLBACK is the only honest "everything drained" signal. let shuttingDown = false; const closeListeners = (): Promise => new Promise((done) => { let pending = 1 + (aiServer ? 1 : 0) + siblingServers.length; const one = (): void => { if (--pending === 0) done(); }; server.close(one); if (aiServer) aiServer.close(one); // Sibling loopback listeners share this port and are drained too. Each // reached 'listening' (startLoopbackSiblings filters), so close(one) // fires its callback; a throw only if it is already gone, in which // case count it as closed so the counter still settles. for (const sibling of siblingServers) { try { sibling.close(one); } catch { one(); } } // A keep-alive socket with no request on it still counts as an open // connection, so close() would sit on it until the client wandered // off. Without this a fully drained server still burns the whole // shutdown budget. server.closeIdleConnections(); aiServer?.closeIdleConnections(); for (const sibling of siblingServers) sibling.closeIdleConnections(); }); const gracefulShutdown = async (signal: string): Promise => { if (shuttingDown) return; shuttingDown = true; Log.info(`Received ${signal}, shutting down gracefully...`); // Drop our identity marker so a later takeover does not match a dead PID. if (!cluster.isWorker) removePidfile(port); stopAllBackgroundTasks(); // Tell live WebSocket peers we are going away (RFC 6455 s7.4.1 code // 1001) BEFORE closing the listeners. A WS connection never "finishes" // the way a request does, so waiting for one to drain would burn the // whole budget every time; the honest move is a proper close frame so // a tina4-js client reconnects on a schedule instead of erroring on a // socket that simply vanished. const wsClosed = wsRouteManager.closeAll(CLOSE_GOING_AWAY, "server shutting down") + devReloadWs.closeAll(CLOSE_GOING_AWAY, "server shutting down"); if (wsClosed > 0) { Log.info(`Closed ${wsClosed} WebSocket connection(s) with 1001 going away`); } // Race the drain against the shutdown budget. Whatever is still in // flight when the budget expires gets force-closed: SIGKILL is what // arrives next, so a bounded drain is strictly better than an // unbounded one that the orchestrator truncates anyway. const budgetSeconds = shutdownTimeoutSeconds(); let timer: NodeJS.Timeout | undefined; const outcome = await Promise.race([ closeListeners().then(() => "drained" as const), new Promise<"timeout">((r) => { timer = setTimeout(() => r("timeout"), budgetSeconds * 1000); timer.unref(); }), ]); if (timer) clearTimeout(timer); if (outcome === "timeout") { Log.warning( `Shutdown timeout (${budgetSeconds}s) reached with requests still in flight - forcing close`, ); server.closeAllConnections(); aiServer?.closeAllConnections(); for (const sibling of siblingServers) sibling.closeAllConnections(); } try { const orm = await import("../../orm/src/index.js"); await orm.closeDatabase(); } catch { /* ORM never initialised - nothing to close */ } Log.info("Server stopped."); // Graceful shutdown owns the final call to reset() (Decision 24 / // LOG-I02): flush the shutdown record above, then clear the resolved // snapshot, exactly once. process.exit() below makes this moot for a // real process, but a test harness that drives gracefulShutdown() // without letting exit() actually kill the process (see // test/gracefulShutdown.test.ts) must not inherit a stale snapshot. Log.reset(); // Exit 0: this process was ASKED to stop and did so cleanly. 128+signum // is what waitpid reports for a process killed BY a signal, i.e. one // that did NOT handle it - it is a diagnosis, not a target. Gunicorn // and Puma both halt 0 on a handled TERM, and a container exiting 0 is // a clean termination rather than a signal-kill. process.exit(0); }; const onSigterm = (): void => { void gracefulShutdown("SIGTERM"); }; const onSigint = (): void => { void gracefulShutdown("SIGINT"); }; process.on("SIGTERM", onSigterm); process.on("SIGINT", onSigint); const loopWatchdog = startLoopWatchdog(); resolvePromise({ close: () => { loopWatchdog.stop(); // An explicit close() is not a signal shutdown: drop the handlers so // a test that starts many servers in one process does not pile up // listeners (and trip Node's MaxListeners warning). process.off("SIGTERM", onSigterm); process.off("SIGINT", onSigint); // Clear any registered background timers so graceful shutdown actually exits. stopAllBackgroundTasks(); if (aiServer) aiServer.close(); server.close(); // Sibling loopback listeners share the port — close them alongside the // primary (guarded: a sibling already gone must not throw out of close()). for (const sibling of siblingServers) { try { sibling.close(); } catch { /* already not running */ } } // Close database if ORM was initialized import("../../orm/src/index.js").then((orm) => orm.closeDatabase()).catch(() => {}); }, router, port, }); }); }); } async function initializeOrm( config: Tina4Config | undefined, router: Router, base: string, ormDir: string, modelsDir: string, ): Promise { // Initialize ORM if models directory exists (check src/orm/ first, then src/models/) const hasOrmDir = existsSync(ormDir); const hasModelsDir = existsSync(modelsDir); if (hasOrmDir || hasModelsDir) { try { const orm = await import("../../orm/src/index.js"); const dbConfig = config?.database ?? {}; await orm.initDatabase({ type: dbConfig.type ?? "sqlite", path: dbConfig.path ?? "./data/tina4.db", }); // Discover from src/orm/ (primary) and src/models/ (fallback), merge results let models = hasOrmDir ? await orm.discoverModels(ormDir) : []; if (hasModelsDir) { const extraModels = await orm.discoverModels(modelsDir); // Only add models not already discovered (src/orm/ takes priority) const existingTables = new Set(models.map((m: any) => m.definition.tableName)); for (const m of extraModels) { if (!existingTables.has(m.definition.tableName)) { models.push(m); } } } if (models.length > 0) { console.log(`\n Models discovered:`); await orm.syncModels(models); for (const { definition } of models) { console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`); } // Generate auto-CRUD routes ONLY for models that explicitly opted in via // `static autoCrud = true` (the documented opt-in gate; default false). The // server previously generated the 5 CRUD endpoints for every discovered model // regardless of the flag, contradicting the documented contract. (Python's // AutoCrud is opt-in too — via an explicit AutoCrud.register/discover call.) const crudModels = orm.crudEligibleModels(models); const crudRoutes = crudModels.length > 0 ? orm.generateCrudRoutes(crudModels) : []; for (const route of crudRoutes) { // Only add if no file-based route already handles this const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\[(\w+)\]/g, "test")); if (!existing) { router.addRoute(route); } } if (crudRoutes.length > 0) { console.log(`\n Auto-CRUD endpoints:`); for (const route of crudRoutes) { console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`); } } } } catch (err) { console.warn(`\n ORM not available (install @tina4/orm to enable):`, err); } // Auto-run pending migrations on startup — AFTER initDatabase()/model sync, // BEFORE the server listens. Non-breaking: a failure is logged and boot // continues (the helper never throws). Gated on a migrations/ dir + the // TINA4_AUTO_MIGRATE flag (default on) + a resolvable DB adapter. await autoMigrateOnStartup("migrations", base); } } async function configureSwagger(router: Router, ormDir: string, modelsDir: string): Promise { let enabled = false; // Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled // in debug mode, off in production). Loading the swagger module also // pulls in route discovery for the generator, so skip the import entirely // when disabled. try { const swagger = await import("../../swagger/src/index.js"); // Single source of truth for BOTH the gated routes and the bundled // public/swagger assets (which static serving would otherwise expose). enabled = swagger.swaggerEnabled(); if (!enabled) { // Skip the rest of the swagger block when disabled. Gate on the LOCAL // `enabled` just read from swaggerEnabled(): the module-level // `swaggerAssetsEnabled` is only assigned from this function's RETURN // (see the call site), so inside here it is still its boot-time false -- // reading it skipped the block even when swagger was enabled, so // /swagger/openapi.json 404'd on an enabled server. throw new Error("__swagger_disabled__"); } // Collect model definitions for schema generation let modelDefs: Array<{ tableName: string; fields: Record }> = []; try { const orm = await import("../../orm/src/index.js"); const allModelDirs = [ormDir, modelsDir].filter((d) => existsSync(d)); const seenTables = new Set(); for (const dir of allModelDirs) { const discovered = await orm.discoverModels(dir); for (const m of discovered) { if (!seenTables.has(m.definition.tableName)) { modelDefs.push(m.definition); seenTables.add(m.definition.tableName); } } } } catch { // ORM not available, swagger will work without model schemas } // Read router.getRoutes() LIVE on every call — never a captured snapshot // (SWAG-NODE-BOOT-SNAPSHOT, ADR-0004). This used to close over a // boot-time `allRoutes` array, so the spec regenerated per request but // over a FROZEN route list: a route registered after boot (hot-reload, or // DevAdmin.register running after this block — see the /__feedback // exclusion above) never appeared. Python/PHP/Ruby always read the live // route table inside their generate() call; this is that same shape. const getSpec = () => swagger.generate(router.getRoutes(), modelDefs as any); const swaggerRoutes = swagger.createSwaggerRoutes(getSpec); for (const route of swaggerRoutes) { router.addRoute(route); } } catch { // Swagger not available } return enabled; }