#!/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 };