Files
headplane/runtime/http.ts
Aarnav Tale 858ba114bd fix(runtime): rewrite origins behind TLS terminated proxies
React Router 8.4 compares the full origin in its CSRF check so every
action would fail when the browser sent HTTPS origins to HTTP behind a
reverse proxy.

Closes #657.
2026-10-16 10:12:23 -07:00

286 lines
8.3 KiB
TypeScript

// MARK: HTTP Runtime
//
// Bare Node http(s) runtime that hosts a `RequestListener` (typically the
// React Router request listener from `@react-router/node`) and serves
// static assets out of a directory.
import { createReadStream } from "node:fs";
import { stat, writeFile } from "node:fs/promises";
import {
type IncomingMessage,
type RequestListener,
type Server,
createServer as createHttpServer,
} from "node:http";
import type { ServerResponse } from "node:http";
import {
type ServerOptions as HttpsServerOptions,
createServer as createHttpsServer,
} from "node:https";
import { extname, normalize, resolve, sep } from "node:path";
import mime from "mime";
import pino from "pino";
export interface Logger {
info: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
const runtimeDestination = pino.destination({ dest: 1, sync: false });
process.on("exit", () => runtimeDestination.flushSync());
const runtimePino = pino(
{
base: { service: "headplane" },
messageKey: "message",
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
formatters: {
level: (label) => ({ level: label }),
},
},
runtimeDestination,
);
const defaultLogger: Logger = {
info: (msg, ...args) => runtimePino.info({ component: "runtime" }, msg, ...args),
error: (msg, ...args) => runtimePino.error({ component: "runtime" }, msg, ...args),
};
export interface StaticOptions {
root: string;
basename: string;
assetsDir: string;
immutableAssets: boolean;
}
/**
* Returns a static-file middleware. The callback resolves to `true` when
* the request was served, `false` when the caller should fall through to
* the next handler (e.g. the React Router request listener).
*/
function createStaticHandler(opts: StaticOptions) {
const root = resolve(opts.root);
const prefix = opts.basename.endsWith("/") ? opts.basename : `${opts.basename}/`;
const assetsPrefix = `${prefix}${opts.assetsDir}/`;
return async function serveStatic(req: IncomingMessage, res: ServerResponse) {
if (req.method !== "GET" && req.method !== "HEAD") return false;
if (!req.url) return false;
let pathname: string;
try {
pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
} catch {
return false;
}
if (!pathname.startsWith(prefix)) return false;
const rel = pathname.slice(prefix.length);
if (!rel || rel.endsWith("/")) return false;
// Resolve and confine to root to prevent path traversal.
const file = resolve(root, normalize(rel));
if (file !== root && !file.startsWith(root + sep)) return false;
let st;
try {
st = await stat(file);
} catch {
return false;
}
if (!st.isFile()) return false;
const isAsset = pathname.startsWith(assetsPrefix);
// Dev serves unhashed files that are rewritten in place, so a rebuild is
// invisible to anything holding a copy. Revalidate instead of pinning.
let cacheControl = "no-cache";
if (opts.immutableAssets) {
cacheControl = isAsset ? "public, max-age=31536000, immutable" : "public, max-age=3600";
}
res.setHeader("Cache-Control", cacheControl);
res.setHeader("Last-Modified", st.mtime.toUTCString());
// HTTP dates carry one-second resolution, so floor mtime before comparing.
const modifiedSince = Date.parse(req.headers["if-modified-since"] ?? "");
if (!Number.isNaN(modifiedSince) && Math.floor(st.mtimeMs / 1000) * 1000 <= modifiedSince) {
res.statusCode = 304;
res.end();
return true;
}
const mimeType = mime.getType(extname(file)) ?? "application/octet-stream";
res.setHeader("Content-Type", mimeType);
res.setHeader("Content-Length", String(st.size));
res.statusCode = 200;
if (req.method === "HEAD") {
res.end();
return true;
}
await new Promise<void>((resolve_, reject) => {
const stream = createReadStream(file);
stream.on("error", reject);
stream.on("end", () => resolve_());
stream.pipe(res);
});
return true;
};
}
// React router tightened the CSRF check to compare full origins in a PR so now
// we need to rewrite the origin header to match the scheme of the request.
//
// PR: https://github.com/remix-run/react-router/pull/15419
export function alignOriginScheme(req: IncomingMessage) {
const { origin, host } = req.headers;
if (!origin || !host || origin === "null") {
return;
}
const originUrl = URL.parse(origin);
if (!originUrl || originUrl.host !== host.toLowerCase()) {
return;
}
const scheme = "encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:";
if (originUrl.protocol === scheme) {
return;
}
req.headers.origin = `${scheme}//${originUrl.host}`;
}
export interface ListenerOptions {
basename: string;
staticRoot?: string;
assetsDir?: string;
immutableAssets?: boolean;
requestListener: RequestListener;
logger?: Logger;
}
/**
* Composes the full Node `RequestListener` chain:
* 1. `${basename}` → 302 to `${basename}/`
* 2. Static asset serving (optional)
* 3. Delegate to the downstream request listener
*/
export function composeListener(opts: ListenerOptions): RequestListener {
const log = opts.logger ?? defaultLogger;
const basename = opts.basename;
const serveStatic = opts.staticRoot
? createStaticHandler({
root: opts.staticRoot,
basename,
assetsDir: opts.assetsDir ?? "assets",
immutableAssets: opts.immutableAssets ?? true,
})
: null;
return (req, res) => {
if (req.url) {
try {
const url = new URL(req.url, "http://localhost");
if (url.pathname === basename) {
res.statusCode = 302;
res.setHeader("Location", `${basename}/${url.search}`);
res.end();
return;
}
} catch {}
}
alignOriginScheme(req);
if (!serveStatic) {
opts.requestListener(req, res);
return;
}
serveStatic(req, res)
.then((handled) => {
if (!handled) opts.requestListener(req, res);
})
.catch((err) => {
log.error("Static handler failed: %s", err);
if (!res.headersSent) {
res.statusCode = 500;
res.end("Internal Server Error");
} else {
res.destroy(err as Error);
}
});
};
}
export interface StartOptions {
host: string;
port: number;
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
/**
* If set, writes `url` to `path` once the server is accepting
* connections. Used by the bundled Docker healthcheck binary to
* discover the right scheme, port, and basename without any
* duplicate configuration. The caller assembles the URL so that
* the runtime stays generic.
*/
listenFile?: { path: string; url: string };
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
* exits. Use this to dispose long-lived resources (timers,
* subprocesses, DB handles).
*/
onShutdown?: () => Promise<void> | void;
}
/**
* Creates and starts an http(s) server. Wires up SIGINT/SIGTERM for
* graceful shutdown.
*/
export function startHttpServer(opts: StartOptions): Server {
const log = opts.logger ?? defaultLogger;
const server = opts.tls
? createHttpsServer(opts.tls, opts.listener)
: createHttpServer(opts.listener);
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
if (opts.listenFile) {
const { path, url } = opts.listenFile;
writeFile(path, `${url}\n`, "utf8").catch((err) => {
log.error("Failed to write listen file %s: %s", path, err);
});
}
});
const shutdown = async (signal: string) => {
log.info("Received %s, shutting down...", signal);
server.close(async () => {
if (opts.onShutdown) {
try {
await opts.onShutdown();
} catch (err) {
log.error("Error during shutdown hook: %o", err);
}
}
process.exit(0);
});
// Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref();
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
return server;
}