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
+59 -18
View File
@@ -1,7 +1,8 @@
import { WebSocketServer, WebSocket } from 'ws';
import { randomUUID } from 'crypto';
import type { IncomingMessage } from 'http';
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'http';
import { Router } from './router.js';
import { serveStatic } from './static.js';
import { SessionBridge } from './session-bridge.js';
import type { SessionBridgeConfig } from './session-bridge.js';
import { authenticateRequest } from './auth.js';
@@ -32,10 +33,12 @@ export interface GatewayServerConfig {
toolExecutor: ToolExecutor;
version?: string;
auth?: AuthConfig;
uiDir?: string;
}
export class GatewayServer {
private wss: WebSocketServer | null = null;
private httpServer: HttpServer | null = null;
private router: Router;
private sessionBridge: SessionBridge;
private connectionMap: Map<WebSocket, string> = new Map();
@@ -95,14 +98,20 @@ export class GatewayServer {
}
async start(): Promise<void> {
const host = this.config.host ?? '127.0.0.1';
const { port } = this.config;
return new Promise((resolve) => {
this.wss = new WebSocketServer({
port: this.config.port,
host: this.config.host ?? '127.0.0.1',
// Create HTTP server first — handles static file requests
this.httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
this.handleHttpRequest(req, res);
});
// Attach WebSocket server to the shared HTTP server (no separate port)
this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
// Auth check on upgrade
// Auth check on upgrade — only WS connections require auth
const authResult = authenticateRequest(req, this.config.auth ?? {});
if (!authResult.authenticated) {
ws.close(4001, authResult.error ?? 'Authentication failed');
@@ -111,34 +120,43 @@ export class GatewayServer {
this.handleConnection(ws, authResult.identity);
});
this.wss.on('listening', () => {
const addr = this.wss?.address();
const portStr = typeof addr === 'object' && addr ? `:${addr.port}` : '';
console.log(`Gateway WebSocket server listening on ${this.config.host ?? '127.0.0.1'}${portStr}`);
this.httpServer.listen(port, host, () => {
console.log(`Gateway server listening on ${host}:${port}`);
resolve();
});
});
}
async stop(): Promise<void> {
return new Promise((resolve) => {
// Close all WebSocket connections first
for (const [ws, connectionId] of this.connectionMap) {
this.sessionBridge.disconnect(connectionId);
ws.close(1001, 'Server shutting down');
}
this.connectionMap.clear();
// Close WSS first, then the underlying HTTP server
await new Promise<void>((resolve) => {
if (!this.wss) {
resolve();
return;
}
// Close all connections
for (const [ws, connectionId] of this.connectionMap) {
this.sessionBridge.disconnect(connectionId);
ws.close(1001, 'Server shutting down');
}
this.connectionMap.clear();
this.wss.close(() => {
this.wss = null;
resolve();
});
});
await new Promise<void>((resolve) => {
if (!this.httpServer) {
resolve();
return;
}
this.httpServer.close(() => {
this.httpServer = null;
resolve();
});
});
}
private handleConnection(ws: WebSocket, identity?: string): void {
@@ -161,6 +179,24 @@ export class GatewayServer {
});
}
/**
* Handle incoming HTTP requests.
* Delegates to serveStatic for UI files; returns 404 if no UI dir or file not found.
* Auth is NOT applied to HTTP requests — only to WS upgrade.
*/
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const uiDir = this.config.uiDir;
if (uiDir) {
const served = await serveStatic(req, res, uiDir);
if (served) return;
}
// No UI directory configured, or file not found
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
private async handleMessage(ws: WebSocket, connectionId: string, raw: string): Promise<void> {
const request = parseMessage(raw);
@@ -192,6 +228,11 @@ export class GatewayServer {
return this.wss;
}
/** Get the underlying HTTP server (for testing). */
getHttpServer(): HttpServer | null {
return this.httpServer;
}
/** Get the session bridge (for testing/debugging). */
getSessionBridge(): SessionBridge {
return this.sessionBridge;