import { createServer, type Server } from 'http' import { exec } from 'child_process' import { readFile } from 'fs/promises' import { existsSync } from 'fs' import { join, normalize, extname, dirname, sep } from 'path' import { fileURLToPath } from 'url' import { AddressInfo } from 'net' import { hostname } from 'os' import { loadPricing } from './models.js' import { buildMenubarPayloadForRange } from './usage-aggregator.js' import type { MenubarPayload } from './menubar-json.js' import { periodInfoFromQuery, UsageQueryError } from './cli-date.js' import { pullDevices, linkRemote } from './sharing/host.js' import { browse } from './sharing/discovery.js' import { loadOrCreateIdentity } from './sharing/identity.js' import { pairingCode } from './sharing/pairing.js' import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js' import { ShareController } from './sharing/share-controller.js' import { sanitizeForSharing } from './sharing/sanitize.js' import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js' import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js' function readBody(req: import('http').IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = '' req.on('data', (c) => { body += c if (body.length > 1_000_000) reject(new Error('request body too large')) }) req.on('end', () => resolve(body)) req.on('error', reject) }) } function writeJsonError(res: import('http').ServerResponse, status: number, error: string): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) res.end(JSON.stringify({ error })) } // Cap on the cached local payload, matched to the parser's own session cache // (parser.ts) so the assembled payload is never staler than its source data. const LOCAL_PAYLOAD_TTL_MS = 180_000 const HERE = dirname(fileURLToPath(import.meta.url)) // Locate the built React dashboard (dist/dash). Works both when running from a // published package (dist/dash next to the bundled CLI) and from source. function resolveDashDir(): string | null { const candidates = [ process.env['CODEBURN_DASH_DIR'], join(HERE, 'dash'), join(HERE, '..', 'dist', 'dash'), join(HERE, '..', 'dash', 'dist'), ].filter(Boolean) as string[] for (const dir of candidates) { if (existsSync(join(dir, 'index.html'))) return dir } return null } const CONTENT_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.ico': 'image/x-icon', '.map': 'application/json', } const NOT_BUILT_PAGE = '' + '' + '

Dashboard not built yet

' + '

Build the web UI once, then reload:

' + '
cd dash && npm install && npm run build
' + '

The CLI keeps serving the live data API in the meantime.

' function openBrowser(url: string): void { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open' try { exec(`${cmd} ${url}`) } catch { /* user can open it manually */ } } export async function runWebDashboard(opts: { period: string provider: string from?: string to?: string project: string[] exclude: string[] port: number open: boolean }): Promise { await loadPricing() const dashDir = resolveDashDir() // Sharing this device serves the SANITIZED aggregate (no project names/paths // or per-session detail), unlike the local /api/usage which shows everything. const shareGetUsage = async (q: { period?: string; from?: string; to?: string }) => { const periodInfo = periodInfoFromQuery(q, opts.period) return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) } const share = new ShareController(shareGetUsage) if (await loadShareAlways()) await share.start(true).catch(() => {}) // The server is long-lived, so cache this machine's parsed payload (the CLI // process cannot). Store the promise before any await so concurrent identical // requests collapse into one parse instead of racing. const localPayloadCache = new Map }>() const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise => { const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}` const hit = localPayloadCache.get(key) if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) return hit.payload const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period) const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false }) const now = Date.now() localPayloadCache.set(key, { at: now, payload }) for (const [k, v] of localPayloadCache) if (now - v.at >= LOCAL_PAYLOAD_TTL_MS) localPayloadCache.delete(k) void payload.catch(() => localPayloadCache.delete(key)) return payload } // Context trees re-read a whole transcript (up to 100MB), so cache each by // file version. Keyed on mtime: an active session invalidates itself. const contextTreeCache = new Map>() const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise => { const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}` const hit = contextTreeCache.get(key) if (hit) { // Re-insert so eviction is LRU rather than insertion order. contextTreeCache.delete(key) contextTreeCache.set(key, hit) return hit } const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref) contextTreeCache.set(key, tree) void tree.catch(() => contextTreeCache.delete(key)) while (contextTreeCache.size > 12) { const oldest = contextTreeCache.keys().next().value if (oldest === undefined) break contextTreeCache.delete(oldest) } return tree } // Embed this machine's prewarmed payload in index.html for an instant first // paint with no data round-trip. Only the local device is inlined: no remote // network wait, and paired devices stream in via the live fetch right after. const serveIndexHtml = async (res: import('http').ServerResponse, filePath: string): Promise => { const html = await readFile(filePath, 'utf8') const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to) const devices = [{ id: 'local', name: hostname(), local: true, payload }] // Escape every '<' so a device/model/project name can't close the \n