177 lines
6.9 KiB
JavaScript
177 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const path = require("path");
|
|
|
|
const MIME_TYPES = {
|
|
".css": "text/css; charset=utf-8",
|
|
".glb": "model/gltf-binary",
|
|
".gltf": "model/gltf+json",
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".ttf": "font/ttf",
|
|
".wasm": "application/wasm",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
};
|
|
|
|
function parseArgs(argv) {
|
|
const values = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (!argument.startsWith("--")) continue;
|
|
const key = argument.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`);
|
|
values[key] = value;
|
|
index += 1;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function createV2xPreviewServer({ root, upstream, wsUpstream }) {
|
|
const staticRoot = path.resolve(root);
|
|
const upstreamUrl = new URL(upstream);
|
|
// Deployments front the API and the WebSocket with different prefixes (e.g.
|
|
// /dashboardApi and /dashboardWebsocket), so the two targets are separate.
|
|
const wsUpstreamUrl = wsUpstream ? new URL(wsUpstream) : upstreamUrl;
|
|
if (!fs.statSync(staticRoot).isDirectory()) throw new Error(`Preview root is not a directory: ${staticRoot}`);
|
|
if (!/^https?:$/.test(upstreamUrl.protocol)) throw new Error("V2X upstream must use http or https");
|
|
if (!/^https?:$/.test(wsUpstreamUrl.protocol)) throw new Error("V2X websocket upstream must use http or https");
|
|
|
|
const server = http.createServer((request, response) => {
|
|
if (isProxyPath(request.url)) {
|
|
proxyHttpRequest(request, response, upstreamUrl);
|
|
return;
|
|
}
|
|
serveStaticFile(request, response, staticRoot);
|
|
});
|
|
|
|
server.on("upgrade", (request, socket, head) => {
|
|
if (!isProxyPath(request.url)) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
proxyWebSocket(request, socket, head, wsUpstreamUrl);
|
|
});
|
|
return server;
|
|
}
|
|
|
|
function isProxyPath(url) {
|
|
const pathname = new URL(url, "http://preview.local").pathname;
|
|
return pathname === "/api" || pathname.startsWith("/api/") ||
|
|
pathname === "/websocket" || pathname.startsWith("/websocket/");
|
|
}
|
|
|
|
// The browser calls /api/... and /websocket/...; the local prefix is dropped and
|
|
// whatever base path the upstream URL carries is prepended, so an upstream of
|
|
// http://host:7862/dashboardApi maps /api/facilities/... onto
|
|
// /dashboardApi/facilities/... instead of the bare backend path.
|
|
function upstreamPath(url, basePath) {
|
|
const parsed = new URL(url, "http://preview.local");
|
|
const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/";
|
|
const base = String(basePath || "").replace(/\/+$/, "");
|
|
return `${base}${pathname}${parsed.search}`;
|
|
}
|
|
|
|
function upstreamRequestOptions(request, upstreamUrl) {
|
|
return {
|
|
protocol: upstreamUrl.protocol,
|
|
hostname: upstreamUrl.hostname,
|
|
port: upstreamUrl.port || undefined,
|
|
method: request.method,
|
|
path: upstreamPath(request.url, upstreamUrl.pathname),
|
|
headers: { ...request.headers, host: upstreamUrl.host },
|
|
};
|
|
}
|
|
|
|
function proxyHttpRequest(request, response, upstreamUrl) {
|
|
const client = upstreamUrl.protocol === "https:" ? https : http;
|
|
const proxyRequest = client.request(upstreamRequestOptions(request, upstreamUrl), (proxyResponse) => {
|
|
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
|
proxyResponse.pipe(response);
|
|
});
|
|
proxyRequest.on("error", (error) => {
|
|
if (!response.headersSent) {
|
|
response.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({ error: "V2X upstream unavailable", detail: error.message }));
|
|
} else {
|
|
response.destroy(error);
|
|
}
|
|
});
|
|
request.pipe(proxyRequest);
|
|
}
|
|
|
|
function proxyWebSocket(request, socket, head, upstreamUrl) {
|
|
const client = upstreamUrl.protocol === "https:" ? https : http;
|
|
const options = upstreamRequestOptions(request, upstreamUrl);
|
|
options.headers = {
|
|
...options.headers,
|
|
connection: "Upgrade",
|
|
upgrade: "websocket",
|
|
};
|
|
const proxyRequest = client.request(options);
|
|
proxyRequest.on("upgrade", (proxyResponse, upstreamSocket, upstreamHead) => {
|
|
socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n`);
|
|
Object.entries(proxyResponse.headers).forEach(([name, value]) => {
|
|
socket.write(`${name}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`);
|
|
});
|
|
socket.write("\r\n");
|
|
if (upstreamHead.length) socket.write(upstreamHead);
|
|
if (head.length) upstreamSocket.write(head);
|
|
socket.pipe(upstreamSocket).pipe(socket);
|
|
});
|
|
proxyRequest.on("response", (proxyResponse) => {
|
|
socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n\r\n`);
|
|
socket.destroy();
|
|
});
|
|
proxyRequest.on("error", () => socket.destroy());
|
|
proxyRequest.end();
|
|
}
|
|
|
|
function serveStaticFile(request, response, root) {
|
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
response.writeHead(405, { Allow: "GET, HEAD" });
|
|
response.end();
|
|
return;
|
|
}
|
|
const pathname = decodeURIComponent(new URL(request.url, "http://preview.local").pathname);
|
|
const relativePath = pathname === "/" ? "" : pathname.slice(1);
|
|
const filename = path.resolve(root, relativePath || "fengshu-er-road-cesium-preview.html");
|
|
if (!filename.startsWith(`${root}${path.sep}`) && filename !== root) {
|
|
response.writeHead(403);
|
|
response.end();
|
|
return;
|
|
}
|
|
fs.stat(filename, (error, stat) => {
|
|
if (error || !stat.isFile()) {
|
|
response.writeHead(404);
|
|
response.end();
|
|
return;
|
|
}
|
|
response.writeHead(200, { "Content-Type": MIME_TYPES[path.extname(filename).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
if (request.method === "HEAD") response.end();
|
|
else fs.createReadStream(filename).pipe(response);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const root = args.root || process.cwd();
|
|
const upstream = args.upstream || process.env.V2X_UPSTREAM;
|
|
const wsUpstream = args.wsUpstream || process.env.V2X_WS_UPSTREAM;
|
|
const port = Number(args.port || process.env.PORT || 7862);
|
|
const host = args.host || process.env.HOST || "0.0.0.0";
|
|
if (!upstream) throw new Error("Set V2X_UPSTREAM or pass --upstream http://host:port");
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535");
|
|
const server = createV2xPreviewServer({ root, upstream, wsUpstream });
|
|
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream} (ws -> ${wsUpstream || upstream})`));
|
|
}
|
|
|
|
module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };
|