Gateway: add browser control UI
This commit is contained in:
136
src/gateway/control-ui.ts
Normal file
136
src/gateway/control-ui.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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/";
|
||||
|
||||
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") {
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", UI_PREFIX);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith(UI_PREFIX)) return false;
|
||||
|
||||
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 = url.pathname.slice(UI_PREFIX.length);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user