import type { ServerResponse } from "node:http"; import fs from "node:fs"; import nodePath from "node:path"; import { once } from "node:events"; import type { Tina4Response, CookieOptions } from "./types.js"; /** * Best-effort close of a streaming source on client disconnect or a mid-stream * error. Async generators expose `.return()`; some custom iterables expose * `.close()`/`.aclose()`. Any failure here is swallowed — cleanup is advisory. */ async function closeSource(source: AsyncIterable): Promise { const s = source as { return?: () => unknown; close?: () => unknown; aclose?: () => unknown; }; try { if (typeof s.return === "function") await s.return(); else if (typeof s.aclose === "function") await s.aclose(); else if (typeof s.close === "function") await s.close(); } catch { /* cleanup is best-effort */ } } /** Cache Frond instances by template directory to avoid repeated instantiation. */ const _frondCache = new Map>(); /** Default templates directory — set via setDefaultTemplatesDir(). */ let _defaultTemplatesDir: string | null = null; /** Global user Frond engine — set via setFrond(). */ let _globalFrond: InstanceType | null = null; /** Singleton framework Frond engine for built-in templates. */ let _frameworkFrond: InstanceType | null = null; /** * Set the default templates directory for render(). * Called by server.ts during startup. */ export function setDefaultTemplatesDir(dir: string): void { _defaultTemplatesDir = dir; } /** * Return the global Frond engine, creating a default if needed. */ export async function getFrond(): Promise> { if (_globalFrond) return _globalFrond; const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates"); let engine = _frondCache.get(dir); if (!engine) { const { Frond } = await import("../../frond/src/engine.js"); engine = new Frond(dir); _frondCache.set(dir, engine); } _globalFrond = engine; return engine; } /** * Return the singleton Frond engine for built-in framework templates. * Syncs custom filters/globals from the user engine. */ export async function getFrameworkFrond(): Promise | null> { const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates"); if (!_frameworkFrond && fs.existsSync(frameworkDir)) { try { const { Frond } = await import("../../frond/src/engine.js"); _frameworkFrond = new Frond(frameworkDir); } catch { return null; } } // Sync custom filters/globals from the user engine if (_frameworkFrond && _globalFrond) { if (typeof _globalFrond._filters === "object") { Object.assign(_frameworkFrond._filters ??= {}, _globalFrond._filters); } if (typeof _globalFrond._globals === "object") { Object.assign(_frameworkFrond._globals ??= {}, _globalFrond._globals); } } return _frameworkFrond; } /** * Register a pre-configured Frond engine for response.render(). */ export function setFrond(engine: InstanceType): void { _globalFrond = engine; } /** * Creates a callable response object. * * return response({ users: [] }); // Auto-JSON * return response({ ok: true }, HTTP_CREATED); // JSON with status * return response("

Hi

"); // Auto-HTML * return response("Not found", HTTP_NOT_FOUND); // Plain text * return response(data, HTTP_OK, APPLICATION_JSON); // Explicit * return response.json(data, 201); // Method * return response.redirect("/login"); // Special */ /** * Normalise domain objects into JSON-serialisable values so handlers can * `return response(model)` / `res.json(model)` without calling .toDict() by hand: * * return response(user); // ORM model -> object * return response(await User.all()); // model[] -> object[] * return response(await db.fetch(sql));// DatabaseResult -> object[] * * Duck-typed (no @tina4/orm import — avoids a package cycle): a callable * `toDict` marks a model; a `records` array plus a `toArray` method marks a * query result. Plain objects / arrays / scalars pass through unchanged. */ function toJsonable(data: unknown): unknown { if (data === null || typeof data !== "object" || Buffer.isBuffer(data)) { return data; } const obj = data as Record; // Query result (DatabaseResult-like): records array + toArray method. if (Array.isArray(obj.records) && typeof obj.toArray === "function") { return obj.records; } // ORM model: callable toDict(). if (typeof obj.toDict === "function") { return (obj.toDict as () => unknown)(); } // Collections: normalise each element (array of models -> array of objects). if (Array.isArray(data)) { return data.map((item) => toJsonable(item)); } return data; } export function createResponse(res: ServerResponse): Tina4Response { // ── Guard: prevent writing after headers are sent ── const safeEnd = (chunk?: string | Buffer, encoding?: BufferEncoding) => { if (res.headersSent) return; if (chunk === undefined) { res.end(); } else if (encoding === undefined) { res.end(chunk); } else { res.end(chunk, encoding); } }; const safeSetHeader = (name: string, value: string | number | readonly string[]) => { if (!res.headersSent) res.setHeader(name, value); }; // ── The callable: response(data, status, contentType) ── const response = function (data?: unknown, statusCode?: number, contentType?: string): Tina4Response { if (res.headersSent) return response; // Normalise ORM models / collections / query results so handlers can // `return response(model)` without serialising by hand. data = toJsonable(data); if (statusCode !== undefined) { res.statusCode = statusCode; } if (contentType) { // Explicit content type safeSetHeader("Content-Type", contentType); if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) { safeEnd(JSON.stringify(data)); } else { safeEnd(data == null ? "" : String(data)); } } else if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) { // dict/array → auto JSON safeSetHeader("Content-Type", "application/json"); safeEnd(JSON.stringify(data)); } else if (typeof data === "string") { const trimmed = data.trim(); if (trimmed.startsWith("<") && trimmed.endsWith(">")) { safeSetHeader("Content-Type", "text/html; charset=utf-8"); } else { safeSetHeader("Content-Type", "text/plain; charset=utf-8"); } safeEnd(data); } else if (Buffer.isBuffer(data)) { if (!res.getHeader("Content-Type")) { safeSetHeader("Content-Type", "application/octet-stream"); } safeEnd(data); } else if (data == null) { safeEnd(""); } else { safeSetHeader("Content-Type", "text/plain; charset=utf-8"); safeEnd(String(data)); } return response; } as Tina4Response; // ── Attach the underlying ServerResponse ── response.raw = res; // ── Explicit methods ── response.json = function (data: unknown, status?: number): Tina4Response { if (res.headersSent) return response; if (status !== undefined) res.statusCode = status; safeSetHeader("Content-Type", "application/json"); safeEnd(JSON.stringify(toJsonable(data))); return response; }; response.html = function (content: string, status?: number): Tina4Response { if (res.headersSent) return response; if (status !== undefined) res.statusCode = status; safeSetHeader("Content-Type", "text/html; charset=utf-8"); safeEnd(content); return response; }; response.text = function (content: string, status?: number): Tina4Response { if (res.headersSent) return response; if (status !== undefined) res.statusCode = status; safeSetHeader("Content-Type", "text/plain; charset=utf-8"); safeEnd(content); return response; }; response.xml = function (content: string, status?: number): Tina4Response { if (res.headersSent) return response; if (status !== undefined) res.statusCode = status; safeSetHeader("Content-Type", "application/xml; charset=utf-8"); safeEnd(content); return response; }; response.send = function (data: unknown, statusCode?: number, contentType?: string): Tina4Response { return response(data, statusCode, contentType); }; response.status = function (code: number): Tina4Response { if (!res.headersSent) res.statusCode = code; return response; }; response.header = function (name: string, value: string | number | readonly string[]): Tina4Response { safeSetHeader(name, value); return response; }; /** * Add a single response header (primary method — parity with Python/PHP/Ruby). */ response.addHeader = function (name: string, value: string): void { safeSetHeader(name, value); }; response.redirect = function (url: string, code?: number): Tina4Response { if (res.headersSent) return response; res.statusCode = code ?? 302; safeSetHeader("Location", url); safeEnd(); return response; }; response.cookie = function (name: string, value: string, options?: CookieOptions): Tina4Response { const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; if (options?.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`); if (options?.path) parts.push(`Path=${options.path}`); if (options?.domain) parts.push(`Domain=${options.domain}`); if (options?.secure) parts.push("Secure"); if (options?.httpOnly) parts.push("HttpOnly"); if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`); const existing = res.getHeader("Set-Cookie"); const cookies: string[] = []; if (Array.isArray(existing)) cookies.push(...(existing as string[])); else if (typeof existing === "string") cookies.push(existing); cookies.push(parts.join("; ")); safeSetHeader("Set-Cookie", cookies); return response; }; response.clearCookie = function (name: string, options?: CookieOptions): Tina4Response { return response.cookie(name, "", { ...options, maxAge: 0, expires: new Date(0) }); }; response.error = function (code: string, message: string, status?: number): Tina4Response { const statusCode = status ?? 400; return response.json({ error: true, code, message, status: statusCode }, statusCode); }; response.file = function ( filePath: string, options?: { download?: boolean; contentType?: string; root?: string }, ): Tina4Response { if (res.headersSent) return response; // SECURITY: confine the read. The natural spelling of a download route, // // response.file("downloads/" + name) // name = "../secret.env" // // served any file the process could read - measured over real HTTP at 200 // with the contents of a .env one directory above the intended one. // // TWO checks. Containment ALONE does not close it: that payload resolves to // /secret.env, which IS inside the project root, and the project // root is exactly where .env lives. Rejecting ".." on the way in is the // check that closes it; containment then catches absolute paths and // symlinks, neither of which carries a ".." segment. Same shape as // static.ts's startsWith guard, which this function never had. // Containment ONLY when a root is declared. Defaulting to cwd broke every // legitimate absolute path. const base = options?.root ? nodePath.resolve(options.root) : null; let forbidden = filePath.split(/[\\/]/).includes(".."); if (!forbidden) { const candidate = (base === null || nodePath.isAbsolute(filePath)) ? filePath : nodePath.join(base, filePath); let resolved = candidate; try { resolved = fs.realpathSync(candidate); } catch { /* missing file: fall through to the 404 below with the joined path */ } if (base !== null && base !== nodePath.sep && resolved !== base && !resolved.startsWith(base + nodePath.sep)) { forbidden = true; } else { filePath = resolved; } } if (forbidden) { // Refuse BEFORE reading: never load bytes we will not send. res.statusCode = 403; safeSetHeader("Content-Type", "text/plain"); safeEnd("Forbidden"); return response; } if (!fs.existsSync(filePath)) { res.statusCode = 404; safeSetHeader("Content-Type", "text/plain"); safeEnd("File not found"); return response; } const content = fs.readFileSync(filePath); const ext = nodePath.extname(filePath).toLowerCase(); const mimeTypes: Record = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".zip": "application/zip", ".csv": "text/csv", ".xml": "application/xml", ".webp": "image/webp", ".ico": "image/x-icon", ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".txt": "text/plain", ".mp4": "video/mp4", ".mp3": "audio/mpeg", }; safeSetHeader("Content-Type", options?.contentType || mimeTypes[ext] || "application/octet-stream"); safeSetHeader("Content-Length", content.length); if (options?.download) { safeSetHeader("Content-Disposition", `attachment; filename="${nodePath.basename(filePath)}"`); } safeEnd(content); return response; }; // ── Template rendering via Frond ── response.render = async function ( templateName: string, data?: Record, status?: number, templateDir?: string, ): Promise { try { const { Frond } = await import("../../frond/src/engine.js"); const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates"); let engine = _frondCache.get(dir); if (!engine) { engine = new Frond(dir); _frondCache.set(dir, engine); } const html = engine.render(templateName, data ?? {}); if (res.headersSent) return response; if (status !== undefined) res.statusCode = status; else res.statusCode = 200; safeSetHeader("Content-Type", "text/html; charset=utf-8"); safeEnd(html); return response; } catch (err) { res.statusCode = 500; response.json({ error: "Template engine error", statusCode: 500, message: err instanceof Error ? err.message : "Frond template engine is not available. Ensure @tina4/frond is installed.", }); return response; } }; /** * Stream response from an async generator for Server-Sent Events (SSE). * * Usage: * export default async function (req, res) { * res.stream(async function* () { * for (let i = 0; i < 10; i++) { * yield `data: message ${i}\n\n`; * await new Promise(r => setTimeout(r, 1000)); * } * }()); * } */ (response as any).stream = async function ( source: AsyncIterable, contentType: string = "text/event-stream", ): Promise { if (res.headersSent) return response; res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }); // True once the client has gone away (socket destroyed) or the response // has been finished — keep checking so we bail cleanly mid-stream rather // than writing into a dead socket or buffering forever. const streamClosed = (): boolean => res.writableEnded || (res.socket?.destroyed ?? false); // Keep-alive heartbeat: periodically write a ':' SSE comment line on a // long-lived stream so proxies/load-balancers don't reap an idle but // healthy connection. Opt-out via TINA4_SSE_HEARTBEAT=0 (any non-positive // value disables it). The interval is unref'd so it never holds the // process open on its own. const heartbeatSeconds = parseFloat(process.env.TINA4_SSE_HEARTBEAT ?? "15"); let heartbeat: ReturnType | null = null; if (Number.isFinite(heartbeatSeconds) && heartbeatSeconds > 0) { heartbeat = setInterval(() => { if (streamClosed()) return; try { res.write(": keep-alive\n\n"); } catch { /* write race with a closing socket — the loop's guard handles it */ } }, heartbeatSeconds * 1000); heartbeat.unref?.(); } const stopHeartbeat = (): void => { if (heartbeat !== null) { clearInterval(heartbeat); heartbeat = null; } }; try { for await (const chunk of source) { // Client disconnected mid-stream — stop cleanly. Closing the source // (best-effort) lets the producer release resources. if (streamClosed()) { await closeSource(source); break; } const data = typeof chunk === "string" ? chunk : chunk.toString(); const ok = res.write(data); // Slow-client backpressure: when write() returns false the kernel // buffer is full. Wait for it to drain before pulling the next chunk // so we don't unboundedly buffer ahead of a client that can't keep up. if (!ok && !streamClosed()) { await once(res, "drain").catch(() => { /* socket errored/closed while waiting — loop guard handles it */ }); } } } catch (err) { // The generator/source itself raised mid-stream. Log and stop cleanly — // end the stream rather than crashing the request handler/worker. const { Log } = await import("./logger.js"); Log.error(`SSE/stream source error: ${err instanceof Error ? err.message : String(err)}`); await closeSource(source); } finally { stopHeartbeat(); } if (!res.writableEnded) res.end(); return response; }; return response; } /** * Build a standard error response envelope (standalone helper). * * Usage: * return response(errorResponse("VALIDATION_FAILED", "Email is required", 400), 400); */ export function errorResponse(code: string, message: string, status: number = 400): Record { return { error: true, code, message, status }; } /** * Content negotiation for an error response (feature 42, ERR-DEC-02): does an * Accept header prefer application/json over text/html? * * `Accept: application/json` (an API client) prefers JSON; a browser Accept * (`text/html`, `*\/*`, or no header at all) prefers HTML. A mixed Accept * header - a real browser's * `text/html,application/xhtml+xml,application/xml;q=0.9,*\/*;q=0.8` - is * resolved by q-value: whichever of the two media types this function cares * about is weighted higher wins; a tie or neither present defaults to HTML, * the historical/back-compatible behaviour for an unspecified client. This is * the ONE shared decision reused by the 403/404/500 error paths (server.ts's * serveNotFound/renderDispatchError, middleware.ts's interpretHookResult), so * a JSON API client sees the SAME negotiated shape everywhere * (ERR-403-SPLIT) - ported with the same algorithm to Python/PHP/Ruby. */ export function acceptPrefersJson(accept: string | undefined | null): boolean { if (!accept) return false; let bestJson = -1; let bestHtml = -1; for (const part of accept.split(",")) { const segments = part.trim().split(";"); const media = (segments[0] ?? "").trim().toLowerCase(); let q = 1; for (const rawParam of segments.slice(1)) { const param = rawParam.trim(); if (param.startsWith("q=")) { const parsed = Number(param.slice(2)); if (!Number.isNaN(parsed)) q = parsed; } } if (media === "application/json") { bestJson = Math.max(bestJson, q); } else if (media === "text/html" || media === "*/*" || media === "application/xhtml+xml") { bestHtml = Math.max(bestHtml, q); } } if (bestJson < 0) return false; if (bestHtml < 0) return true; return bestJson > bestHtml; } /** True when the request's Accept header prefers JSON - see acceptPrefersJson(). */ export function wantsJson(req: { headers?: Record }): boolean { const accept = req?.headers?.["accept"]; return acceptPrefersJson(Array.isArray(accept) ? accept[0] : accept); } const ERROR_CODE_NAMES: Record = { 403: "FORBIDDEN", 404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED", 500: "INTERNAL_SERVER_ERROR", }; /** * The ONE JSON error envelope for a negotiated 403/404/500 (ERR-DEC-02). * * Reuses errorResponse() (error: true, code, message, status) already shared * by app-level response.error() calls, plus request_id for correlation * (feature 43, ERR-404-REQUESTID) - the SAME shape Python/PHP/Ruby build. */ export function negotiatedErrorBody(code: number, message: string, requestId: string): Record { const body = errorResponse(ERROR_CODE_NAMES[code] ?? `HTTP_${code}`, message, code); body.request_id = requestId; return body; }