// Local simulator that serves the exported build and replaces CGI placeholders from mocks/. // - Serves the exported "out" folder. // - Replaces placeholders like <%=SAV00%>, <%=SAN01%>, DANxx/DASxx etc. using mocks/device-cgi-simulator/**. // - Supports mapping /CPL?/path to /path in the export. import http from "http"; import fs from "fs"; import path from "path"; const PORT = process.env.PORT ? Number(process.env.PORT) : 3030; const ROOT = path.join(process.cwd(), "out"); function exists(p) { try { fs.accessSync(p, fs.constants.F_OK); return true; } catch { return false; } } function contentTypeByExt(ext) { switch (ext) { case ".html": return "text/html; charset=utf-8"; case ".js": return "application/javascript; charset=utf-8"; case ".json": return "application/json; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".txt": return "text/plain; charset=utf-8"; case ".svg": return "image/svg+xml"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".ico": return "image/x-icon"; default: return "application/octet-stream"; } } function isTextExt(ext) { return [".html", ".js", ".json", ".css", ".txt", ".svg"].includes(ext); } function readJsonSafe(fp) { try { return JSON.parse(fs.readFileSync(fp, "utf8")); } catch { return {}; } } function getRelFromRoot(filePath) { try { const r = path.relative(ROOT, filePath).split(path.sep).join("/"); return r.startsWith("../") ? r : r; } catch { return ""; } } function tryServeSpecialMocks(res, relPath) { const rp = relPath.replace(/^\/+/, ""); // Serve ready JS/JSON mocks for some routes if (rp === "CPL/SERVICE/systemVoltTemp.js") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "systemVoltTempMockData.js" ); if (exists(mockPath)) return streamRaw(res, mockPath); } if (rp === "CPL/SERVICE/last20Messages.js") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "last20MessagesMockData.js" ); if (exists(mockPath)) return streamRaw(res, mockPath); } if (rp === "CPL/meldungen/messages_all.json") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "meldungen", "messages_all.json" ); if (exists(mockPath)) return streamRaw(res, mockPath); } if (rp.startsWith("CPL/chartsData/analogInputs/")) { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", rp.replace(/^CPL\//, "") ); if (exists(mockPath)) return streamRaw(res, mockPath); } return false; } function streamRaw(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const type = contentTypeByExt(ext); res.writeHead(200, { "Content-Type": type }); fs.createReadStream(filePath).pipe(res); return true; } function resolvePlaceholders(filePath, content) { const rel = getRelFromRoot(filePath); const serviceSystem = /CPL\/SERVICE\/(System\.(js|json)|system\.(js|json))$/i.test(rel); const serviceOpcua = /CPL\/SERVICE\/opcua\.(js|json)$/i.test(rel); const serviceDO = /CPL\/SERVICE\/digitalOutputs\.(js|json)$/i.test(rel); let data = {}; let replacer = (m) => m; if (serviceSystem || /\.html$/i.test(rel)) { const baseDir = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE" ); const sysJson = exists(path.join(baseDir, "SystemMockData.json")) ? path.join(baseDir, "SystemMockData.json") : path.join(baseDir, "systemMockData.json"); data = exists(sysJson) ? readJsonSafe(sysJson) : {}; const map = { SAV00: "win_appVersion", SAN01: "win_deviceName", SEM01: "win_mac1", SEI01: "win_ip", SES01: "win_subnet", SEG01: "win_gateway", SCL01: "win_cplInternalTimestamp", STP01: "win_ntp1", STP02: "win_ntp2", STP03: "win_ntp3", STT00: "win_ntpTimezone", STA00: "win_ntpActive", SOS01: "win_opcState", SOC01: "win_opcSessions", SON01: "win_opcName", }; replacer = (m, key) => { if (Object.prototype.hasOwnProperty.call(data, key)) return String(data[key]); const prop = map[key]; if (prop && Object.prototype.hasOwnProperty.call(data, prop)) return String(data[prop]); return m; }; } else if (serviceOpcua) { const json = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "opcuaMockData.json" ); data = exists(json) ? readJsonSafe(json) : {}; const map = { SOS01: "win_opcUaZustand", SOC01: "win_opcUaActiveClientCount", SON01: "win_opcUaNodesetName", }; replacer = (m, key) => { if (Object.prototype.hasOwnProperty.call(data, key)) return String(data[key]); const prop = map[key]; if (prop && Object.prototype.hasOwnProperty.call(data, prop)) return String(data[prop]); return m; }; } else if (serviceDO) { const json = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "digitalOutputsMockData.json" ); data = exists(json) ? readJsonSafe(json) : {}; replacer = (m, key) => { // DASnn -> win_da_state[n-1] if (/^DAS\d{2}$/i.test(key)) { const idx = parseInt(key.slice(3), 10) - 1; const arr = data.win_da_state; if (Array.isArray(arr) && idx >= 0 && idx < arr.length) return String(arr[idx]); } // DANnn -> string label (ensure proper quoting in JS array) if (/^DAN\d{2}$/i.test(key)) { const idx = parseInt(key.slice(3), 10) - 1; const arr = data.win_da_bezeichnung; if (Array.isArray(arr) && idx >= 0 && idx < arr.length) return JSON.stringify(arr[idx] ?? ""); } return m; }; } else { // Default: no change replacer = (m) => m; } return content.replace(/<%=([A-Z0-9_]+)%>/g, replacer); } function sendFileWithReplace(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const type = contentTypeByExt(ext); if (isTextExt(ext)) { try { let content = fs.readFileSync(filePath, "utf8"); content = resolvePlaceholders(filePath, content); res.writeHead(200, { "Content-Type": type }); res.end(content); return; } catch { // fall through to stream as binary if read fails } } res.writeHead(200, { "Content-Type": type }); fs.createReadStream(filePath).pipe(res); } function notFound(res) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not Found"); } const server = http.createServer((req, res) => { try { const url = new URL(req.url, `http://localhost:${PORT}`); const pathname = decodeURIComponent(url.pathname); const rawQuery = req.url.includes("?") ? req.url.split("?")[1] : ""; // CPL? mapping: /CPL?/CPL/... -> /CPL/... if (pathname === "/CPL" && rawQuery) { const q = decodeURIComponent(rawQuery); if (q.startsWith("/")) { const rel = q.replace(/^\/+/, ""); // Special mocks if (tryServeSpecialMocks(res, rel)) return; const target = path.join(ROOT, rel); if (exists(target) && fs.statSync(target).isFile()) { return sendFileWithReplace(res, target); } if (exists(target) && fs.statSync(target).isDirectory()) { const indexFile = path.join(target, "index.html"); if (exists(indexFile)) return sendFileWithReplace(res, indexFile); } return notFound(res); } // Non-file commands: just 200 OK res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); res.end("OK"); return; } // Static serving from export let filePath = path.join(ROOT, pathname); if (pathname.endsWith("/")) { filePath = path.join(filePath, "index.html"); if (exists(filePath)) return sendFileWithReplace(res, filePath); } // Special mocks outside CPL? const relStatic = pathname.replace(/^\/+/, ""); if (tryServeSpecialMocks(res, relStatic)) return; if (exists(filePath) && fs.statSync(filePath).isFile()) { return sendFileWithReplace(res, filePath); } const htmlVariant = filePath + ".html"; if (exists(htmlVariant)) { return sendFileWithReplace(res, htmlVariant); } // Fallback: out/index.html const fallback = path.join(ROOT, "index.html"); if (exists(fallback)) return sendFileWithReplace(res, fallback); return notFound(res); } catch (err) { res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); res.end( "Internal Server Error\n" + (err && err.stack ? err.stack : String(err)) ); } }); server.listen(PORT, () => { console.log(`Local CPL simulator running on http://localhost:${PORT}`); console.log(`Serving from: ${ROOT}`); console.log("Replacing CGI placeholders using mocks/ where available"); });