Files
plainpages/src/static.ts
lilleman a9e3dedbb4 §9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, inheriting level/format/streams/OTLP) into a "request" span, adopting an inbound W3C traceparent so a request continues an upstream proxy's distributed trace (malformed ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). Config (environment-agnostic, fail-loud): LOG_LEVEL (info), LOG_FORMAT (text; prod compose → json), OTLP_ENDPOINT (unset ⇒ console-only; set ⇒ export logs + spans to an OTel Collector), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00

86 lines
3.5 KiB
TypeScript

import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import type { ServerResponse } from "node:http";
import { extname, isAbsolute, join, relative } from "node:path";
const contentTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".webp": "image/webp",
".woff2": "font/woff2",
};
export function contentTypeFor(filePath: string): string {
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
}
// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
if (/[\x00-\x1f]/.test(requestedPath)) return null;
const filePath = join(dir, requestedPath);
const rel = relative(dir, filePath);
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
}
export interface StaticRoute {
dir: string;
subPath: string;
}
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
// and traversal-guards the sub-path as before.
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
const slash = restPath.indexOf("/");
const first = slash === -1 ? restPath : restPath.slice(0, slash);
if (pluginIds.has(first)) {
return { dir: join(pluginsDir, first, "public"), subPath: slash === -1 ? "" : restPath.slice(slash + 1) };
}
return { dir: publicDir, subPath: restPath };
}
function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
}
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
// static.ts stays standalone, while app.ts passes the request logger for structured output (§9).
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
let decoded: string;
try {
decoded = decodeURIComponent(requestedPath);
} catch {
return plain(res, 400, "Bad Request");
}
const filePath = resolveStaticPath(dir, decoded);
if (filePath === null) return plain(res, 403, "Forbidden");
try {
const info = await stat(filePath);
if (!info.isFile()) return plain(res, 404, "Not Found");
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
if (head) return void res.end(); // headers only — skip opening the file
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
// log and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath)
.on("error", (err) => {
onError(err);
res.destroy();
})
.pipe(res);
} catch {
plain(res, 404, "Not Found");
}
}