266 lines
7.8 KiB
JavaScript
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}`);
|
|
});
|