Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/server/server.ts
T

598 lines
20 KiB
TypeScript

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<Response>) => Promise<Response>;
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<string, string>, options?: WebSecurityHeaderOptions): Record<string, string>;
function appendWebSecurityHeaders(headers: ServerResponse, options?: WebSecurityHeaderOptions): ServerResponse;
function appendWebSecurityHeaders(headers: Headers | Record<string, string> | 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<string, string> = {
"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<string, string> | 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<string, string> | 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<string, string> = {
"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;
}