LocalLens

8. The Bun server

A small HTTP surface that serves the static UI and proxies LocalLensApp.

src/server.ts is an optional entry point. It serves the static UI, exposes five JSON routes, and forwards everything to LocalLensApp. No business logic here, by 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}`);

A single LocalLensApp is shared by every request. Safe, because the class is stateless beyond its store and gateway, and the gateway already deduplicates concurrent model loads.

idleTimeout: 255 (seconds) is generous. Model-loading streams can take a while on a cold start.

Static UI

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 is two lines:

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

Bun.file(...) returns a streaming Blob-like object that the Response constructor knows how to send.

API routes

Five JSON routes:

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

The brain ID and action come out of one small regex:

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

Two helpers do the heavy lifting:

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

Error handling

Every route is wrapped in a single 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);
}

That's the entire error mapping. AppErrors flow through with their status codes. Everything else becomes a 500 with the message. The workflow already throws meaningful AppErrors, so the server doesn't need to reshape them.

Graceful shutdown

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

Ctrl+C triggers LocalLensApp.close(), which tears QVAC down. Without this, killing the process can leave the model runtime alive in the background.

Why so few routes?

Five routes is enough for the UI. Adding a route is cheap, but most useful features belong inside LocalLensApp, not as new HTTP endpoints. The server's job is to expose what already exists.

Next: the browser UI, which talks to exactly these endpoints.

On this page