Files
osmWorkflow/scripts/v2x-preview-server.js

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