LocalLens
Walkthrough

8. El servidor Bun

Una superficie HTTP pequeña que sirve la UI estática y le hace proxy a LocalLensApp.

src/server.ts es un punto de entrada opcional. Sirve la UI estática, expone cinco rutas JSON y le pasa todo a LocalLensApp. Sin lógica de negocio acá, por diseño.

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}`);

Una sola instancia de LocalLensApp la comparten todos los requests. Es seguro porque la clase es stateless más allá de su store y gateway, y el gateway ya deduplica las cargas concurrentes de modelos.

idleTimeout: 255 (segundos) es generoso. Los streams de carga de modelos pueden tomar un rato en un 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 son dos líneas:

function fileResponse(filename: string, contentType: string): Response {
  return new Response(Bun.file(join(publicPath, filename)), {
    headers: { "content-type": contentType },
  });
}

Bun.file(...) devuelve un objeto tipo Blob con streaming que el constructor de Response sabe cómo enviar.

Rutas de la API

Cinco rutas JSON:

MétodoRutaBodyDevuelve
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

El ID del cerebro y la acción salen de un solo regex pequeño:

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);
  }
}

Dos helpers hacen el trabajo 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 });
}

Manejo de errores

Cada ruta está envuelta en un solo 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);
}

Ese es todo el mapeo de errores. Los AppError pasan con sus status codes. Todo lo demás se vuelve un 500 con el mensaje. El workflow ya arroja AppErrors con sentido, así que el servidor no necesita reformularlos.

Shutdown elegante

process.on("SIGINT", async () => {
  await app.close();
  process.exit(0);
});

Ctrl+C dispara LocalLensApp.close(), que desmonta QVAC. Sin esto, matar el proceso puede dejar el runtime del modelo vivo en background.

¿Por qué tan pocas rutas?

Cinco rutas alcanzan para la UI. Agregar una ruta es barato, pero las features útiles pertenecen dentro de LocalLensApp, no como nuevos endpoints HTTP. El trabajo del servidor es exponer lo que ya existe.

Lo que sigue: la UI del navegador, que habla con exactamente estos endpoints.

On this page