598 lines
20 KiB
TypeScript
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;
|
|
}
|