Files
HiveGrid-Wireframes/hivegrid-cloud/server/server.ts
T

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;
}