import { createServer, type Server } from 'node:http' import type { Socket } from 'node:net' import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' import { join, normalize, extname } from 'node:path' import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants' import { readManifest } from './manifest' import type { ArtifactEntry } from '../shared/types' import { getMetrics } from '../core/metrics' // ── SSE client tracking ── interface SseClient { id: number res: any } /** * Embedded HTTP server for serving artifact files (.html, .svg). * * Phase 2 additions: * - Gallery page at / with dark-themed card layout * - SSE /events endpoint for live reload * - Versioned file serving (name.v2.html) * - Port auto-increment on conflict */ export class ArtifactServer { private server: Server | null = null private port: number private artifactsDir: string private started = false private sseClients: SseClient[] = [] private sseIdCounter = 0 private sockets = new Set() constructor(artifactsDir: string, preferredPort: number) { this.artifactsDir = artifactsDir this.port = preferredPort } /** Notify all connected SSE clients to reload. Called after artifact changes. */ notifyReload(): void { for (const client of this.sseClients) { try { client.res.write('event: reload\ndata: {}\n\n') } catch { // Client disconnected — will be cleaned up on next request } } } async start(maxTries = 10): Promise { if (this.started) return this.port for (let attempt = 0; attempt < maxTries; attempt++) { const port = this.port + attempt try { await this.listenOn(port) this.port = port this.started = true return port } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw err } } throw new Error(`Artifact server could not find an available port after ${maxTries} attempts.`) } stop(): void { // Close all SSE connections for (const client of this.sseClients) { try { client.res.end() } catch { /* ignore */ } } this.sseClients = [] if (this.server) { this.server.close() this.server = null this.started = false } // `close()` only stops *accepting* — sockets already established (a browser's // keep-alive, an SSE stream) stay open and keep being served, so a "stopped" // server answers on the old port until the client hangs up. Destroy them. for (const socket of this.sockets) socket.destroy() this.sockets.clear() } getPort(): number { return this.port } isRunning(): boolean { return this.started } resolveFile(urlPath: string): { filePath: string; error?: string; status?: number } { const cleaned = urlPath.replace(/^\/+/, '') const normalized = normalize(cleaned) if (normalized.startsWith('..') || normalized.includes('/../')) { return { filePath: '', error: 'Path traversal rejected', status: 403 } } const fullPath = join(this.artifactsDir, normalized) const ext = extname(fullPath).toLowerCase() if (!ARTIFACT_ALLOWED_EXTENSIONS.includes(ext)) { return { filePath: '', error: `File type "${ext}" not served`, status: 403 } } return { filePath: fullPath } } // ── Private ── private listenOn(port: number): Promise { return new Promise((resolve, reject) => { const srv = createServer((req, res) => { this.handleRequest(req, res) }) srv.on('connection', (socket) => { this.sockets.add(socket) socket.on('close', () => this.sockets.delete(socket)) }) srv.on('error', reject) srv.listen(port, () => { this.server = srv resolve() }) }) } private handleRequest(req: { method?: string; url?: string }, res: any): void { if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }) res.end('Method Not Allowed') return } const urlPath = req.url || '/' // SSE endpoint if (urlPath === '/events') { this.handleSse(res) return } // Name-based SSE endpoint: GET /:name/sse if (urlPath.endsWith('/sse') && urlPath.length > 4) { const name = urlPath.slice(1, -4) // strip leading '/' and trailing '/sse' if (name.length > 0 && !name.includes('/')) { this.handleNameSse(name, res) return } } // Gallery page if (urlPath === '/' || urlPath === '/index.html') { this.serveGallery(res) return } // Metrics endpoint — Prometheus text format if (urlPath === '/metrics') { this.serveMetrics(res, 'prometheus') return } // Metrics endpoint — JSON format if (urlPath === '/metrics/json') { this.serveMetrics(res, 'json') return } // Artifact file this.serveArtifact(urlPath, res) } // ── Metrics ── private serveMetrics(res: any, format: 'prometheus' | 'json'): void { try { const metrics = getMetrics() if (format === 'json') { const body = JSON.stringify(metrics.toJSON(), null, 2) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }) res.end(body) } else { const body = metrics.toPrometheusText() res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*', }) res.end(body) } } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }) res.end(`Metrics error: ${err instanceof Error ? err.message : 'unknown'}`) } } // ── SSE ── private handleSse(res: any): void { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', }) const client: SseClient = { id: ++this.sseIdCounter, res } this.sseClients.push(client) // Send initial heartbeat res.write(':ok\n\n') // Clean up on close res.on('close', () => { this.sseClients = this.sseClients.filter((c) => c.id !== client.id) }) } /** * Per-artifact SSE stream: pushes the artifact's content to connected browsers * every 500ms, so a page can follow an artifact the AI rewrites in place. * * Resolves the file through `resolveFile` — the same coordinate the gallery * links to and the same traversal guard the static path uses — and 404s when no * artifact carries that name, instead of holding a stream open on a file that * does not exist. */ private handleNameSse(name: string, res: any): void { const entry = readManifest(this.artifactsDir).artifacts.find((a) => a.name === name) if (!entry) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end(`No artifact named "${name}"`) return } const ext = entry.type === 'svg' ? '.svg' : '.html' const { filePath, error, status } = this.resolveFile(`/${entry.sessionId}/${entry.name}${ext}`) if (error) { res.writeHead(status || 404, { 'Content-Type': 'text/plain' }) res.end(error) return } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', }) let last: string | null = null const interval = setInterval(() => { try { const content = readFileSync(filePath, 'utf-8') // Only on change: without this the stream re-sends the whole file every // 500ms per client for as long as the tab stays open. if (content !== last) { last = content res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`) } } catch { // Momentarily absent (archiving renames it) — skip this tick, try the next. } }, 500) res.on('close', () => clearInterval(interval)) } // ── Gallery Page ── private serveGallery(res: any): void { const manifest = readManifest(this.artifactsDir) const artifacts = manifest.artifacts const cards = artifacts.length === 0 ? `

🎨

No artifacts yet.

Ask the AI to create one with the Artifact tool.

` : artifacts.map((a) => this.artifactCard(a)).join('\n') const html = ` Mipham Code — Artifacts

🎨 Mipham Code Artifacts

Interactive visual output from your AI sessions

Server running
Port ${this.port}
${cards}
${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'} · Open in terminal: /artifact open <name> · Auto-refresh active
` res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', }) res.end(html) } private artifactCard(a: ArtifactEntry): string { const icon = a.type === 'svg' ? '🖼' : '📊' const sizeStr = a.size < 1024 ? `${a.size}B` : a.size < 1_048_576 ? `${(a.size / 1024).toFixed(1)}KB` : `${(a.size / 1_048_576).toFixed(1)}MB` const date = a.createdAt.slice(0, 16).replace('T', ' ') const versionInfo = a.versions && a.versions.length > 1 ? `${a.versions.length} versions` : '' return `
${icon}
${this.escapeHtml(a.name)}
${a.type.toUpperCase()} ${sizeStr} ${date} ${versionInfo}
session: ${a.sessionId}
` } // ── Artifact Serving ── private serveArtifact(urlPath: string, res: any): void { const { filePath, error, status } = this.resolveFile(urlPath) if (error) { res.writeHead(status || 403, { 'Content-Type': 'text/plain' }) res.end(error) return } if (!existsSync(filePath)) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Artifact not found') return } const ext = extname(filePath).toLowerCase() const mimeType = ext === '.svg' ? 'image/svg+xml' : 'text/html; charset=utf-8' const size = statSync(filePath).size res.writeHead(200, { 'Content-Type': mimeType, 'Content-Length': size, 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; img-src data: 'self';", 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-cache', }) const stream = createReadStream(filePath) stream.pipe(res) stream.on('error', () => { if (!res.headersSent) { res.writeHead(500) res.end('Stream error') } }) } private escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>') } }