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
JavaScript
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