Files
weirsoe-party-protocol/scripts/serve_static_dir.mjs
Asger Geel Weirsøe a81bc1250c
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
Big visual overhaul docker compsoe file etc
2026-03-23 14:11:30 +01:00

266 lines
7.8 KiB
JavaScript

import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { extname, join, normalize, resolve } from "node:path";
const [, , rootArg = ".", portArg = "4200", backendArg = "http://app:8000"] = process.argv;
const rootDir = resolve(rootArg);
const port = Number.parseInt(portArg, 10);
const backendOrigin = backendArg.replace(/\/$/, "");
const backendUrl = new URL(backendOrigin);
if (!Number.isInteger(port) || port <= 0) {
console.error(`[static-server] invalid port: ${portArg}`);
process.exit(1);
}
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".map", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".txt", "text/plain; charset=utf-8"],
[".woff2", "font/woff2"],
]);
const proxyPrefixes = ["/accounts", "/admin", "/healthz", "/lobby", "/media", "/static", "/ws"];
function safePathname(urlPath) {
const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
const normalized = normalize(decoded).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized === "/" ? "/" : normalized;
}
function shouldProxy(urlPath) {
const pathname = safePathname(urlPath);
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
async function resolveFile(urlPath) {
const pathname = safePathname(urlPath);
const candidatePaths =
pathname === "/"
? [resolve(join(rootDir, "browser", "index.html"))]
: [
resolve(join(rootDir, `.${pathname}`)),
resolve(join(rootDir, "browser", `.${pathname}`)),
];
for (const candidatePath of candidatePaths) {
if (!candidatePath.startsWith(rootDir) || !existsSync(candidatePath)) {
continue;
}
const candidateStat = await stat(candidatePath);
if (candidateStat.isDirectory()) {
const indexPath = join(candidatePath, "index.html");
if (existsSync(indexPath)) {
return indexPath;
}
continue;
}
return candidatePath;
}
if (!extname(pathname) && !shouldProxy(pathname)) {
const spaIndex = resolve(join(rootDir, "browser", "index.html"));
if (spaIndex.startsWith(rootDir) && existsSync(spaIndex)) {
return spaIndex;
}
}
return null;
}
async function readRequestBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function proxyRequest(request, response) {
const upstreamUrl = new URL(request.url || "/", `${backendOrigin}/`);
const body =
request.method && request.method !== "GET" && request.method !== "HEAD" ? await readRequestBody(request) : undefined;
const headers = {};
for (const [key, value] of Object.entries(request.headers)) {
if (value === undefined) {
continue;
}
const loweredKey = key.toLowerCase();
if (loweredKey === "host" || loweredKey === "connection") {
continue;
}
headers[key] = value;
}
const forwardedHost = request.headers.host || "localhost";
headers.host = forwardedHost;
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = "http";
if (request.socket.remoteAddress) {
headers["x-forwarded-for"] = request.socket.remoteAddress;
}
const transport = upstreamUrl.protocol === "https:" ? httpsRequest : httpRequest;
await new Promise((resolvePromise, rejectPromise) => {
const upstream = transport(
{
protocol: upstreamUrl.protocol,
hostname: backendUrl.hostname,
port: backendUrl.port || (upstreamUrl.protocol === "https:" ? 443 : 80),
method: request.method,
path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
headers,
},
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
upstreamResponse.on("end", resolvePromise);
},
);
upstream.on("error", rejectPromise);
if (body) {
upstream.end(body);
return;
}
upstream.end();
});
}
function shouldProxyUpgrade(urlPath) {
const pathname = safePathname(urlPath);
return pathname === "/ws" || pathname.startsWith("/ws/");
}
function writeUpgradeFailure(socket, message = "Bad Gateway") {
try {
socket.write(`HTTP/1.1 502 ${message}\r\nConnection: close\r\n\r\n`);
} finally {
socket.destroy();
}
}
function proxyUpgrade(request, socket, head) {
const upstreamUrl = new URL(request.url || "/", `${backendOrigin}/`);
const headers = {};
for (const [key, value] of Object.entries(request.headers)) {
if (value === undefined) {
continue;
}
headers[key] = value;
}
const forwardedHost = request.headers.host || "localhost";
headers.host = forwardedHost;
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = "http";
if (request.socket.remoteAddress) {
headers["x-forwarded-for"] = request.socket.remoteAddress;
}
const transport = upstreamUrl.protocol === "https:" ? httpsRequest : httpRequest;
const upstream = transport({
protocol: upstreamUrl.protocol,
hostname: backendUrl.hostname,
port: backendUrl.port || (upstreamUrl.protocol === "https:" ? 443 : 80),
method: request.method,
path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
headers,
});
upstream.on("upgrade", (upstreamResponse, upstreamSocket, upstreamHead) => {
const lines = [`HTTP/1.1 ${upstreamResponse.statusCode || 101} ${upstreamResponse.statusMessage || "Switching Protocols"}`];
for (const [key, value] of Object.entries(upstreamResponse.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
lines.push(`${key}: ${item}`);
}
continue;
}
lines.push(`${key}: ${value}`);
}
lines.push("", "");
socket.write(lines.join("\r\n"));
if (head?.length) {
upstreamSocket.write(head);
}
if (upstreamHead?.length) {
socket.write(upstreamHead);
}
// After the HTTP upgrade handshake, both sockets become a raw websocket tunnel.
upstreamSocket.pipe(socket);
socket.pipe(upstreamSocket);
});
upstream.on("response", (upstreamResponse) => {
upstreamResponse.resume();
writeUpgradeFailure(socket, upstreamResponse.statusMessage || "Bad Gateway");
});
upstream.on("error", () => {
writeUpgradeFailure(socket);
});
socket.on("error", () => {
upstream.destroy();
});
upstream.end();
}
const server = createServer(async (request, response) => {
try {
if (shouldProxy(request.url || "/")) {
await proxyRequest(request, response);
return;
}
const filePath = await resolveFile(request.url || "/");
if (!filePath) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found\n");
return;
}
response.writeHead(200, {
"Content-Type": contentTypes.get(extname(filePath)) || "application/octet-stream",
});
createReadStream(filePath).pipe(response);
} catch (error) {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end(`Server error: ${error instanceof Error ? error.message : "unknown"}\n`);
}
});
server.on("upgrade", (request, socket, head) => {
if (!shouldProxyUpgrade(request.url || "/")) {
writeUpgradeFailure(socket, "Not Found");
return;
}
proxyUpgrade(request, socket, head);
});
server.listen(port, "0.0.0.0", () => {
console.log(`[static-server] serving ${rootDir} on 0.0.0.0:${port} with backend proxy ${backendOrigin}`);
});