const express = require("express"); const { createProxyMiddleware } = require("http-proxy-middleware"); const { spawn } = require("child_process"); const app = express(); // http-proxy-middleware (via node http-proxy) doesn't correctly relay // permessage-deflate compression negotiation for proxied WebSocket upgrades, // which causes clients to reject compressed frames from the upstream node // with "Invalid WebSocket frame: RSV1 must be clear". Stripping the // extension request header prevents compression from being negotiated at // all, avoiding the mismatch entirely. function stripWsCompression(proxyReq) { proxyReq.removeHeader("sec-websocket-extensions"); } // Ethereum (Anvil) - serves JSON-RPC and WebSocket on the same port app.use("/eth", createProxyMiddleware({ target: "http://127.0.0.1:8545", changeOrigin: true, ws: true, pathRewrite: { "^/eth": "" }, on: { proxyReqWs: stripWsCompression } })); // Rootstock (Anvil) - serves JSON-RPC and WebSocket on the same port app.use("/rootstock", createProxyMiddleware({ target: "http://127.0.0.1:8546", changeOrigin: true, ws: true, pathRewrite: { "^/rootstock": "" }, on: { proxyReqWs: stripWsCompression } })); // Solana - JSON-RPC on 8899, pubsub/WebSocket on a separate port (8900 by // default), so WS upgrade requests are routed to the pubsub port instead. app.use("/solana", createProxyMiddleware({ target: "http://127.0.0.1:8899", changeOrigin: true, ws: true, pathRewrite: { "^/solana": "" }, router: (req) => ( req.headers.upgrade && req.headers.upgrade.toLowerCase() === "websocket" ? "http://127.0.0.1:8900" : "http://127.0.0.1:8899" ), on: { proxyReqWs: stripWsCompression } })); // Per-chain log files written by start.sh const LOG_FILES = { eth: "/data/logs/eth.log", rootstock: "/data/logs/rootstock.log", solana: "/data/logs/solana.log" }; app.get("/logs", (req, res) => { const links = Object.keys(LOG_FILES) .map((chain) => `
  • ${chain} (last 200 lines) | ${chain} (live)
  • `) .join("\n"); res.type("html").send(`

    DLT chain logs

    `); }); app.get("/logs/:chain", (req, res) => { const logFile = LOG_FILES[req.params.chain]; if (!logFile) { return res.status(404).send(`Unknown chain "${req.params.chain}". Valid: ${Object.keys(LOG_FILES).join(", ")}`); } const follow = req.query.follow === "1"; const lines = Math.max(1, Math.min(parseInt(req.query.tail, 10) || 200, 5000)); const tailArgs = follow ? ["-F", "-n", String(lines), logFile] : ["-n", String(lines), logFile]; const child = spawn("tail", tailArgs, { stdio: ["ignore", "pipe", "pipe"] }); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("X-Accel-Buffering", "no"); // defensive, in case a buffering proxy sits in front res.flushHeaders(); child.stdout.pipe(res, { end: false }); child.stderr.pipe(res, { end: false }); let heartbeat = null; let maxDuration = null; if (follow) { heartbeat = setInterval(() => { res.write(`\n# --- heartbeat ${new Date().toISOString()} ---\n`); }, 15000); // Failsafe: never keep a follow session open indefinitely, in case a // close event is somehow never delivered (e.g. odd proxy behavior). maxDuration = setTimeout(() => { res.end("\n# --- session limit reached, reconnect to keep following ---\n"); cleanup(); }, 45 * 60 * 1000); } else { // Snapshot mode: end the response once tail exits (EOF on a plain -n read). child.on("close", () => res.end()); } function cleanup() { if (heartbeat) clearInterval(heartbeat); if (maxDuration) clearTimeout(maxDuration); if (!child.killed) child.kill("SIGTERM"); } req.on("close", cleanup); res.on("close", cleanup); child.on("error", (err) => { if (!res.headersSent) res.status(500); res.end(`\n# error running tail: ${err.message}\n`); cleanup(); }); }); app.get("/", (req, res) => { res.send("DLT multi-chain RPC gateway running"); }); app.listen(7860, "0.0.0.0");