diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 6aa5c24..2236b25 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -6,6 +6,7 @@ export { SessionBridge } from './session-bridge.js'; export type { SessionBridgeConfig } from './session-bridge.js'; export { authenticateRequest } from './auth.js'; export type { AuthConfig, AuthResult } from './auth.js'; +export { serveStatic } from './static.js'; export { ErrorCode, isValidRequest, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index c40cccf..4af4e0d 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { WebSocket } from 'ws'; +import { resolve } from 'path'; import { GatewayServer } from './server.js'; import type { GatewayServerConfig } from './server.js'; import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js'; @@ -87,6 +88,7 @@ describe('GatewayServer integration', () => { toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], version: '0.1.0-test', + uiDir: resolve(import.meta.dirname, 'ui'), }); await server.start(); }); @@ -185,4 +187,30 @@ describe('GatewayServer integration', () => { expect(methods).toContain('tools.list'); expect(methods).toContain('tools.invoke'); }); + + // ── HTTP static file serving tests ──────────────────────────── + + it('serves index.html on HTTP GET /', async () => { + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/html'); + const body = await res.text(); + expect(body).toContain('Flynn'); + }); + + it('serves style.css on HTTP GET /style.css', async () => { + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/css'); + }); + + it('returns 404 for unknown HTTP path', async () => { + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`); + expect(res.status).toBe(404); + }); + + it('returns 404 for path traversal attempt', async () => { + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`); + expect(res.status).toBe(404); + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index f76d64a..ea62f14 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 = new Map(); @@ -95,14 +98,20 @@ export class GatewayServer { } async start(): Promise { + 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 { - 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((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((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 { + 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 { 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; diff --git a/src/gateway/static.ts b/src/gateway/static.ts new file mode 100644 index 0000000..18f1b01 --- /dev/null +++ b/src/gateway/static.ts @@ -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 = { + '.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 { + // 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; + } +} diff --git a/src/gateway/ui/chat.html b/src/gateway/ui/chat.html new file mode 100644 index 0000000..06eea8d --- /dev/null +++ b/src/gateway/ui/chat.html @@ -0,0 +1,329 @@ + + + + + + Flynn Chat + + + +
+
+

Flynn

+ Connecting... + Dashboard +
+
+
+ + +
+
+ + + + + diff --git a/src/gateway/ui/index.html b/src/gateway/ui/index.html new file mode 100644 index 0000000..d1641d8 --- /dev/null +++ b/src/gateway/ui/index.html @@ -0,0 +1,219 @@ + + + + + + Flynn Dashboard + + + +
+
+

Flynn Dashboard

+ Connecting... + Chat +
+
+ +
+
+

Sessions

+
Loading...
+
+
+

Tools

+
Loading...
+
+
+ + + diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css new file mode 100644 index 0000000..ff63bba --- /dev/null +++ b/src/gateway/ui/style.css @@ -0,0 +1,532 @@ +/* ========================================================================== + Flynn Gateway — Shared Dark Theme + Terminal-aesthetic dark theme used by chat.html and index.html (dashboard). + ========================================================================== */ + +/* ---------- CSS Custom Properties (Design Tokens) ---------- */ +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #1c2128; + --bg-input: #0d1117; + + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #6e7681; + + --accent: #58a6ff; + --accent-muted: rgba(88, 166, 255, 0.15); + + --error: #f85149; + --error-muted: rgba(248, 81, 73, 0.15); + + --success: #3fb950; + --success-muted: rgba(63, 185, 80, 0.15); + + --warning: #d29922; + + --border: #30363d; + --border-light: #21262d; + + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-size-base: 14px; + --font-size-sm: 12px; + --font-size-lg: 18px; + --font-size-xl: 24px; + + --line-height: 1.55; + --radius: 6px; + --radius-lg: 10px; + --container-max: 900px; + + --transition: 150ms ease; +} + +/* ---------- Reset ---------- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ---------- Base ---------- */ +html, +body { + height: 100%; + width: 100%; + overflow: hidden; /* Pages handle their own scrolling */ +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--font-size-base); + line-height: var(--line-height); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ---------- Links ---------- */ +a { + color: var(--accent); + text-decoration: none; + transition: opacity var(--transition); +} + +a:hover { + opacity: 0.8; + text-decoration: underline; +} + +/* ---------- Container ---------- */ +.container { + max-width: var(--container-max); + margin: 0 auto; + padding: 0 16px; +} + +/* ---------- Header ---------- */ +header { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +header h1 { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--text-primary); + margin-right: auto; +} + +header #status { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +header #status.connected { + color: var(--success); +} + +header #status.disconnected, +header #status.status-error { + color: var(--error); +} + +header #status.status-ok { + color: var(--success); +} + +/* ---------- Scrollbar (Webkit) ---------- */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* ========================================================================== + Chat-Specific Styles + ========================================================================== */ + +/* Chat layout — full viewport flex column */ +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: var(--container-max); + margin: 0 auto; +} + +/* Scrollable message area */ +.messages { + flex: 1 1 0; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Individual message bubble */ +.message { + padding: 10px 14px; + border-radius: var(--radius); + max-width: 85%; + word-wrap: break-word; + white-space: pre-wrap; + font-size: var(--font-size-base); + line-height: var(--line-height); +} + +/* User messages — right-aligned with accent tint */ +.message.user { + align-self: flex-end; + background-color: var(--accent-muted); + color: var(--text-primary); + border: 1px solid rgba(88, 166, 255, 0.25); +} + +/* Assistant messages — left-aligned with subtle bg */ +.message.assistant { + align-self: flex-start; + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-light); +} + +/* Error messages */ +.message.error { + align-self: flex-start; + background-color: var(--error-muted); + color: var(--text-primary); + border: 1px solid rgba(248, 81, 73, 0.35); +} + +/* Input area — fixed at the bottom of chat */ +.input-area { + display: flex; + flex-direction: row; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.input-area input { + flex: 1 1 0; + padding: 10px 12px; + background-color: var(--bg-input); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--font-size-base); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; + transition: border-color var(--transition); +} + +.input-area input::placeholder { + color: var(--text-muted); +} + +.input-area input:focus { + border-color: var(--accent); +} + +.input-area button { + padding: 10px 18px; + background-color: var(--accent); + color: var(--bg-primary); + font-family: var(--font-mono); + font-size: var(--font-size-base); + font-weight: 600; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: opacity var(--transition); +} + +.input-area button:hover { + opacity: 0.85; +} + +.input-area button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ========================================================================== + Dashboard-Specific Styles + ========================================================================== */ + +/* Dashboard layout — grid of cards */ +.dashboard { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; + padding: 24px 0; +} + +/* Section headings in dashboard page */ +.container > section { + padding: 16px 0; +} + +.container > section > h2 { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +/* Dashboard header — spans full width */ +.dashboard-header { + grid-column: 1 / -1; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; +} + +.dashboard-header h1 { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--text-primary); +} + +/* Card component */ +.card { + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + transition: border-color var(--transition); +} + +.card:hover { + border-color: var(--text-muted); +} + +.card h2 { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.card .value { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.card .label { + font-size: var(--font-size-sm); + color: var(--text-muted); + margin-top: 4px; +} + +/* Status indicators */ +.status-ok { + color: var(--success); +} + +.status-error { + color: var(--error); +} + +.status-warning { + color: var(--warning); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; +} + +.status-dot.ok { + background-color: var(--success); +} + +.status-dot.error { + background-color: var(--error); +} + +.status-dot.warning { + background-color: var(--warning); +} + +/* Session list */ +.session-list { + list-style: none; +} + +.session-list li { + padding: 8px 0; + border-bottom: 1px solid var(--border-light); + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.session-list li:last-child { + border-bottom: none; +} + +.session-list li a { + color: var(--accent); +} + +/* Tool list */ +.tool-list { + list-style: none; +} + +.tool-list li { + padding: 6px 0; + border-bottom: 1px solid var(--border-light); + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.tool-list li:last-child { + border-bottom: none; +} + +/* ========================================================================== + Utility Classes + ========================================================================== */ + +/* Tool event display — monospace block for tool_start / tool_end */ +.tool-event { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + background-color: var(--bg-tertiary); + color: var(--text-muted); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 8px 10px; + margin: 4px 0; + white-space: pre-wrap; + word-break: break-all; +} + +/* CSS spinner animation */ +.spinner { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Hidden utility */ +.hidden { + display: none !important; +} + +/* Text utilities */ +.text-muted { + color: var(--text-muted); +} + +.text-secondary { + color: var(--text-secondary); +} + +.text-accent { + color: var(--accent); +} + +.text-error { + color: var(--error); +} + +.text-success { + color: var(--success); +} + +.text-sm { + font-size: var(--font-size-sm); +} + +.text-lg { + font-size: var(--font-size-lg); +} + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: var(--font-size-sm); + border-radius: 12px; + font-weight: 600; +} + +.badge.ok { + background-color: var(--success-muted); + color: var(--success); +} + +.badge.error { + background-color: var(--error-muted); + color: var(--error); +} + +/* ========================================================================== + Responsive — Small Screens (<600px) + ========================================================================== */ +@media (max-width: 600px) { + .container { + padding: 0 8px; + } + + .chat-container { + max-width: 100%; + } + + .messages { + padding: 10px 8px; + } + + .message { + max-width: 95%; + padding: 8px 10px; + } + + .input-area { + padding: 8px; + } + + .input-area input { + padding: 8px 10px; + } + + .input-area button { + padding: 8px 14px; + } + + .dashboard { + grid-template-columns: 1fr; + padding: 16px 8px; + gap: 12px; + } + + .card { + padding: 12px; + } + + .card .value { + font-size: var(--font-size-lg); + } +}