345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { ServerResponse, createServer, type IncomingMessage } from "node:http";
|
|
import { createReadStream } from "node:fs";
|
|
import { stat } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import type { ServerBuild } from "react-router";
|
|
import { loadRequiredWebConfig } from "@toolctrl/config";
|
|
import { createChildLogger, defaultLogger } from "@toolctrl/logger";
|
|
import { app as apiApp } from "../apps/api/src/app";
|
|
import { getHostRoutingRuntimeDecision } from "./host-routing-runtime";
|
|
|
|
process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
|
|
|
|
const RUNTIME_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT_DIRECTORY = RUNTIME_DIRECTORY.endsWith(path.join("build", "server-runtime"))
|
|
? path.resolve(RUNTIME_DIRECTORY, "..", "..")
|
|
: path.resolve(RUNTIME_DIRECTORY, "..");
|
|
const WEB_BUILD_DIRECTORY = process.env.WEB_BUILD_DIRECTORY
|
|
? path.resolve(process.env.WEB_BUILD_DIRECTORY)
|
|
: path.join(REPO_ROOT_DIRECTORY, "apps", "web", "build");
|
|
const BUILD_PATH = path.join(WEB_BUILD_DIRECTORY, "server", "index.js");
|
|
const CLIENT_BUILD_DIRECTORY = path.join(WEB_BUILD_DIRECTORY, "client");
|
|
const ALLOWED_STATIC_ASSET_EXTENSIONS = new Set([
|
|
".avif",
|
|
".css",
|
|
".eot",
|
|
".gif",
|
|
".ico",
|
|
".jpeg",
|
|
".jpg",
|
|
".js",
|
|
".json",
|
|
".mjs",
|
|
".png",
|
|
".svg",
|
|
".ttf",
|
|
".txt",
|
|
".webmanifest",
|
|
".webp",
|
|
".woff",
|
|
".woff2",
|
|
".xml",
|
|
]);
|
|
const webConfig = loadRequiredWebConfig();
|
|
const PORT = webConfig.listen.port;
|
|
const HOST = webConfig.listen.host;
|
|
const serverLogger = createChildLogger(defaultLogger, { component: "server" });
|
|
|
|
const [{ createRequestHandler }, { createReadableStreamFromReadable, writeReadableStreamToWritable }] = await Promise.all([
|
|
import("react-router"),
|
|
import("@react-router/node"),
|
|
]);
|
|
const build = (await import(pathToFileURL(BUILD_PATH).href)) as ServerBuild;
|
|
const handleReactRouterRequest = createRequestHandler(build, process.env.NODE_ENV);
|
|
|
|
type NodeRequestInit = RequestInit & { duplex?: "half" };
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
if (await serveStaticAsset(req, res)) {
|
|
return;
|
|
}
|
|
|
|
const request = createWebRequest(req, res);
|
|
const routingDecision = getHostRoutingRuntimeDecision(request);
|
|
if (routingDecision.type === "redirect") {
|
|
await sendWebResponse(
|
|
res,
|
|
new Response(null, {
|
|
status: routingDecision.status,
|
|
headers: { Location: routingDecision.location },
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (routingDecision.type === "reject") {
|
|
await sendWebResponse(res, new Response("Not Found", { status: routingDecision.status }));
|
|
return;
|
|
}
|
|
|
|
if (routingDecision.target === "web" && isWebOperationalRoute(request)) {
|
|
await sendWebResponse(res, await handleReactRouterRequest(request));
|
|
return;
|
|
}
|
|
|
|
const response = routingDecision.target === "api" ? await apiApp.fetch(routingDecision.request) : await handleReactRouterRequest(routingDecision.request);
|
|
await sendWebResponse(res, response);
|
|
} catch (error) {
|
|
serverLogger.error({ error }, "server request failed");
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
}
|
|
res.end("Internal Server Error");
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
const address = HOST ?? getLocalAddress() ?? "localhost";
|
|
serverLogger.info({ host: address, port: PORT }, "server listening");
|
|
});
|
|
|
|
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
|
process.once(signal, () => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
serverLogger.error({ error }, "server shutdown failed");
|
|
process.exitCode = 1;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function serveStaticAsset(req: IncomingMessage, res: ServerResponse) {
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
return false;
|
|
}
|
|
|
|
const requestUrl = new URL(req.url ?? "/", "http://internal");
|
|
const filePath = getClientFilePath(requestUrl.pathname);
|
|
if (!filePath) {
|
|
return false;
|
|
}
|
|
|
|
const fileStat = await stat(filePath).catch(() => null);
|
|
if (!fileStat?.isFile()) {
|
|
return false;
|
|
}
|
|
|
|
const headers = getStaticHeaders(filePath, requestUrl.pathname);
|
|
res.writeHead(200, headers);
|
|
|
|
if (req.method === "HEAD") {
|
|
res.end();
|
|
return true;
|
|
}
|
|
|
|
createReadStream(filePath)
|
|
.on("error", (error) => {
|
|
serverLogger.error({ error }, "static asset stream failed");
|
|
if (!res.headersSent) {
|
|
res.writeHead(500);
|
|
}
|
|
res.end();
|
|
})
|
|
.pipe(res);
|
|
|
|
return true;
|
|
}
|
|
|
|
function getClientFilePath(pathname: string) {
|
|
let decodedPathname: string;
|
|
try {
|
|
decodedPathname = decodeURIComponent(pathname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const relativePath = decodedPathname.replace(/^\/+/, "");
|
|
if (!relativePath) {
|
|
return null;
|
|
}
|
|
if (!ALLOWED_STATIC_ASSET_EXTENSIONS.has(path.extname(relativePath).toLowerCase())) {
|
|
return null;
|
|
}
|
|
|
|
const filePath = path.resolve(CLIENT_BUILD_DIRECTORY, relativePath);
|
|
if (filePath !== CLIENT_BUILD_DIRECTORY && !filePath.startsWith(`${CLIENT_BUILD_DIRECTORY}${path.sep}`)) {
|
|
return null;
|
|
}
|
|
|
|
return filePath;
|
|
}
|
|
|
|
function createWebRequest(req: IncomingMessage, res: ServerResponse) {
|
|
const protocol = req.headers["x-forwarded-proto"]?.toString().split(",")[0]?.trim() || "http";
|
|
const host = req.headers.host || "localhost";
|
|
const url = new URL(req.url ?? "/", `${protocol}://${host}`);
|
|
const controller = new AbortController();
|
|
|
|
res.on("close", () => {
|
|
controller.abort();
|
|
});
|
|
|
|
const init: NodeRequestInit = {
|
|
method: req.method,
|
|
headers: createWebHeaders(req.headers),
|
|
signal: controller.signal,
|
|
};
|
|
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
init.body = createReadableStreamFromReadable(req);
|
|
init.duplex = "half";
|
|
}
|
|
|
|
return new Request(url, init);
|
|
}
|
|
|
|
function isWebOperationalRoute(request: Request) {
|
|
const { pathname } = new URL(request.url);
|
|
return pathname === "/healthz" || pathname === "/readyz" || pathname === "/healthcheck";
|
|
}
|
|
|
|
function createWebHeaders(headers: IncomingMessage["headers"]) {
|
|
const webHeaders = new Headers();
|
|
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
webHeaders.append(key, item);
|
|
}
|
|
} else if (value !== undefined) {
|
|
webHeaders.set(key, value);
|
|
}
|
|
}
|
|
|
|
return webHeaders;
|
|
}
|
|
|
|
async function sendWebResponse(res: ServerResponse, response: Response) {
|
|
res.statusCode = response.status;
|
|
res.statusMessage = response.statusText;
|
|
|
|
for (const [key, value] of response.headers) {
|
|
res.appendHeader(key, value);
|
|
}
|
|
appendWebSecurityHeaders(res);
|
|
|
|
if (!response.body) {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
await writeReadableStreamToWritable(response.body, res);
|
|
}
|
|
|
|
function appendWebSecurityHeaders(headers: Headers): Headers;
|
|
function appendWebSecurityHeaders(headers: Record<string, string>): Record<string, string>;
|
|
function appendWebSecurityHeaders(headers: ServerResponse): ServerResponse;
|
|
function appendWebSecurityHeaders(headers: Headers | Record<string, string> | ServerResponse) {
|
|
const securityHeaders: Record<string, string> = {
|
|
"Content-Security-Policy": "base-uri 'self'; object-src 'none'; frame-ancestors 'self'",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"X-Frame-Options": "SAMEORIGIN",
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
|
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
"Cross-Origin-Resource-Policy": "same-origin",
|
|
"Cross-Origin-Embedder-Policy": "unsafe-none",
|
|
};
|
|
|
|
if (webConfig.routing.protocol === "https") {
|
|
securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(securityHeaders)) {
|
|
if (key === "Content-Security-Policy" && hasHeader(headers, key)) {
|
|
continue;
|
|
}
|
|
if (headers instanceof Headers) {
|
|
headers.set(key, value);
|
|
} else if (headers instanceof ServerResponse) {
|
|
headers.setHeader(key, value);
|
|
} else {
|
|
headers[key] = value;
|
|
}
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
function hasHeader(headers: Headers | Record<string, string> | ServerResponse, key: string) {
|
|
if (headers instanceof Headers) {
|
|
return headers.has(key);
|
|
}
|
|
if (headers instanceof ServerResponse) {
|
|
return headers.hasHeader(key);
|
|
}
|
|
const normalizedKey = key.toLowerCase();
|
|
return Object.keys(headers).some((candidate) => candidate.toLowerCase() === normalizedKey);
|
|
}
|
|
|
|
function getStaticHeaders(filePath: string, pathname: string) {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": getContentType(filePath),
|
|
};
|
|
|
|
// Cache-Control strategy aligned with docs/ops/asset-cdn.md:
|
|
// * /assets/* → Vite-hashed, safe to cache forever (1 year, immutable)
|
|
// * /fonts/* → static brand assets, 1 day
|
|
// * /images/* → static brand assets, 1 day
|
|
// Anything else (HTML / loader responses / favicons) keeps the default
|
|
// (no Cache-Control here — the loader / entry.server is in charge).
|
|
if (pathname === "/assets" || pathname.startsWith("/assets/")) {
|
|
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
|
} else if (pathname === "/fonts" || pathname.startsWith("/fonts/") || pathname === "/images" || pathname.startsWith("/images/")) {
|
|
headers["Cache-Control"] = "public, max-age=86400";
|
|
}
|
|
|
|
return appendWebSecurityHeaders(headers);
|
|
}
|
|
|
|
function getContentType(filePath: string) {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case ".css":
|
|
return "text/css; charset=utf-8";
|
|
case ".js":
|
|
case ".mjs":
|
|
return "text/javascript; charset=utf-8";
|
|
case ".json":
|
|
case ".webmanifest":
|
|
return "application/json; charset=utf-8";
|
|
case ".svg":
|
|
return "image/svg+xml";
|
|
case ".png":
|
|
return "image/png";
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
return "image/jpeg";
|
|
case ".gif":
|
|
return "image/gif";
|
|
case ".ico":
|
|
return "image/x-icon";
|
|
case ".txt":
|
|
return "text/plain; charset=utf-8";
|
|
case ".html":
|
|
return "text/html; charset=utf-8";
|
|
case ".woff":
|
|
return "font/woff";
|
|
case ".woff2":
|
|
return "font/woff2";
|
|
case ".ttf":
|
|
return "font/ttf";
|
|
default:
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function getLocalAddress() {
|
|
return Object.values(os.networkInterfaces())
|
|
.flat()
|
|
.find((entry) => entry && String(entry.family).includes("4") && !entry.internal)?.address;
|
|
}
|