// 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"); // Simple in-memory caches to speed up local dev const textCache = new Map(); // key: absolute path, value: { mtimeMs, body, contentType } let messagesAllCache = null; // cache parsed meldungen/messages_all.json 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 // Digital Inputs JSON if (rp === "CPL/SERVICE/digitalInputs.json") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "digitalInputsMockData.json" ); if (exists(mockPath)) return streamRaw(res, mockPath); } // Analog Inputs JSON if (rp === "CPL/SERVICE/analogInputs.json") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "analogInputsMockData.json" ); if (exists(mockPath)) return streamRaw(res, mockPath); } // Kabelüberwachung main JS (kueData.js) if (rp === "CPL/SERVICE/kueData.js") { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "kabelueberwachungMockData.js" ); if (exists(mockPath)) return streamRaw(res, mockPath); } // Kabelüberwachung Knotenpunkte slot JS files if (rp.startsWith("CPL/SERVICE/knotenpunkte/") && rp.endsWith(".js")) { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", rp.replace(/^CPL\//, "") ); if (exists(mockPath)) return streamRaw(res, mockPath); } // Kabelüberwachung Knotenpunkte via kueDataKnoten (case: Service) if ( (rp.startsWith("CPL/Service/kueDataKnoten/") || rp.startsWith("CPL/SERVICE/kueDataKnoten/")) && rp.endsWith(".js") ) { const m = rp.match(/kueDataKnoten\/kueData(\d+)\.js$/); if (m) { const slot = Number(m[1]); const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "knotenpunkte", `slot${slot}.js` ); if (exists(mockPath)) return streamRaw(res, mockPath); } } 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); } // Some builds request last messages via Start.js if (rp === "CPL/SERVICE/Start.js" || rp === "CPL/Service/Start.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); } // TDR reference curves (if mocks exist) if (rp.startsWith("CPL/tdr-reference-curves/")) { const mockPath = path.join( process.cwd(), "mocks", "device-cgi-simulator", rp.replace(/^CPL\//, "") ); if (exists(mockPath)) return streamRaw(res, mockPath); } // Generic: any CPL/SERVICE/*.js -> try MockData.js or .js from mocks const m = rp.match(/^CPL\/(SERVICE|Service)\/([A-Za-z0-9_-]+)\.js$/); if (m) { const name = m[2]; const candidates = [ path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", `${name}MockData.js` ), path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", `${name}.js` ), ]; for (const p of candidates) { if (exists(p)) return streamRaw(res, p); } } return false; } function streamRaw(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const type = contentTypeByExt(ext); // cache headers for static assets const headers = { "Content-Type": type }; if (getRelFromRoot(filePath).startsWith("_next/")) { headers["Cache-Control"] = "public, max-age=31536000, immutable"; } else { headers["Cache-Control"] = "public, max-age=60"; } res.writeHead(200, headers); 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); const serviceDI = /CPL\/SERVICE\/digitalInputs\.(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 (serviceDI) { const json = path.join( process.cwd(), "mocks", "device-cgi-simulator", "SERVICE", "digitalInputsMockData.json" ); data = exists(json) ? readJsonSafe(json) : {}; const getter = (arrName, idx) => { const arr = data[arrName]; return Array.isArray(arr) && idx >= 0 && idx < arr.length ? String(arr[idx]) : undefined; }; replacer = (m, key) => { // Map DES/DEN/DEC/DEF/DEG/DEI/DEZ/DEA nn (80..83) to CSV lines const m2 = key.match(/^(DES|DEN|DEC|DEF|DEG|DEI|DEZ|DEA)(\d{2})$/i); if (m2) { const prefix = m2[1].toUpperCase(); const num = parseInt(m2[2], 10); const idx = num - 80; // 80..83 => 0..3 if (idx >= 0 && idx < 4) { const mapName = { DES: "win_de_state", DEN: "win_de_label", DEC: "win_de_counter", DEF: "win_de_time_filter", DEG: "win_de_weighting", DEI: "win_de_invert", DEZ: "win_de_counter_active", DEA: "win_de_offline", }[prefix]; if (mapName) { const v = getter(mapName, idx); if (v !== undefined) return v; } } } 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); const rel = getRelFromRoot(filePath); const shouldReplace = isTextExt(ext) && (ext === ".html" || /(^|\/)CPL\//.test(rel)); // Try cache first (only for text files) if (shouldReplace) { try { const stat = fs.statSync(filePath); const cached = textCache.get(filePath); if (cached && cached.mtimeMs === stat.mtimeMs) { const headers = { "Content-Type": type, "Cache-Control": "no-cache" }; res.writeHead(200, headers); res.end(cached.body); return; } let content = fs.readFileSync(filePath, "utf8"); // quick check to avoid heavy replace if no tokens if (content.includes("<%=")) { content = resolvePlaceholders(filePath, content); } const headers = { "Content-Type": type, "Cache-Control": "no-cache" }; res.writeHead(200, headers); res.end(content); textCache.set(filePath, { mtimeMs: stat.mtimeMs, body: content, contentType: type, }); return; } catch { // fall through to stream as binary if read fails } } const headers = { "Content-Type": type }; if (rel.startsWith("_next/")) headers["Cache-Control"] = "public, max-age=31536000, immutable"; else headers["Cache-Control"] = "public, max-age=60"; res.writeHead(200, headers); 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] : ""; // Lightweight API for messages used in local mocks if (pathname === "/api/cpl/messages") { const fromDate = url.searchParams.get("fromDate"); const toDate = url.searchParams.get("toDate"); if (!fromDate || !toDate) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: "fromDate und toDate sind erforderlich" }) ); return; } try { if (!messagesAllCache) { const p = path.join( process.cwd(), "mocks", "device-cgi-simulator", "meldungen", "messages_all.json" ); const raw = fs.readFileSync(p, "utf8"); messagesAllCache = JSON.parse(raw); } const from = new Date(fromDate); const to = new Date(toDate); to.setHours(23, 59, 59, 999); const filtered = messagesAllCache.filter((m) => { const t = new Date(m.t); return t >= from && t <= to; }); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache", }); res.end(JSON.stringify(filtered)); } catch { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Interner Serverfehler" })); } return; } // CPL? mapping: /CPL?/CPL/... -> /CPL/... and service commands 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); } // Service commands: implement minimal subset for messages // Example: Service/ae.ACP&MSS1=YYYY;MM;DD;YYYY;MM;DD;All if (/^Service\/ae\.ACP/i.test(q) && q.includes("MSS1=")) { try { // Extract date parameters const m = q.match(/MSS1=([^&]+)/); const parts = m && m[1] ? m[1].split(";") : []; if (parts.length >= 7) { const [fy, fm, fd, ty, tm, td] = parts; const from = new Date(`${fy}-${fm}-${fd}`); const to = new Date(`${ty}-${tm}-${td}`); to.setHours(23, 59, 59, 999); if (!messagesAllCache) { const p = path.join( process.cwd(), "mocks", "device-cgi-simulator", "meldungen", "messages_all.json" ); const raw = fs.readFileSync(p, "utf8"); messagesAllCache = JSON.parse(raw); } const filtered = messagesAllCache.filter((m) => { const t = new Date(m.t); return t >= from && t <= to; }); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache", }); res.end(JSON.stringify(filtered)); return; } } catch { // fall-through } } // Other 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"); });