LocalLens
Walkthrough

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

src/server.ts
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:

MethodPathBodyReturns
GET/api/health{ ok: true, name: "LocalLens" }
GET/api/brains{ brains: Brain[] }
POST/api/brains/from-filesCreateBrainFromFilesInput{ brain: Brain } (status 201)
POST/api/brains/:id/chat{ question: string }ChatAnswer
DELETE/api/brains/:id204 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.

On this page