fix: render live V2X vehicles in Cesium preview

This commit is contained in:
2026-08-25 12:57:35 +08:00
parent bc845444bb
commit 25cf82e7c7
23 changed files with 198 additions and 58 deletions

View File

@@ -16,7 +16,7 @@ const {
} = require("./lib/scene-layers");
const { digest: glbDigest } = require("./glb-digest");
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { writePreviewVehicleLibrary, writePreviewV2xVehicleLibrary } = require("./lib/vehicle-library");
const { readTrafficSignals } = require("./lib/traffic-signals");
const {
cesiumPreviewHtml,
@@ -648,10 +648,11 @@ function writeVehicleModel(area) {
const outDir = path.dirname(area.outputs.vehicleModel);
const fileStem = path.basename(area.outputs.vehicleModel, ".gltf").replace(/-vehicle-car$/, "");
const models = writePreviewVehicleLibrary(outDir, fileStem);
const v2xModels = area.v2xPreview.enabled ? writePreviewV2xVehicleLibrary(outDir) : [];
// Preserve the existing output/manifest contract for old preview HTML.
fs.copyFileSync(path.join(outDir, models[0]), area.outputs.vehicleModel);
console.log(`Vehicle models: ${models.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`);
return models;
console.log(`Vehicle models: ${models.length + v2xModels.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`);
return [...models, ...v2xModels];
}
function sceneGeojsonRecords(area) {

View File

@@ -1437,7 +1437,10 @@
];
if (v2xOverlay?.state) {
lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X"));
lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size);
lines.push("Live vehicles: " + (v2xOverlay.state?.registry?.visibleSize ?? 0));
lines.push("Target WS: " + (v2xOverlay.state?.targetMessages ?? 0) + " messages / " + (v2xOverlay.state?.targetLastCount ?? 0) + " parsed");
lines.push("Target payload: " + (v2xOverlay.state?.targetPayloadShape || "waiting"));
if (v2xOverlay.state?.targetLastError) lines.push("Target error: " + v2xOverlay.state.targetLastError);
}
if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));

View File

@@ -5,6 +5,8 @@
const EARTH_A = 6378245.0;
const EARTH_EE = 0.00669342162296594323;
const TOKEN_KEY = "osm-asset-preview-v2x-token";
const LIVE_VEHICLE_HEIGHT_METERS = 1.2;
const LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90;
function createV2xCesiumOverlay(context) {
const settings = context.config.v2xPreview || {};
@@ -22,6 +24,13 @@
vehicleSource: "waiting",
parseFailures: 0,
missingModels: new Set(),
targetMessages: 0,
targetVehiclesReceived: 0,
targetLastCount: 0,
targetLastError: "",
targetPayloadType: "",
targetPayloadSample: "",
targetPayloadShape: "",
};
// This preview intentionally starts at the login gate. Do not revive a
// previous tab's token and begin REST/WebSocket traffic before sign-in.
@@ -47,6 +56,7 @@
if (state.phaseBinding.unbound.length) parts.push(`${state.phaseBinding.unbound.length} unbound`);
if (state.missingModels.size) parts.push(`${state.missingModels.size} models missing`);
if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`);
if (state.targetMessages) parts.push(`${state.targetVehiclesReceived} target vehicles`);
return parts.join(", ");
}
@@ -55,7 +65,6 @@
state.entities = [];
registry.list().forEach((record) => {
if (record.entity) context.viewer.entities.remove(record.entity);
if (record.trace) context.viewer.entities.remove(record.trace);
});
registry.clear();
state.linkEntitiesByPhase.clear();
@@ -264,12 +273,29 @@
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
}, (value) => {
const push = normalizeTargetPush(value);
if (!push.vehicles.length) { if (!isHeartbeat(value) && parseLoosePayload(value) === null) state.parseFailures += 1; return; }
useLiveVehicles();
registry.ingest(push.vehicles, push.interval);
registry.sweep();
renderVehicles();
if (isHeartbeat(value)) return;
state.targetMessages += 1;
try {
state.targetPayloadType = Object.prototype.toString.call(value);
state.targetPayloadSample = typeof value === "string" ? value.slice(0, 800) : String(value).slice(0, 800);
const payload = parseLoosePayload(value);
const data = payload?.data;
state.targetPayloadShape = data === null ? "data:null" : Array.isArray(data) ? `data:array(${data.length})` : typeof data === "object" ? `data:object(${Object.keys(data || {}).join(",")})` : `data:${typeof data}`;
const vehicles = targetVehiclesFrom(payload);
state.targetLastCount = vehicles.length;
if (!vehicles.length) {
if (payload === null) state.parseFailures += 1;
return;
}
state.targetVehiclesReceived += vehicles.length;
useLiveVehicles();
registry.ingest(vehicles, payload?.interval);
registry.sweep();
renderVehicles();
} catch (error) {
state.targetLastError = String(error?.message || error);
state.parseFailures += 1;
}
}, "Target vehicle WebSocket unavailable");
}
@@ -370,10 +396,8 @@
function syncVehicleEntity(viewer, record, state) {
const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, .65);
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, LIVE_VEHICLE_HEIGHT_METERS);
if (!record.entity) {
const tracePositions = [position];
record.tracePositions = tracePositions;
record.renderedPosition = position;
record.targetPosition = position;
record.sampleStart = Date.now();
@@ -390,35 +414,27 @@
() => vehicleOrientation(record.targetPosition, record.angle), false),
model: {
uri: vehicleModelUri(record, state),
scale: 0.9,
minimumPixelSize: 14,
maximumScale: 2.0,
// The full-intersection overview needs more than a couple of screen
// pixels for a car to remain distinguishable from road markings.
scale: 1.0,
minimumPixelSize: 28,
maximumScale: 8.0,
show: arrayValue(state.vehicleModels).length > 0,
},
label: { text: record.label, font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) },
});
record.trace = viewer.entities.add({
name: `${record.label} live trace`,
polyline: { positions: new Cesium.CallbackProperty(() => record.tracePositions, false), width: 3, material: record.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE },
});
} else {
record.renderedPosition = record.targetPosition || position;
record.targetPosition = position;
record.sampleStart = Date.now();
record.tracePositions.push(position);
if (record.tracePositions.length > 24) record.tracePositions.shift();
if (record.entity.label) record.entity.label.text = record.label;
}
// Stale vehicles are hidden, not removed, so the slot stays reusable.
record.entity.show = record.visible;
record.trace.show = record.visible;
if (!record.visible) record.tracePositions.length = 0;
}
function vehicleOrientation(position, angle) {
return Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(Number(angle) || 0), 0, 0),
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((Number(angle) || 0) + LIVE_VEHICLE_HEADING_OFFSET_DEGREES), 0, 0),
);
}
@@ -441,7 +457,7 @@
function relaxJson(text) {
return text
.replace(/'/g, "\"")
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, "$1\"$2\":")
.replace(/([{,]\s*)([A-Za-z_$][\w$]*|\d+)\s*:/g, "$1\"$2\":")
.replace(/,\s*([}\]])/g, "$1")
.replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2");
}
@@ -620,7 +636,21 @@
// dashboard's onConnected. A reconnect without this receives nothing.
settings.onOpen?.({ send });
};
socket.onmessage = (event) => settings.onMessage?.(event?.data);
socket.onmessage = (event) => {
const payload = event?.data;
// The upstream sometimes marks UTF-8 JSON as a binary WS frame. DevTools
// renders that Blob as JSON, but JSON.parse cannot consume a Blob; decode
// it before applying the same dashboard payload parser.
if (typeof Blob !== "undefined" && payload instanceof Blob) {
payload.text().then((text) => settings.onMessage?.(text)).catch((error) => settings.onError?.(error));
return;
}
if (typeof ArrayBuffer !== "undefined" && payload instanceof ArrayBuffer) {
settings.onMessage?.(new TextDecoder().decode(payload));
return;
}
settings.onMessage?.(payload);
};
socket.onerror = (event) => settings.onError?.(event);
socket.onclose = (event) => {
stopHeartbeat();
@@ -702,11 +732,11 @@
}).filter(Boolean));
}
// OBU vehicles use a fixed model; targets are keyed by type/subType. Mirrors
// useObuCars.ts ('car_obu.glb') and useTargetCars.ts (`${type}${subType}.glb`).
// The dashboard's CrossCars template renders OBU with 11.glb, while target
// vehicles retain their `${type}${subType}.glb` model selection.
function modelNameFor(vehicle) {
if (!vehicle) return "";
if (vehicle.kind === "obu") return "car_obu.glb";
if (vehicle.kind === "obu") return "11.glb";
return `${vehicle.type || 1}${vehicle.subType || 1}.glb`;
}
@@ -777,6 +807,7 @@
list: () => records.slice(),
get interval() { return interval; },
get size() { return records.length; },
get visibleSize() { return records.filter((record) => record.visible).length; },
};
}

View File

@@ -12,8 +12,12 @@ const VEHICLE_IDS = [
"truck_a03_001",
];
const LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "custom", "lowpoly_cars");
const V2X_LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "v2x-vehicles");
const TEXTURE_NAME = "color_512x512.jpg";
const REVERSED_MODEL_IDS = new Set(["truck_a03_001"]);
// These are the dashboard's published vehicle assets. OBU rendering uses
// 11.glb in CrossCars/index.vue; target vehicles use `${type}${subType}.glb`.
const V2X_VEHICLE_MODEL_NAMES = ["11.glb", "12.glb", "13.glb", "14.glb", "15.glb", "16.glb", "17.glb", "31.glb", "221.glb", "222.glb"];
function writePreviewVehicleLibrary(outDir, fileStem) {
const textureOutput = `${fileStem}-vehicle-texture.jpg`;
@@ -46,4 +50,21 @@ function writePreviewVehicleLibrary(outDir, fileStem) {
});
}
module.exports = { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary };
function writePreviewV2xVehicleLibrary(outDir) {
const targetDir = path.join(outDir, "v2x-vehicles");
fs.mkdirSync(targetDir, { recursive: true });
for (const modelName of V2X_VEHICLE_MODEL_NAMES) {
const source = path.join(V2X_LIBRARY_ROOT, modelName);
if (!fs.existsSync(source)) throw new Error(`V2X vehicle model not found: ${source}`);
fs.copyFileSync(source, path.join(targetDir, modelName));
}
return V2X_VEHICLE_MODEL_NAMES.map((modelName) => `v2x-vehicles/${modelName}`);
}
module.exports = {
VEHICLE_IDS,
REVERSED_MODEL_IDS,
V2X_VEHICLE_MODEL_NAMES,
writePreviewVehicleLibrary,
writePreviewV2xVehicleLibrary,
};

View File

@@ -7,7 +7,13 @@ const os = require("os");
const path = require("path");
const { cesiumPreviewHtml } = require("./lib/area-preview");
const { makeVehicleGltf } = require("./lib/vehicle-model");
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const {
VEHICLE_IDS,
REVERSED_MODEL_IDS,
V2X_VEHICLE_MODEL_NAMES,
writePreviewVehicleLibrary,
writePreviewV2xVehicleLibrary,
} = require("./lib/vehicle-library");
const {
allowedTurns,
buildVehicleRoute,
@@ -235,6 +241,14 @@ if (fs.existsSync(vehicleLibraryRoot)) {
}
}
const v2xVehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "v2x-vehicles");
if (fs.existsSync(v2xVehicleLibraryRoot)) {
const v2xLibraryDir = path.join(tempDir, "v2x-vehicle-library");
const models = writePreviewV2xVehicleLibrary(v2xLibraryDir);
assert.deepEqual(models, V2X_VEHICLE_MODEL_NAMES.map((name) => `v2x-vehicles/${name}`));
for (const model of models) assert.ok(fs.existsSync(path.join(v2xLibraryDir, model)));
}
const html = cesiumPreviewHtml(
"scene<&>.glb",
"scene.json",
@@ -298,6 +312,18 @@ assert.match(previewRuntime, /entry\.nodeKeys/,
assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets");
assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/,
"the overlay needs the native signal list to bind phases");
assert.match(previewRuntime, /registry\?\.visibleSize/,
"diagnostics must report the visible live-vehicle count");
assert.match(previewRuntime, /Target WS:/,
"diagnostics must expose target websocket receipt and parsing counts");
assert.match(previewRuntime, /Target payload:/,
"diagnostics must expose the payload shape when target pushes have no vehicles");
assert.match(v2xRuntime, /minimumPixelSize: 28/,
"live vehicles remain visible from the intersection overview");
assert.match(v2xRuntime, /LIVE_VEHICLE_HEIGHT_METERS = 1\.2/,
"live vehicles stay clear of the static road surface");
assert.match(v2xRuntime, /LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90/,
"live dashboard vehicle models receive their required clockwise heading correction");
// Overlay-side regressions: lamp codes, heartbeat, subscription frames,
// vehicle lifecycle and the removed out-of-scope request.

View File

@@ -11,6 +11,8 @@ const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.j
const context = {
window: { location: new URL("https://preview.example.test/areas/fengshu/") },
URL,
Blob,
TextDecoder,
console,
};
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
@@ -69,6 +71,9 @@ assert.equal(lampColorName(999), "other");
// --- AC7: loose payload parsing --------------------------------------------
assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 });
assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" });
assert.deepEqual(parseLoosePayload('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221}]}}'), {
data: { 1519: [{ id: "53122", longitude: 114.12871535072304, latitude: 30.46061866463221 }] },
}, "targetPosition uses unquoted numeric device-id keys");
assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null });
assert.equal(parseLoosePayload("{bad"), null);
assert.equal(parseLoosePayload(null), null);
@@ -161,6 +166,10 @@ assert.equal(obuPush.vehicle.id, "obu-c1");
assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{
id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2,
}]);
assert.equal(normalizeTargetPush(JSON.stringify({ data: { 1519: [{ id: "50089", longitude: 114.12876415699589, latitude: 30.46060781353787, type: 1, subType: 1 }] }, interval: 1000 })).vehicles.length, 1,
"the live targetPosition payload uses numeric object keys and string target ids");
assert.equal(normalizeTargetPush('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221,type:1,subType:1}]},interval:1000}').vehicles.length, 1,
"unquoted numeric device-id keys are accepted from the live targetPosition stream");
const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } }));
assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1");
assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500");
@@ -168,7 +177,7 @@ assert.equal(resolvePushInterval(undefined), 500);
assert.equal(resolvePushInterval(900), 900);
// --- AC8: model naming matches the dashboard --------------------------------
assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb");
assert.equal(modelNameFor({ kind: "obu" }), "11.glb");
assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb");
assert.equal(modelNameFor({ kind: "target" }), "11.glb");
@@ -178,10 +187,12 @@ const registry = createVehicleRegistry({ now: () => clock });
registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000);
assert.equal(registry.list().length, 1);
assert.equal(registry.list()[0].visible, true);
assert.equal(registry.visibleSize, 1);
assert.equal(registry.list()[0].duration, 1000);
assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5");
assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5");
assert.equal(registry.list()[0].visible, false);
assert.equal(registry.visibleSize, 0);
assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed");
clock = 5000;

View File

@@ -30,6 +30,10 @@ async function request(port, pathName, options = {}) {
assert.equal(isProxyPath("/package/manifest.json"), false);
assert.equal(upstreamPath("/api/facilities/api/sys/login?x=1"), "/facilities/api/sys/login?x=1");
assert.equal(upstreamPath("/websocket/network/ws/network/signal"), "/network/ws/network/signal");
// An upstream base path (deployments front the API as /dashboardApi) is kept.
assert.equal(upstreamPath("/api/facilities/api/sys/login", "/dashboardApi"), "/dashboardApi/facilities/api/sys/login");
assert.equal(upstreamPath("/websocket/network/ws/x", "/dashboardWebsocket/"), "/dashboardWebsocket/network/ws/x");
assert.equal(upstreamPath("/api/x", "/"), "/x");
const upstream = http.createServer((req, res) => {
let body = "";
@@ -59,6 +63,28 @@ async function request(port, pathName, options = {}) {
} finally {
await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]);
}
// Same upstream, but reached through a prefixed base path.
const prefixedUpstream = http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ path: req.url }));
});
const prefixedPort = await listen(prefixedUpstream);
const prefixedPreview = createV2xPreviewServer({
root: path.join(__dirname, "..", "outputs", "fengshu-er-road"),
upstream: `http://127.0.0.1:${prefixedPort}/dashboardApi`,
wsUpstream: `http://127.0.0.1:${prefixedPort}/dashboardWebsocket`,
});
const prefixedPreviewPort = await listen(prefixedPreview);
try {
const proxied = await request(prefixedPreviewPort, "/api/facilities/api/sys/login");
assert.deepEqual(JSON.parse(proxied.body), { path: "/dashboardApi/facilities/api/sys/login" });
} finally {
await Promise.all([
new Promise((resolve) => prefixedPreview.close(resolve)),
new Promise((resolve) => prefixedUpstream.close(resolve)),
]);
}
console.log("V2X preview server tests passed.");
})().catch((error) => {
console.error(error);

View File

@@ -34,11 +34,15 @@ function parseArgs(argv) {
return values;
}
function createV2xPreviewServer({ root, upstream }) {
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)) {
@@ -53,7 +57,7 @@ function createV2xPreviewServer({ root, upstream }) {
socket.destroy();
return;
}
proxyWebSocket(request, socket, head, upstreamUrl);
proxyWebSocket(request, socket, head, wsUpstreamUrl);
});
return server;
}
@@ -64,10 +68,15 @@ function isProxyPath(url) {
pathname === "/websocket" || pathname.startsWith("/websocket/");
}
function upstreamPath(url) {
// 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)(?=\/|$)/, "") || "/";
return `${pathname}${parsed.search}`;
const base = String(basePath || "").replace(/\/+$/, "");
return `${base}${pathname}${parsed.search}`;
}
function upstreamRequestOptions(request, upstreamUrl) {
@@ -76,7 +85,7 @@ function upstreamRequestOptions(request, upstreamUrl) {
hostname: upstreamUrl.hostname,
port: upstreamUrl.port || undefined,
method: request.method,
path: upstreamPath(request.url),
path: upstreamPath(request.url, upstreamUrl.pathname),
headers: { ...request.headers, host: upstreamUrl.host },
};
}
@@ -155,12 +164,13 @@ 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 });
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`));
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 };