// Optional reverse proxy + static host for the built PWA. // // HERMES_TARGET=https://hermes.example.com PORT=8787 node server/proxy.mjs // // Why use it? A browser PWA served from a different origin than Hermes needs // the Hermes server to send CORS headers *and* allow credentialed cookies. // Serving the app from this proxy puts the app and the API on the same origin, // so the `hermes_session` cookie just works — and SSE streams pass through // unbuffered. It uses only Node built-ins (no dependencies). // import { createServer } from "node:http"; import { request as httpsRequest } from "node:https"; import { request as httpRequest } from "node:http"; import { readFile, stat } from "node:fs/promises"; import { extname, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = resolve(fileURLToPath(import.meta.url), ".."); const DIST = resolve(__dirname, "../dist"); const PORT = Number(process.env.PORT || 8787); const TARGET = process.env.HERMES_TARGET; // Optional: inject the API key server-side so it never reaches the browser. const API_KEY = process.env.HERMES_API_KEY; if (!TARGET) { console.error("Set HERMES_TARGET to your Hermes base URL, e.g."); console.error(" HERMES_TARGET=https://hermes.example.com node server/proxy.mjs"); process.exit(1); } const target = new URL(TARGET); const isHttps = target.protocol === "https:"; const doRequest = isHttps ? httpsRequest : httpRequest; const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".woff2": "font/woff2", ".map": "application/json", }; function proxy(req, res) { const headers = { ...req.headers }; headers.host = target.host; if (API_KEY && !headers.authorization) { headers.authorization = `Bearer ${API_KEY}`; } // Let the upstream decide; we forward the original origin for CSRF checks. const options = { protocol: target.protocol, hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), method: req.method, path: req.url, headers, }; const upstream = doRequest(options, (upRes) => { // Strip hop-by-hop headers. const outHeaders = { ...upRes.headers }; delete outHeaders["transfer-encoding"]; delete outHeaders["connection"]; // Ensure SSE is not buffered by any intermediary. if (String(outHeaders["content-type"] || "").includes("text/event-stream")) { outHeaders["x-accel-buffering"] = "no"; outHeaders["cache-control"] = "no-cache"; } res.writeHead(upRes.statusCode || 502, outHeaders); upRes.pipe(res); }); upstream.on("error", (err) => { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: `Upstream error: ${err.message}` })); }); req.pipe(upstream); } async function serveStatic(req, res) { let pathname = decodeURIComponent((req.url || "/").split("?")[0]); if (pathname === "/") pathname = "/index.html"; const filePath = normalize(join(DIST, pathname)); if (!filePath.startsWith(DIST)) { res.writeHead(403); res.end("Forbidden"); return; } try { const info = await stat(filePath); if (info.isDirectory()) throw new Error("dir"); const data = await readFile(filePath); const type = MIME[extname(filePath)] || "application/octet-stream"; const immutable = pathname.startsWith("/assets/"); res.writeHead(200, { "content-type": type, "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache", }); res.end(data); } catch { // SPA fallback. try { const html = await readFile(join(DIST, "index.html")); res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-cache" }); res.end(html); } catch { res.writeHead(404, { "content-type": "text/plain" }); res.end("Not found. Did you run `npm run build` first?"); } } } const server = createServer((req, res) => { const path = req.url || ""; if (path.startsWith("/api/") || path.startsWith("/v1/") || path.startsWith("/health")) { proxy(req, res); } else { void serveStatic(req, res); } }); server.listen(PORT, () => { console.log(`Hermes PWA proxy listening on http://localhost:${PORT}`); console.log(` serving: ${DIST}`); console.log(` proxying: /api/*, /v1/* -> ${target.origin}`); if (API_KEY) console.log(" injecting: Authorization: Bearer "); });