The webserver serves the repo checkout directly as static files, so index.html at the repo root must be the production build (pointing at hashed /assets/*.js) rather than the dev template (/src/main.tsx), which was being served with an unrecognized application/octet-stream MIME type and blocked by the browser. - Move src/, public/, scripts/, server/, package.json, vite.config.ts, tsconfig.json, .env(.example), and the dev index.html into app/. - Add app/scripts/deploy.mjs: copies app/dist/* into the repo root after every `vite build`, cleaning up stale hashed filenames. - Wire it into `npm run build` (app/package.json). - Update run.sh to operate from app/, and .gitignore for the new layout. - Commit the current build output (index.html, assets/, manifest, sw.js, workbox-*.js, favicon.svg, icons/, LICENSE) to the repo root. - Update README with the new project layout and deployment model.
139 lines
4.6 KiB
JavaScript
139 lines
4.6 KiB
JavaScript
// 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 <HERMES_API_KEY>");
|
|
});
|