/**
* BID Console — light web UI for partner demos.
*
* Single Node http server (no Express, no framework, no build step).
* Three things it does:
* 1. Browse past runs from output/run-*.json — list + detail pages
* 2. Run a fresh question live, streaming the pipeline's stdout via
* Server-Sent Events as the agents work
* 3. Code browser over src/, scripts/, web/ with syntax highlighting
*
* npm run web # default port 4178
* PORT=3000 npm run web
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
homepage,
runDetail,
codePage,
notFoundPage,
} from './views.js';
import { listRuns, loadRunById } from './runs.js';
import { buildCodeTree, readCodeFile } from './code-tree.js';
import { startRun, attachStream, getRunStatus } from './runner.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const STATIC_DIR = path.join(__dirname, 'static');
const PORT = Number(process.env.PORT) || 4178;
function send(res: ServerResponse, status: number, contentType: string, body: string | Buffer): void {
res.writeHead(status, { 'Content-Type': contentType, 'Cache-Control': 'no-store' });
res.end(body);
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
send(res, status, 'application/json; charset=utf-8', JSON.stringify(body));
}
function sendHtml(res: ServerResponse, status: number, body: string): void {
send(res, status, 'text/html; charset=utf-8', body);
}
async function serveStatic(req: IncomingMessage, res: ServerResponse, rel: string): Promise<boolean> {
const safe = path.normalize(rel).replace(/^([./\\])+/, '');
const full = path.join(STATIC_DIR, safe);
if (!full.startsWith(STATIC_DIR) || !existsSync(full)) return false;
const ext = path.extname(full).toLowerCase();
const types: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
};
const buf = await readFile(full);
send(res, 200, types[ext] ?? 'application/octet-stream', buf);
return true;
}
async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const method = req.method ?? 'GET';
const pathname = url.pathname;
/* Static files */
if (pathname.startsWith('/static/')) {
const ok = await serveStatic(req, res, pathname.slice('/static/'.length));
if (ok) return;
}
/* Pages */
if (method === 'GET' && pathname === '/') {
const runs = await listRuns();
return sendHtml(res, 200, homepage(runs));
}
if (method === 'GET' && pathname.startsWith('/run/')) {
const id = pathname.slice('/run/'.length);
const run = await loadRunById(id);
if (!run) return sendHtml(res, 404, notFoundPage(`Run "${id}" not found.`));
return sendHtml(res, 200, runDetail(run));
}
if (method === 'GET' && pathname === '/code') {
const tree = await buildCodeTree(ROOT);
const filePath = url.searchParams.get('path');
let file: { path: string; content: string; language: string } | null = null;
if (filePath) {
const f = await readCodeFile(ROOT, filePath);
if (f) file = f;
}
return sendHtml(res, 200, codePage(tree, file));
}
/* JSON API */
if (method === 'GET' && pathname === '/api/runs') {
return sendJson(res, 200, await listRuns());
}
if (method === 'GET' && pathname.startsWith('/api/run/') && pathname.endsWith('/stream')) {
const id = pathname.slice('/api/run/'.length, -'/stream'.length);
return attachStream(id, res);
}
if (method === 'GET' && pathname.startsWith('/api/run/')) {
const id = pathname.slice('/api/run/'.length);
/* Live run status (if still active) takes precedence over disk
* lookup so the client can poll the latest state. */
const liveStatus = getRunStatus(id);
if (liveStatus) return sendJson(res, 200, liveStatus);
const run = await loadRunById(id);
if (!run) return sendJson(res, 404, { error: 'not-found' });
return sendJson(res, 200, run);
}
if (method === 'POST' && pathname === '/api/run') {
const body = await readBody(req);
let parsed: { question?: string } = {};
try { parsed = JSON.parse(body); } catch { /* ignore */ }
const question = (parsed.question ?? '').trim();
if (!question) return sendJson(res, 400, { error: 'question required' });
if (!process.env.ANTHROPIC_API_KEY) {
return sendJson(res, 503, { error: 'server has no ANTHROPIC_API_KEY set; cannot run live demos' });
}
const runId = await startRun(question, ROOT);
return sendJson(res, 200, { runId });
}
if (method === 'GET' && pathname === '/api/code/tree') {
return sendJson(res, 200, await buildCodeTree(ROOT));
}
if (method === 'GET' && pathname === '/api/code/file') {
const filePath = url.searchParams.get('path') ?? '';
const file = await readCodeFile(ROOT, filePath);
if (!file) return sendJson(res, 404, { error: 'not found or outside allowed roots' });
return sendJson(res, 200, file);
}
return sendHtml(res, 404, notFoundPage(`No route for ${method} ${pathname}.`));
}
const server = createServer((req, res) => {
handle(req, res).catch(err => {
console.error('[web] unhandled', err);
if (!res.writableEnded) {
try { sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); } catch { /* */ }
}
});
});
server.listen(PORT, () => {
const keyState = process.env.ANTHROPIC_API_KEY ? 'set' : 'NOT SET (live runs will be rejected)';
console.log(`BID Console listening on http://localhost:${PORT}`);
console.log(` ANTHROPIC_API_KEY: ${keyState}`);
console.log(` Past runs read from: ${path.relative(process.cwd(), path.join(ROOT, 'output'))}/`);
});