UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

199 lines 8.44 kB
import { createServer } from 'node:http'; import { EventStream } from '@gemstack/ai-autopilot'; import { resolveDashboardBundle } from './dashboard/bundle.js'; import { serveClientBundle } from './dashboard/static.js'; import { makeTelefuncMount } from './dashboard/telefunc-serve.js'; import { requestPathname } from './request-path.js'; import { emptyProjectsProvider } from './dashboard/projects.js'; const RUN_PATH = /^\/r\/([^/]+)(\/[^?]*)?/; /** Start the hosted run relay. See {@link Relay}. */ export async function startRelay(opts = {}) { const host = opts.host ?? '0.0.0.0'; const port = opts.port ?? 4488; const maxBody = opts.maxBodyBytes ?? 256 * 1024; const maxRuns = opts.maxRuns ?? 200; const clientBundleDir = opts.clientBundleDir ?? (await resolveDashboardBundle()); // Insertion-ordered as an LRU: touching a run re-inserts it at the end, so the // first entry is always the least-recently-used and the eviction victim. const runs = new Map(); const run = (id) => { const existing = runs.get(id); if (existing) { runs.delete(id); runs.set(id, existing); // touch: move to most-recently-used return existing; } // Bound memory: evict the least-recently-used run before creating a new one. while (runs.size >= maxRuns) { const oldest = runs.keys().next().value; if (oldest === undefined) break; runs.get(oldest).stream.close(); // closing drains every viewer's Channel follower runs.delete(oldest); } const r = { stream: new EventStream() }; runs.set(id, r); return r; }; // The dashboard's Telefunc surface, mounted like the daemon's — but `onEvents` streams // the relay's own in-memory run (create-on-access, so a viewer can connect before the // publisher), and an empty projects provider neutralizes every file/registry RPC on // this public host. No `startRun`, so a start is never enabled here. const telefunc = makeTelefuncMount({ projects: emptyProjectsProvider(), eventsSource: id => run(id).stream }); const server = createServer((req, res) => handle(req, res, { run, maxBody, clientBundleDir, telefunc })); return new Promise((resolvePromise, rejectPromise) => { server.once('error', rejectPromise); server.listen(port, host, () => { server.removeListener('error', rejectPromise); const address = server.address(); const url = `http://${host}:${address.port}`; resolvePromise({ url, viewerUrl: id => `${url}/?run=${encodeURIComponent(id)}`, ingest: (id, event) => run(id).stream.push(event), runIds: () => [...runs.keys()], close: () => closeRelay(server, runs), }); }); }); } function handle(req, res, ctx) { // A public server: a request no URL parser accepts must get a 400, not crash the relay (#938). const pathname = requestPathname(req); if (pathname === undefined) { res.writeHead(400, { 'content-type': 'text/plain' }); res.end('bad request'); return; } if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok'); return; } // The dashboard's live event stream (and the rest of its RPC surface, neutralized). if (pathname === '/_telefunc' || pathname.startsWith('/_telefunc/')) { void ctx.telefunc(req, res); return; } const m = RUN_PATH.exec(pathname); if (m) { // A malformed escape in the id segment (`/r/%zz/`) must not throw out of the handler (#938). let id; try { id = decodeURIComponent(m[1]); } catch { res.writeHead(400, { 'content-type': 'text/plain' }); res.end('bad request'); return; } const rest = m[2] ?? ''; // Ingest is the one thing still under /r/:id/ — the publisher POSTs a run's events here. if (rest === '/publish') { if (req.method !== 'POST') { res.writeHead(405, { 'content-type': 'text/plain', allow: 'POST' }); res.end('method not allowed'); return; } ingestBody(req, res, ctx.run(id), ctx.maxBody); return; } // Any viewer GET of a run moved to the SPA at `/?run=:id`; redirect old links there. res.writeHead(302, { location: `/?run=${encodeURIComponent(id)}` }); res.end(); return; } // Everything else is the dashboard SPA (`/`, `/?run=:id`, `/assets/**`, SPA fallback). if (ctx.clientBundleDir) { void serveClientBundle(req, res, ctx.clientBundleDir); return; } res.writeHead(404, { 'content-type': 'text/plain' }); res.end('dashboard bundle not built'); } /** Read a JSON body (one event or an array) and push each event into the run's stream. */ function ingestBody(req, res, r, maxBody) { // Collect raw bytes and decode once at the end: a per-chunk `String(chunk)` corrupts a // multibyte UTF-8 codepoint split across two chunks, and the cap is in bytes, not the // UTF-16 code units a string length would count. const chunks = []; let bytes = 0; let tooBig = false; req.on('data', (chunk) => { if (tooBig) return; bytes += chunk.length; if (bytes > maxBody) { tooBig = true; res.writeHead(413, { 'content-type': 'text/plain' }); res.end('payload too large'); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { if (tooBig) return; let parsed; try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { res.writeHead(400, { 'content-type': 'text/plain' }); res.end('invalid json'); return; } const events = (Array.isArray(parsed) ? parsed : [parsed]); for (const event of events) r.stream.push(event); res.writeHead(202, { 'content-type': 'application/json' }); res.end(`{"ok":true,"received":${events.length}}`); }); } function closeRelay(server, runs) { for (const { stream } of runs.values()) stream.close(); // closing drains each Channel follower runs.clear(); return new Promise(resolvePromise => server.close(() => resolvePromise())); } /** * Forward a live run's {@link FrameworkEvent}s to a {@link startRelay} relay so * remote browsers can watch it. POSTs are serialized (chained) so the relay's * replay order matches the run, and best-effort: a failed POST is reported via * `onError` but never interrupts the run. */ export function relayPublisher(base, runId, onError, timeoutMs = 10_000) { const origin = base.replace(/\/+$/, ''); const root = `${origin}/r/${encodeURIComponent(runId)}`; let chain = Promise.resolve(); return { // Share the SPA viewer URL; events still POST to the ingest route under /r/:id/. url: `${origin}/?run=${encodeURIComponent(runId)}`, publish(event) { chain = chain.then(async () => { try { // Timeout so a relay that accepts but never responds can't wedge flush() // (awaited on shutdown) and hang the whole CLI on exit. const res = await fetch(`${root}/publish`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(event), signal: AbortSignal.timeout(timeoutMs), }); // fetch only throws on a transport failure, so a rejected event (413 over // the body cap, 400, or a URL that isn't a relay) would otherwise be silent. if (!res.ok) throw new Error(`relay answered ${res.status} ${res.statusText}`.trimEnd()); } catch (err) { onError?.(err); } }); }, flush() { return chain; }, }; } //# sourceMappingURL=relay.js.map