138 lines
3.9 KiB
TypeScript
138 lines
3.9 KiB
TypeScript
import fs from "node:fs";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const _UI_PREFIX = "/ui/";
|
|
const ROOT_PREFIX = "/";
|
|
|
|
function resolveControlUiRoot(): string | null {
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const candidates = [
|
|
// Running from dist: dist/gateway/control-ui.js -> dist/control-ui
|
|
path.resolve(here, "../control-ui"),
|
|
// Running from source: src/gateway/control-ui.ts -> dist/control-ui
|
|
path.resolve(here, "../../dist/control-ui"),
|
|
// Fallback to cwd (dev)
|
|
path.resolve(process.cwd(), "dist", "control-ui"),
|
|
];
|
|
for (const dir of candidates) {
|
|
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function contentTypeForExt(ext: string): string {
|
|
switch (ext) {
|
|
case ".html":
|
|
return "text/html; charset=utf-8";
|
|
case ".js":
|
|
return "application/javascript; charset=utf-8";
|
|
case ".css":
|
|
return "text/css; charset=utf-8";
|
|
case ".json":
|
|
case ".map":
|
|
return "application/json; 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";
|
|
case ".txt":
|
|
return "text/plain; charset=utf-8";
|
|
default:
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function respondNotFound(res: ServerResponse) {
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not Found");
|
|
}
|
|
|
|
function serveFile(res: ServerResponse, filePath: string) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
res.setHeader("Content-Type", contentTypeForExt(ext));
|
|
// Static UI should never be cached aggressively while iterating; allow the
|
|
// browser to revalidate.
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.end(fs.readFileSync(filePath));
|
|
}
|
|
|
|
function isSafeRelativePath(relPath: string) {
|
|
if (!relPath) return false;
|
|
const normalized = path.posix.normalize(relPath);
|
|
if (normalized.startsWith("../") || normalized === "..") return false;
|
|
if (normalized.includes("\0")) return false;
|
|
return true;
|
|
}
|
|
|
|
export function handleControlUiHttpRequest(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): boolean {
|
|
const urlRaw = req.url;
|
|
if (!urlRaw) return false;
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
res.statusCode = 405;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Method Not Allowed");
|
|
return true;
|
|
}
|
|
|
|
const url = new URL(urlRaw, "http://localhost");
|
|
|
|
if (url.pathname === "/ui" || url.pathname.startsWith("/ui/")) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
|
|
const root = resolveControlUiRoot();
|
|
if (!root) {
|
|
res.statusCode = 503;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end(
|
|
"Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).",
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const rel = (() => {
|
|
if (url.pathname === ROOT_PREFIX) return "";
|
|
if (url.pathname.startsWith("/assets/")) return url.pathname.slice(1);
|
|
return url.pathname.slice(1);
|
|
})();
|
|
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
|
const fileRel = requested || "index.html";
|
|
if (!isSafeRelativePath(fileRel)) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
|
|
const filePath = path.join(root, fileRel);
|
|
if (!filePath.startsWith(root)) {
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|
|
|
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
serveFile(res, filePath);
|
|
return true;
|
|
}
|
|
|
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
|
const indexPath = path.join(root, "index.html");
|
|
if (fs.existsSync(indexPath)) {
|
|
serveFile(res, indexPath);
|
|
return true;
|
|
}
|
|
|
|
respondNotFound(res);
|
|
return true;
|
|
}
|