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}`); });