import { Hono } from 'hono' import type { ProcessManager } from './manager.js' import { MANAGER_HTML } from './html.js' function logReq(method: string, path: string, status?: number) { const ts = new Date().toISOString() const suffix = status != null ? ` -> ${status}` : '' console.log(`[${ts}] [http] ${method} ${path}${suffix}`) } export function createApp(manager: ProcessManager): Hono { const app = new Hono() app.get('/', c => { logReq('GET', '/', 200) return c.html(MANAGER_HTML) }) app.get('/api/instances', c => { const list = manager.list() logReq('GET', '/api/instances', 200) return c.json(list) }) app.post('/api/instances', async c => { let body: { group?: string; command?: string } try { body = await c.req.json<{ group?: string; command?: string }>() } catch { logReq('POST', '/api/instances', 400) return c.json({ error: 'invalid JSON body' }, 400) } if (!body.group?.trim() || !body.command?.trim()) { logReq('POST', '/api/instances', 400) return c.json({ error: 'group and command are required' }, 400) } const instance = manager.create(body.group.trim(), body.command.trim()) logReq('POST', `/api/instances group=${body.group}`, 201) return c.json( { id: instance.id, group: instance.group, command: instance.command, status: instance.status, pid: instance.pid, startTime: instance.startTime, exitCode: instance.exitCode, }, 201, ) }) app.post('/api/instances/:id/stop', c => { const id = c.req.param('id') const inst = manager.get(id) if (!inst) { logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 404) return c.json({ error: 'not found' }, 404) } if (inst.status !== 'running') { logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 400) return c.json({ error: 'not running' }, 400) } manager.stop(inst.id) logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 200) return c.json({ ok: true }) }) app.delete('/api/instances/:id', c => { const id = c.req.param('id') const inst = manager.get(id) if (!inst) { logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 404) return c.json({ error: 'not found' }, 404) } if (inst.status === 'running') { logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 400) return c.json({ error: 'still running' }, 400) } manager.remove(inst.id) logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 200) return c.json({ ok: true }) }) app.get('/api/instances/:id/logs', c => { const id = c.req.param('id') const inst = manager.get(id) if (!inst) { logReq('GET', `/api/instances/${id.slice(0, 8)}/logs`, 404) return c.json({ error: 'not found' }, 404) } logReq('GET', `/api/instances/${id.slice(0, 8)}/logs SSE`) const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder() const send = (data: string) => { try { controller.enqueue(encoder.encode(data)) } catch { // stream closed } } // send historical logs for (const log of inst.logs) { send(`data: ${JSON.stringify(log)}\n\n`) } // subscribe to new logs const unsub = manager.subscribe(inst.id, entry => { send(`data: ${JSON.stringify(entry)}\n\n`) }) // keepalive every 15s const keepalive = setInterval(() => { send(': keepalive\n\n') }, 15000) const cleanup = () => { unsub() clearInterval(keepalive) logReq('SSE', `/api/instances/${id.slice(0, 8)}/logs closed`) try { controller.close() } catch { // already closed } } c.req.raw.signal.addEventListener('abort', cleanup, { once: true }) }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }, }) }) // Catch-all: log unmatched routes for debugging app.all('*', c => { logReq(c.req.method, c.req.path, 404) return c.json({ error: 'not found', path: c.req.path }, 404) }) return app }