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
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étodo | Ruta | Body | Devuelve |
|---|---|---|---|
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 |
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.