Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getHostRoutingRuntimeDecision } from "./host-routing-runtime";
|
||||
|
||||
function expectDispatchDecision(request: Request) {
|
||||
const decision = getHostRoutingRuntimeDecision(request);
|
||||
expect(decision.type).toBe("dispatch");
|
||||
if (decision.type !== "dispatch") {
|
||||
throw new Error(`Expected dispatch decision, received ${decision.type}`);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
describe("getHostRoutingRuntimeDecision", () => {
|
||||
test("dispatches the canonical app host root to the tenant picker route", () => {
|
||||
const decision = expectDispatchDecision(new Request("http://app.localhost/?from=login#tenants"));
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe("/");
|
||||
expect(new URL(decision.request.url).search).toBe("?from=login");
|
||||
expect(new URL(decision.request.url).hash).toBe("#tenants");
|
||||
});
|
||||
|
||||
test("keeps app host requests on promoted tenant paths for web dispatch", () => {
|
||||
const decision = expectDispatchDecision(new Request("http://app.localhost/acme/dashboard?tab=activity#events"));
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe("/acme/dashboard");
|
||||
expect(new URL(decision.request.url).search).toBe("?tab=activity");
|
||||
expect(new URL(decision.request.url).hash).toBe("#events");
|
||||
});
|
||||
|
||||
test("dispatches public auth routes on the app host without tenant rewriting", () => {
|
||||
const decision = expectDispatchDecision(new Request("http://app.localhost/login?redirectTo=%2Facme%2Fdashboard"));
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe("/login");
|
||||
expect(new URL(decision.request.url).search).toBe("?redirectTo=%2Facme%2Fdashboard");
|
||||
});
|
||||
|
||||
test("rejects removed public API paths instead of redirecting compatibility routes", () => {
|
||||
const decision = getHostRoutingRuntimeDecision(new Request("http://app.localhost/api/workflows/run/workflow_1?tenantId=tenant_1"));
|
||||
|
||||
expect(decision).toEqual({
|
||||
type: "reject",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects legacy api v1 paths before removed API path rejection", () => {
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://app.localhost/api/v1/health?check=deep#status"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://api.localhost/v1/health?check=deep#status",
|
||||
});
|
||||
});
|
||||
|
||||
test("lets internal API proxies reach private web API handlers without public redirects", () => {
|
||||
const decision = expectDispatchDecision(
|
||||
new Request("http://app.localhost/internal/api/workflows/run/workflow_1?tenantId=tenant_1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-toolctrl-internal-web-api-proxy": "1",
|
||||
"x-toolctrl-internal-web-api-proxy-signature": "signed",
|
||||
"x-api-key": "tenant-api-key",
|
||||
},
|
||||
body: JSON.stringify({ name: "Ada" }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe("/internal/api/workflows/run/workflow_1");
|
||||
expect(new URL(decision.request.url).search).toBe("?tenantId=tenant_1");
|
||||
expect(decision.request.headers.get("x-toolctrl-internal-web-api-proxy")).toBe("1");
|
||||
expect(decision.request.headers.get("x-toolctrl-internal-web-api-proxy-signature")).toBe("signed");
|
||||
expect(decision.request.headers.get("x-api-key")).toBe("tenant-api-key");
|
||||
});
|
||||
|
||||
test("dispatches canonical admin host requests for web dispatch", () => {
|
||||
const decision = expectDispatchDecision(new Request("http://admin.localhost/accounts/users?role=owner#active"));
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe("/accounts/users");
|
||||
expect(new URL(decision.request.url).search).toBe("?role=owner");
|
||||
expect(new URL(decision.request.url).hash).toBe("#active");
|
||||
});
|
||||
|
||||
test("keeps canonical admin host routes stable for web dispatch", () => {
|
||||
const settingsDecision = expectDispatchDecision(new Request("http://admin.localhost/settings/profile?tab=mfa#codes"));
|
||||
const metricsDataDecision = expectDispatchDecision(new Request("http://admin.localhost/metrics/summary.data?_routes=routes%2Fadmin%2Fmetrics%2Fsummary.__host"));
|
||||
|
||||
expect(settingsDecision.target).toBe("web");
|
||||
expect(new URL(settingsDecision.request.url).pathname).toBe("/settings/profile");
|
||||
expect(new URL(settingsDecision.request.url).search).toBe("?tab=mfa");
|
||||
expect(new URL(settingsDecision.request.url).hash).toBe("#codes");
|
||||
expect(new URL(metricsDataDecision.request.url).pathname).toBe("/metrics/summary.data");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["/api?tab=overview", "/api"],
|
||||
["/api/keys?filter=active#keys", "/api/keys"],
|
||||
["/api/keys/new", "/api/keys/new"],
|
||||
["/api/keys/api_key_1", "/api/keys/api_key_1"],
|
||||
["/api/logs", "/api/logs"],
|
||||
["/api/docs", "/api/docs"],
|
||||
["/api/credits", "/api/credits"],
|
||||
["/api/keys.data?_routes=routes%2Fadmin%2Fapi%2Fkeys.__host", "/api/keys.data"],
|
||||
])("dispatches canonical admin API section route %s to the web runtime", (route, expectedPathname) => {
|
||||
const decision = expectDispatchDecision(new Request(`http://admin.localhost${route}`));
|
||||
|
||||
expect(decision.target).toBe("web");
|
||||
expect(new URL(decision.request.url).pathname).toBe(expectedPathname);
|
||||
});
|
||||
|
||||
test("redirects API host unversioned aliases to v1 paths before dispatch", () => {
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://api.localhost/health?check=deep#status"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://api.localhost/v1/health?check=deep#status",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://api.localhost/accounts?cursor=next"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://api.localhost/v1/accounts?cursor=next",
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects public web paths away from the API host", () => {
|
||||
const decision = getHostRoutingRuntimeDecision(new Request("http://api.localhost/login?redirectTo=%2Facme%2Fdashboard"));
|
||||
|
||||
expect(decision).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://app.localhost/login?redirectTo=%2Facme%2Fdashboard",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not double-prefix API internal paths", () => {
|
||||
const apiDecision = expectDispatchDecision(new Request("http://api.localhost/v1/health?check=deep#status"));
|
||||
|
||||
expect(new URL(apiDecision.request.url).pathname).toBe("/v1/health");
|
||||
});
|
||||
|
||||
test("rejects removed root app paths instead of redirecting compatibility routes", () => {
|
||||
const decision = getHostRoutingRuntimeDecision(new Request("http://localhost/app/acme/settings?tab=profile#billing"));
|
||||
|
||||
expect(decision).toEqual({
|
||||
type: "reject",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects removed admin paths on root and app hosts", () => {
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://localhost/admin/settings?tab=users#active"))).toEqual({
|
||||
type: "reject",
|
||||
status: 404,
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://app.localhost/admin/settings?tab=users#active"))).toEqual({
|
||||
type: "reject",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects canonical paths away from the wrong host before dispatch", () => {
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://app.localhost/dashboard?tab=activity#events"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://admin.localhost/dashboard?tab=activity#events",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://app.localhost/accounts/users?filter=active"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://admin.localhost/accounts/users?filter=active",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://admin.localhost/acme/dashboard?tab=activity#events"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://app.localhost/acme/dashboard?tab=activity#events",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://admin.localhost/acme"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://app.localhost/acme",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://api.localhost/acme"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://app.localhost/acme",
|
||||
});
|
||||
expect(getHostRoutingRuntimeDecision(new Request("http://app.localhost/v1/health"))).toEqual({
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: "http://api.localhost/v1/health",
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatches API operational routes to the API runtime", () => {
|
||||
const healthDecision = expectDispatchDecision(new Request("http://api.localhost/healthz"));
|
||||
const readyDecision = expectDispatchDecision(new Request("http://api.localhost/readyz"));
|
||||
|
||||
expect(healthDecision.target).toBe("api");
|
||||
expect(new URL(healthDecision.request.url).pathname).toBe("/healthz");
|
||||
expect(readyDecision.target).toBe("api");
|
||||
expect(new URL(readyDecision.request.url).pathname).toBe("/readyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { getCanonicalHostRedirectUrl, getHostAwareInternalUrlPath, getHostRoutingContextFromRequest } from "../apps/web/app/utils/app/HostRouting.server";
|
||||
|
||||
export type HostRoutingRuntimeDecision =
|
||||
| { type: "redirect"; status: 308; location: string }
|
||||
| { type: "reject"; status: 404 }
|
||||
| { type: "dispatch"; target: "api" | "web"; request: Request };
|
||||
|
||||
const internalWebApiProxyHeader = "x-toolctrl-internal-web-api-proxy";
|
||||
|
||||
export function getHostRoutingRuntimeDecision(request: Request): HostRoutingRuntimeDecision {
|
||||
const context = getHostRoutingContextFromRequest(request);
|
||||
if (isInternalWebApiProxyRequest(request, context)) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const rewrittenRequest = rewriteRequestUrl(request, `${requestUrl.pathname}${requestUrl.search}${requestUrl.hash}`);
|
||||
return {
|
||||
type: "dispatch",
|
||||
target: "web",
|
||||
request: rewrittenRequest,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRemovedLegacyRoute(request, context)) {
|
||||
return {
|
||||
type: "reject",
|
||||
status: 404,
|
||||
};
|
||||
}
|
||||
|
||||
const canonicalRedirectUrl = getCanonicalHostRedirectUrl(request);
|
||||
if (canonicalRedirectUrl) {
|
||||
return {
|
||||
type: "redirect",
|
||||
status: 308,
|
||||
location: canonicalRedirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const rewrittenRequest = rewriteRequestUrl(request, getRuntimeInternalUrlPath(request, context));
|
||||
return {
|
||||
type: "dispatch",
|
||||
target: context === "api" ? "api" : "web",
|
||||
request: rewrittenRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function isInternalWebApiProxyRequest(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
|
||||
if (context !== "app" || request.headers.get(internalWebApiProxyHeader) !== "1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathname = new URL(request.url).pathname;
|
||||
return pathname === "/internal/api/usage" || pathname === "/internal/api/stripe.webhooks" || pathname.startsWith("/internal/api/workflows/");
|
||||
}
|
||||
|
||||
function isRemovedLegacyRoute(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
|
||||
const pathname = new URL(request.url).pathname.replace(/\/+$/g, "") || "/";
|
||||
const isLegacyApiV1Route = pathname === "/api/v1" || pathname.startsWith("/api/v1/");
|
||||
const isAdminApiSectionRoute = context === "admin" && (pathname === "/api" || pathname.startsWith("/api/")) && !isLegacyApiV1Route;
|
||||
return (
|
||||
pathname === "/app" ||
|
||||
pathname.startsWith("/app/") ||
|
||||
pathname === "/admin" ||
|
||||
pathname.startsWith("/admin/") ||
|
||||
(!isAdminApiSectionRoute && (pathname === "/api" || (pathname.startsWith("/api/") && !isLegacyApiV1Route)))
|
||||
);
|
||||
}
|
||||
|
||||
function getRuntimeInternalUrlPath(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
|
||||
if (context === "app") {
|
||||
const url = new URL(request.url);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
return getHostAwareInternalUrlPath(request);
|
||||
}
|
||||
|
||||
function rewriteRequestUrl(request: Request, internalUrlPath: string) {
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
|
||||
const rewrittenUrl = new URL(internalUrlPath, url);
|
||||
return new Request(rewrittenUrl, request);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@toolctrl/server",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const serverSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "server.ts"), "utf8");
|
||||
const rootPackage = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
||||
const webPackage = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "apps", "web", "package.json"), "utf8"));
|
||||
|
||||
describe("server runtime routing integration", () => {
|
||||
test("delegates host routing decisions before API or React Router dispatch", () => {
|
||||
expect(serverSource).toContain("getHostRoutingRuntimeDecision(request)");
|
||||
expect(serverSource).toContain('routingDecision.type === "redirect"');
|
||||
expect(serverSource).toContain('routingDecision.target === "api"');
|
||||
});
|
||||
|
||||
test("uses required web config for fail-fast production startup and listen host/port", () => {
|
||||
expect(serverSource).toContain('import { loadRequiredWebConfig } from "@toolctrl/config"');
|
||||
expect(serverSource).toContain("const webConfig = loadRequiredWebConfig()");
|
||||
expect(serverSource).toContain("const PORT = webConfig.listen.port");
|
||||
expect(serverSource).toContain("const HOST = webConfig.listen.host");
|
||||
expect(serverSource).not.toContain("loadWebConfig().config");
|
||||
expect(serverSource).not.toContain("parsePort(process.env.PORT)");
|
||||
expect(serverSource).not.toContain("process.env.HOST");
|
||||
});
|
||||
|
||||
test("delegates root start to the web runtime owner", () => {
|
||||
expect(rootPackage.scripts.start).toBe("pnpm --filter @toolctrl/web start");
|
||||
expect(webPackage.scripts.start).toContain("node ../../build/server-runtime/server.js");
|
||||
expect(webPackage.scripts.start).not.toContain("tsx");
|
||||
expect(rootPackage.scripts.start).not.toContain("tsx");
|
||||
});
|
||||
|
||||
test("resolves web build artifacts independently from caller cwd", () => {
|
||||
expect(serverSource).toContain("const RUNTIME_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))");
|
||||
expect(serverSource).toContain('RUNTIME_DIRECTORY.endsWith(path.join("build", "server-runtime"))');
|
||||
expect(serverSource).toContain('path.join(REPO_ROOT_DIRECTORY, "apps", "web", "build")');
|
||||
expect(serverSource).toContain('const BUILD_PATH = path.join(WEB_BUILD_DIRECTORY, "server", "index.js")');
|
||||
expect(serverSource).toContain('const CLIENT_BUILD_DIRECTORY = path.join(WEB_BUILD_DIRECTORY, "client")');
|
||||
expect(serverSource).not.toContain('path.resolve("build/server/index.js")');
|
||||
expect(serverSource).not.toContain('path.resolve("build/client")');
|
||||
});
|
||||
|
||||
test("defines idempotent web security headers", () => {
|
||||
expect(serverSource).toContain("function appendWebSecurityHeaders");
|
||||
expect(serverSource).toContain("\"Content-Security-Policy\": \"base-uri 'self'; object-src 'none'; frame-ancestors 'self'\"");
|
||||
expect(serverSource).toContain('"Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"');
|
||||
expect(serverSource).toContain('"X-Content-Type-Options": "nosniff"');
|
||||
expect(serverSource).toContain('"Referrer-Policy": "strict-origin-when-cross-origin"');
|
||||
expect(serverSource).toContain('"X-Frame-Options": "SAMEORIGIN"');
|
||||
expect(serverSource).toContain('"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()"');
|
||||
expect(serverSource).toContain('"Cross-Origin-Opener-Policy": "same-origin"');
|
||||
expect(serverSource).toContain('"Cross-Origin-Resource-Policy": "same-origin"');
|
||||
expect(serverSource).toContain('"Cross-Origin-Embedder-Policy": "unsafe-none"');
|
||||
expect(serverSource).toContain('key === "Content-Security-Policy" && hasHeader(headers, key)');
|
||||
expect(serverSource).toContain("headers.set(key, value)");
|
||||
expect(serverSource).toContain("headers.setHeader(key, value)");
|
||||
});
|
||||
|
||||
test("applies web security headers to forwarded responses", () => {
|
||||
expect(serverSource).toMatch(/for \(const \[key, value\] of response\.headers\)[\s\S]*res\.appendHeader\(key, value\);[\s\S]*appendWebSecurityHeaders\(res\);/);
|
||||
});
|
||||
|
||||
test("applies web security headers to static assets without changing cache control", () => {
|
||||
expect(serverSource).toContain('headers["Cache-Control"] = "public, max-age=31536000, immutable"');
|
||||
expect(serverSource).toMatch(/function getStaticHeaders[\s\S]*return appendWebSecurityHeaders\(headers\);/);
|
||||
});
|
||||
|
||||
test("sets a 1-day cache for /fonts and /images static assets", () => {
|
||||
// Hash-named bundles get `immutable` because their URL contains the hash,
|
||||
// but unhashed brand assets need a shorter TTL or stale-content lingers.
|
||||
// See docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md.
|
||||
expect(serverSource).toContain('headers["Cache-Control"] = "public, max-age=86400"');
|
||||
expect(serverSource).toMatch(/pathname === "\/fonts"[\s\S]*pathname\.startsWith\("\/fonts\/"\)/);
|
||||
expect(serverSource).toMatch(/pathname === "\/images"[\s\S]*pathname\.startsWith\("\/images\/"\)/);
|
||||
});
|
||||
|
||||
test("wires server diagnostics through the structured logger", () => {
|
||||
expect(serverSource).toContain('import { createChildLogger, defaultLogger } from "@toolctrl/logger"');
|
||||
expect(serverSource).toContain('const serverLogger = createChildLogger(defaultLogger, { component: "server" })');
|
||||
expect(serverSource).toContain('serverLogger.error({ error }, "server request failed")');
|
||||
expect(serverSource).toContain('serverLogger.info({ host: address, port: PORT }, "server listening")');
|
||||
expect(serverSource).toContain('serverLogger.error({ error }, "server shutdown failed")');
|
||||
expect(serverSource).toContain('serverLogger.error({ error }, "static asset stream failed")');
|
||||
});
|
||||
|
||||
test("does not leave active console diagnostics in the server runtime", () => {
|
||||
expect(serverSource).not.toMatch(/console\.(?:log|error|warn|info)\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user