8. O servidor Bun
Uma pequena superfície HTTP que serve a UI estática e faz proxy para o LocalLensApp.
src/server.ts é um ponto de entrada opcional. Serve a UI
estática, expõe cinco rotas JSON e encaminha tudo para
LocalLensApp. Sem lógica de negócio aqui, por design.
Boot
const app = new LocalLensApp();
const publicPath = join(import.meta.dir, "ui");
const port = Number(Bun.env.PORT ?? 3000);
const server = Bun.serve({
port,
idleTimeout: 255,
async fetch(request) {
/* … routes … */
},
});
console.log(`LocalLens is running at http://localhost:${server.port}`);Um único LocalLensApp é compartilhado por todo request. Safe, porque
a classe é stateless além do store e do gateway, e o
gateway já deduplica loads de modelo concorrentes.
idleTimeout: 255 (segundos) é generoso. Streams de carregamento
de modelo podem demorar num cold start.
UI estática
if (url.pathname === "/" && request.method === "GET") {
return fileResponse("index.html", "text/html; charset=utf-8");
}
if (url.pathname === "/assets/styles.css" && request.method === "GET") {
return fileResponse("styles.css", "text/css; charset=utf-8");
}
if (url.pathname === "/assets/app.js" && request.method === "GET") {
return fileResponse("app.js", "text/javascript; charset=utf-8");
}fileResponse são duas linhas:
function fileResponse(filename: string, contentType: string): Response {
return new Response(Bun.file(join(publicPath, filename)), {
headers: { "content-type": contentType },
});
}Bun.file(...) retorna um objeto streaming tipo Blob que o
construtor de Response sabe enviar.
Rotas de API
Cinco rotas JSON:
| Method | Path | Body | Returns |
|---|---|---|---|
GET | /api/health | — | { ok: true, name: "LocalLens" } |
GET | /api/brains | — | { brains: Brain[] } |
POST | /api/brains/from-files | CreateBrainFromFilesInput | { brain: Brain } (status 201) |
POST | /api/brains/:id/chat | { question: string } | ChatAnswer |
DELETE | /api/brains/:id | — | 204 No Content |
O ID do brain e a ação saem de um regex pequeno:
const brainMatch = /^\/api\/brains\/([^/]+)(?:\/([^/]+))?$/.exec(url.pathname);
if (brainMatch) {
const [, brainId, action] = brainMatch;
if (!action && request.method === "DELETE") {
await app.deleteBrain(brainId);
return noContent();
}
if (action === "chat" && request.method === "POST") {
const body = await readJson<{ question: string }>(request);
const result = await app.askBrain(brainId, body.question);
return json(result);
}
}Dois helpers fazem o trabalho pesado:
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}Tratamento de erros
Toda rota é envelopada em um único
try { … } catch (error) { return errorResponse(error); }:
function errorResponse(error: unknown): Response {
if (error instanceof AppError) {
return json({ error: error.message }, error.status);
}
return json({ error: error instanceof Error ? error.message : String(error) }, 500);
}Esse é o mapeamento de erros inteiro. AppErrors fluem com
seus status codes. Tudo mais vira 500 com a
mensagem. O workflow já lança AppErrors significativos, então
o servidor não precisa reformatar.
Shutdown gracioso
process.on("SIGINT", async () => {
await app.close();
process.exit(0);
});Ctrl+C dispara LocalLensApp.close(), que desmonta o QVAC.
Sem isso, matar o processo pode deixar o runtime do modelo
vivo em background.
Por que tão poucas rotas?
Cinco rotas é o suficiente para a UI. Adicionar uma rota é barato, mas
a maior parte das features úteis pertence dentro do LocalLensApp, não como
novos endpoints HTTP. O trabalho do servidor é expor o que já
existe.
A seguir: a UI do navegador, que conversa com exatamente esses endpoints.