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:
+59
-18
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user