feat(gateway): add web UI with dashboard and chat interface

Refactor GatewayServer to serve HTTP and WebSocket on a shared
http.Server. Add static file serving with path traversal protection,
a dark-themed dashboard (system health, sessions, tools) and a
WebSocket chat interface with streaming tool events and markdown
rendering.
This commit is contained in:
William Valentin
2026-02-05 19:39:53 -08:00
parent f30a8bc318
commit 282a15d2b9
7 changed files with 1244 additions and 18 deletions
+76
View File
@@ -0,0 +1,76 @@
import { resolve, extname } from 'path';
import { readFile } from 'fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
/** Supported content types by file extension. */
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml',
};
/**
* Serve static files from a directory.
*
* - Only handles GET requests
* - Maps `/` to `/index.html`
* - Protects against path traversal (resolved path must be within uiDir)
* - Returns true if a file was served, false otherwise (caller should 404)
*/
export async function serveStatic(
req: IncomingMessage,
res: ServerResponse,
uiDir: string,
): Promise<boolean> {
// Only serve GET requests
if (req.method !== 'GET') {
return false;
}
// Parse the pathname from the request URL
const urlStr = req.url ?? '/';
let pathname: string;
try {
// Use URL constructor to safely parse; base doesn't matter for pathname extraction
const parsed = new URL(urlStr, 'http://localhost');
pathname = parsed.pathname;
} catch {
return false;
}
// Map root to index.html
if (pathname === '/') {
pathname = '/index.html';
}
// Check that the file extension is one we serve
const ext = extname(pathname);
const contentType = CONTENT_TYPES[ext];
if (!contentType) {
return false;
}
// Resolve the absolute path to the requested file
// Strip leading slash to get a relative path for resolve()
const relativePath = pathname.slice(1);
const resolvedUiDir = resolve(uiDir);
const filePath = resolve(resolvedUiDir, relativePath);
// Path traversal protection: ensure the resolved path is within uiDir
if (!filePath.startsWith(resolvedUiDir + '/') && filePath !== resolvedUiDir) {
return false;
}
// Try to read and serve the file
try {
const content = await readFile(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
return true;
} catch {
// File doesn't exist or can't be read — let the caller handle it
return false;
}
}