Files
osmWorkflow/scripts/v2x-preview-server.js
que01 bc845444bb fix: restore live V2X signal and vehicle fidelity in Cesium preview
The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.

- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
  The previous 2/3 reading made every real push fall through to red. The
  dictionary lives only in the overlay; the preview consumes normalized
  {nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
  document has no phaseNo, so the old lookup fell back to signal.id and
  never matched, leaving the dynamic assembly dark. Travel heading is
  recovered as faceHeadingDegrees + 180, per the generator's
  mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
  against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
  map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
  replaying their subscription frame. Without this the service dropped
  the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
  it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
  they no longer accumulate as ghosts. Models follow the dashboard's
  car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
  the payload is not guaranteed to be strict JSON. Failures are counted
  and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.

Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 09:20:50 +08:00

167 lines
6.1 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 }) {
const staticRoot = path.resolve(root);
const upstreamUrl = new URL(upstream);
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");
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, upstreamUrl);
});
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/");
}
function upstreamPath(url) {
const parsed = new URL(url, "http://preview.local");
const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/";
return `${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),
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 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 });
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`));
}
module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };