import { ServerResponse, createServer, type IncomingMessage } from "node:http"; import { createReadStream } from "node:fs"; 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 "@hivegrid/config"; import { assertRlsRuntimeReady } from "@hivegrid/db/rls-runtime-readiness.server"; import { assertPortalRlsRuntimeReady } from "@hivegrid/db/portal-rls-runtime-readiness.server"; import { resolvePortalRuntimeCustomHost, resolvePortalRuntimeCustomHostHealth, resolvePortalRuntimeTenant, } from "@hivegrid/db/portal-runtime-tenant.server"; import { createChildLogger, defaultLogger } from "@hivegrid/logger"; import { finalizePortalHostRoutingDecision, finalizePortalTenantRoutingDecision, getHostRoutingRuntimeDecision, } from "./host-routing-runtime"; import { createStaticEtag, getStaticAssetMetadata, getStaticCacheControl, hasCompressedRepresentation, isStaticAssetNotModified, selectStaticRepresentation, type StaticAssetMetadata, type StaticAssetRepresentation, } from "./static-assets"; import { createLimitedRequestBody, isRequestBodyTooLargeError, parseRequestContentLength, resolveWebRequestBodyLimit } from "./request-body"; import { validateMutationOrigin } from "./request-origin"; import { overwriteRequestCspNonce } from "./csp"; import { prepareReactRouterManifestRequest } from "./react-router-manifest-dispatch"; import { getPortalCapabilityResponseSecurityHeaders } from "../apps/web/app/services/customer-portal/portal-response-security"; import { getWebBuildDirectory, getWebServiceName, requireWebBuildSurface } from "../apps/web/build-surface"; import { CSP_NONCE_REQUEST_HEADER, SERVICE_WORKER_CONTENT_SECURITY_POLICY, STATIC_ASSET_CONTENT_SECURITY_POLICY, buildDocumentContentSecurityPolicy, contentSecurityPolicyAllowsExternalFraming, getRequestCspNonce, type DocumentContentSecurityPolicyOptions, } from "../apps/web/app/utils/security/contentSecurityPolicy"; import { getRequestDocumentContentSecurityPolicyOptions } from "../apps/web/app/utils/security/requestContentSecurityPolicy.server"; 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 webConfig = loadRequiredWebConfig(); const runtimeBuildSurface = requireWebBuildSurface(); if (runtimeBuildSurface !== webConfig.runtimeSurface) { throw new Error("HIVEGRID_RUNTIME_SURFACE must match WEB_RUNTIME_SURFACE"); } const WEB_BUILD_DIRECTORY = path.join(REPO_ROOT_DIRECTORY, "apps", "web", getWebBuildDirectory(runtimeBuildSurface)); 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 PORT = webConfig.listen.port; const HOST = webConfig.listen.host; const serverLogger = createChildLogger(defaultLogger, { component: "server", service: getWebServiceName(webConfig.runtimeSurface), surface: webConfig.runtimeSurface, }); const browserBuildEnv = { VITE_SENTRY_DSN_WEB: process.env.VITE_SENTRY_DSN_WEB, VITE_OTEL_EXPORTER_OTLP_ENDPOINT: process.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT, VITE_HOCUSPOCUS_URL: process.env.VITE_HOCUSPOCUS_URL, }; const rlsReadiness = webConfig.runtimeSurface === "portal" ? await assertPortalRlsRuntimeReady() : await assertRlsRuntimeReady(); if (!rlsReadiness.ready) { serverLogger.warn({ diagnostics: rlsReadiness.diagnostics, surface: webConfig.runtimeSurface }, "RLS runtime is running in audit mode with failed checks"); } 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 reactRouterRequestHandler = createRequestHandler(build, process.env.NODE_ENV); type RequestI18nScopeRunner = (request: Request, callback: () => Promise) => Promise; const runWithRequestI18nScope = ( build.entry.module as ServerBuild["entry"]["module"] & { runWithRequestI18nScope?: RequestI18nScopeRunner; } ).runWithRequestI18nScope; if (typeof runWithRequestI18nScope !== "function") { throw new Error("Web server build is missing the request-scoped i18n entry wrapper"); } const handleReactRouterRequest = (request: Request) => runWithRequestI18nScope(request, () => reactRouterRequestHandler(request)); type NodeRequestInit = RequestInit & { duplex?: "half" }; const server = createServer(async (req, res) => { try { const requestBodyLimit = resolveWebRequestBodyLimit(req.url, webConfig.runtimeSurface); const declaredBodyLength = getDeclaredBodyLength(req); if (declaredBodyLength !== null && declaredBodyLength > requestBodyLimit) { sendPayloadTooLarge(res); return; } const request = createWebRequest(req, res, requestBodyLimit); let routingDecision = getHostRoutingRuntimeDecision(request, { runtimeSurface: webConfig.runtimeSurface, routing: webConfig.routing, }); if (routingDecision.type === "resolve-portal-tenant") { const tenant = await resolvePortalRuntimeTenant(routingDecision.tenantSlug); routingDecision = finalizePortalTenantRoutingDecision(routingDecision, tenant?.tenantSlug ?? null); } if (routingDecision.type === "resolve-portal-host") { const host = new URL(routingDecision.request.url).pathname === "/healthz" ? await resolvePortalRuntimeCustomHostHealth(routingDecision.hostname) : await resolvePortalRuntimeCustomHost(routingDecision.hostname); routingDecision = finalizePortalHostRoutingDecision(routingDecision, host); } if (routingDecision.type === "redirect") { await sendWebResponse( res, new Response(null, { status: routingDecision.status, headers: { Location: routingDecision.location }, }), request ); return; } if (routingDecision.type === "reject") { await sendWebResponse(res, new Response("Not Found", { status: routingDecision.status }), request); return; } if (routingDecision.serveStaticAssets) { if (await serveStaticAsset(req, res)) { return; } if (!routingDecision.reactRouterFallback) { await sendWebResponse(res, new Response("Not Found", { status: 404 }), routingDecision.request); return; } } const manifestDispatchDecision = prepareReactRouterManifestRequest(routingDecision.request, runtimeBuildSurface); if (manifestDispatchDecision.type === "reject") { await sendWebResponse(res, new Response("Not Found", { status: manifestDispatchDecision.status }), routingDecision.request); return; } const manifestRequest = manifestDispatchDecision.request; const mutationOriginDecision = validateMutationOrigin(manifestRequest); if (!mutationOriginDecision.allowed) { serverLogger.warn({ reason: mutationOriginDecision.reason, method: manifestRequest.method }, "cross-origin web mutation rejected"); await sendWebResponse( res, new Response("Forbidden", { status: 403, headers: { "Cache-Control": "no-store" }, }), manifestRequest ); return; } if (routingDecision.target === "web" && isWebOperationalRoute(manifestRequest)) { await sendWebResponse(res, await handleReactRouterRequest(manifestRequest), manifestRequest); return; } if (routingDecision.target === "api") { serverLogger.warn({ host: request.headers.get("host") }, "API host request rejected by web runtime"); await sendWebResponse(res, new Response("Not Found", { status: 404 }), request); return; } await sendWebResponse(res, await handleReactRouterRequest(manifestRequest), manifestRequest); } catch (error) { if (isRequestBodyTooLargeError(error)) { sendPayloadTooLarge(res); return; } 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.requestTimeout = 120_000; server.headersTimeout = 15_000; server.keepAliveTimeout = 5_000; server.maxHeadersCount = 100; server.maxRequestsPerSocket = 1_000; server.listen(PORT, HOST, () => { const address = HOST ?? getLocalAddress() ?? "localhost"; serverLogger.info({ host: address, port: PORT }, "server listening"); }); let shuttingDown = false; for (const signal of ["SIGTERM", "SIGINT"] as const) { process.once(signal, () => beginGracefulShutdown(signal)); } 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 metadata = await getStaticAssetMetadata(filePath); if (!metadata) { return false; } const representation = selectStaticRepresentation(metadata, getSingleHeader(req.headers["accept-encoding"])); const headers = getStaticHeaders(filePath, requestUrl, metadata, representation); const etag = headers.ETag; if ( etag && isStaticAssetNotModified({ etag, ifNoneMatch: getSingleHeader(req.headers["if-none-match"]), ifModifiedSince: getSingleHeader(req.headers["if-modified-since"]), mtimeMs: metadata.identity.stat.mtimeMs, }) ) { delete headers["Content-Length"]; res.writeHead(304, headers); res.end(); return true; } res.writeHead(200, headers); if (req.method === "HEAD") { res.end(); return true; } createReadStream(representation.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, bodyLimitBytes: number) { const host = req.headers.host || "localhost"; const url = createWebRequestUrl(req.url, host); const controller = new AbortController(); res.on("close", () => { controller.abort(); }); const headers = createWebHeaders(req.headers); overwriteRequestCspNonce(headers); const init: NodeRequestInit = { method: req.method, headers, signal: controller.signal, }; if (req.method !== "GET" && req.method !== "HEAD") { init.body = createReadableStreamFromReadable(createLimitedRequestBody(req, bodyLimitBytes)); init.duplex = "half"; } return new Request(url, init); } function createWebRequestUrl(requestUrl: string | undefined, host: string) { try { return new URL(requestUrl ?? "/", `${webConfig.routing.protocol}://${host}`); } catch { // Preserve the original Host header for the routing gate; this inert URL // only prevents malformed client input from escaping the fail-closed 404. return new URL(requestUrl ?? "/", `${webConfig.routing.protocol}://invalid.localhost`); } } function isWebOperationalRoute(request: Request) { const { pathname } = new URL(request.url); return pathname === "/healthz" || pathname === "/readyz" || pathname === "/healthcheck" || pathname === "/api/metrics"; } function createWebHeaders(headers: IncomingMessage["headers"]) { const webHeaders = new Headers(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase().startsWith("x-hivegrid-portal-")) { continue; } 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, request: Request) { res.statusCode = response.status; res.statusMessage = response.statusText; for (const [key, value] of response.headers) { if (key.toLowerCase() === CSP_NONCE_REQUEST_HEADER) continue; res.appendHeader(key, value); } const portalCapabilityHeaders = getPortalCapabilityResponseSecurityHeaders(request, runtimeBuildSurface); if (portalCapabilityHeaders) { for (const [key, value] of Object.entries(portalCapabilityHeaders)) { res.setHeader(key, value); } } appendWebSecurityHeaders(res, { documentNonce: getRequestCspNonce(request), documentPolicy: getRequestDocumentContentSecurityPolicyOptions(request, webConfig, process.env, browserBuildEnv), }); if (!response.body) { res.end(); return; } try { await writeReadableStreamToWritable(response.body, res); } catch (error) { // A browser can abandon an SSR or SSE response while it is still streaming. // This is expected transport cancellation, not a server failure. if (request.signal.aborted) { serverLogger.debug({ event: "web.response.client_disconnected" }, "web response stream ended after client disconnect"); return; } throw error; } } type WebSecurityHeaderOptions = { documentNonce?: string; documentPolicy?: DocumentContentSecurityPolicyOptions; }; function appendWebSecurityHeaders(headers: Headers, options?: WebSecurityHeaderOptions): Headers; function appendWebSecurityHeaders(headers: Record, options?: WebSecurityHeaderOptions): Record; function appendWebSecurityHeaders(headers: ServerResponse, options?: WebSecurityHeaderOptions): ServerResponse; function appendWebSecurityHeaders(headers: Headers | Record | ServerResponse, options: WebSecurityHeaderOptions = {}) { const routePolicy = getHeaderValue(headers, "Content-Security-Policy"); const routeReferrerPolicy = getHeaderValue(headers, "Referrer-Policy"); const contentSecurityPolicy = options.documentNonce ? buildDocumentContentSecurityPolicy(options.documentNonce, routePolicy, options.documentPolicy) : (routePolicy ?? STATIC_ASSET_CONTENT_SECURITY_POLICY); const allowsExternalFraming = contentSecurityPolicyAllowsExternalFraming(contentSecurityPolicy); const securityHeaders: Record = { "Content-Security-Policy": contentSecurityPolicy, "X-Content-Type-Options": "nosniff", "Referrer-Policy": routeReferrerPolicy ?? "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()", "Cross-Origin-Opener-Policy": allowsExternalFraming ? "unsafe-none" : "same-origin", "Cross-Origin-Resource-Policy": allowsExternalFraming ? "cross-origin" : "same-origin", "Cross-Origin-Embedder-Policy": "unsafe-none", }; if (webConfig.routing.protocol === "https") { securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; } if (allowsExternalFraming) { removeHeader(headers, "X-Frame-Options"); } else { securityHeaders["X-Frame-Options"] = "SAMEORIGIN"; } for (const [key, value] of Object.entries(securityHeaders)) { if (headers instanceof Headers) { headers.set(key, value); } else if (headers instanceof ServerResponse) { headers.setHeader(key, value); } else { headers[key] = value; } } return headers; } function getHeaderValue(headers: Headers | Record | ServerResponse, key: string): string | undefined { if (headers instanceof Headers) { return headers.get(key) ?? undefined; } if (headers instanceof ServerResponse) { const value = headers.getHeader(key); if (Array.isArray(value)) return value.join("; "); return value === undefined ? undefined : String(value); } const normalizedKey = key.toLowerCase(); const candidate = Object.keys(headers).find((headerName) => headerName.toLowerCase() === normalizedKey); return candidate ? headers[candidate] : undefined; } function removeHeader(headers: Headers | Record | ServerResponse, key: string): void { if (headers instanceof Headers) { headers.delete(key); return; } if (headers instanceof ServerResponse) { headers.removeHeader(key); return; } const normalizedKey = key.toLowerCase(); for (const candidate of Object.keys(headers)) { if (candidate.toLowerCase() === normalizedKey) delete headers[candidate]; } } function getStaticHeaders(filePath: string, requestUrl: URL, metadata: StaticAssetMetadata, representation: StaticAssetRepresentation) { const headers: Record = { "Content-Type": getContentType(filePath), "Content-Length": String(representation.stat.size), "Last-Modified": metadata.identity.stat.mtime.toUTCString(), ETag: createStaticEtag(representation), "Cache-Control": getStaticCacheControl(requestUrl.pathname, Boolean(requestUrl.searchParams.get("v"))), }; if (requestUrl.pathname === "/sw.js") { headers["Content-Security-Policy"] = SERVICE_WORKER_CONTENT_SECURITY_POLICY; } if (representation.encoding !== "identity") { headers["Content-Encoding"] = representation.encoding; } if (hasCompressedRepresentation(metadata)) { headers.Vary = "Accept-Encoding"; } return appendWebSecurityHeaders(headers); } function getSingleHeader(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value.at(-1) : value; } function getDeclaredBodyLength(req: IncomingMessage): number | null { if (req.method === "GET" || req.method === "HEAD") { return null; } return parseRequestContentLength(getSingleHeader(req.headers["content-length"])); } function sendPayloadTooLarge(res: ServerResponse): void { if (res.headersSent) { res.destroy(); return; } res.statusCode = 413; res.shouldKeepAlive = false; res.setHeader("Connection", "close"); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); appendWebSecurityHeaders(res); res.end("Request body too large"); } function beginGracefulShutdown(signal: "SIGINT" | "SIGTERM"): void { if (shuttingDown) return; shuttingDown = true; serverLogger.info({ signal }, "server shutdown started"); server.closeIdleConnections(); const forceCloseTimer = setTimeout(() => { serverLogger.error({ signal }, "server shutdown deadline exceeded"); server.closeAllConnections(); process.exitCode = 1; }, 10_000); forceCloseTimer.unref(); server.close((error) => { clearTimeout(forceCloseTimer); if (error) { serverLogger.error({ error }, "server shutdown failed"); process.exitCode = 1; } }); } 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; }