LocalLens
ウォークスルー

8. Bun サーバー

静的 UI を配信し、LocalLensApp をプロキシする小さな HTTP インターフェース。

src/server.ts はオプションのエントリポイントです。静的 UI を配信し、 5 つの JSON ルートを公開し、すべてを LocalLensApp に転送します。 設計上、ビジネスロジックはここに置きません。

起動

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

LocalLensApp の単一インスタンスをすべてのリクエストが共有します。 このクラスはストアとゲートウェイ以外に状態を持たず、ゲートウェイは 同時並行のモデル読み込みをすでに重複排除するので、これは安全です。

idleTimeout: 255(秒)は寛大な値です。コールドスタート時のモデル 読み込みストリームには時間がかかることがあります。

静的 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 は 2 行です。

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

Bun.file(...) はストリーミング可能な Blob 風オブジェクトを返し、 Response コンストラクタがそれをどう送るかを知っています。

API ルート

JSON ルートは 5 つです。

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

brain ID とアクションは小さな正規表現 1 つから取り出します。

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

重い仕事は 2 つのヘルパーが引き受けます。

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

エラーハンドリング

すべてのルートは 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);
}

エラーマッピングはこれだけです。AppError はステータスコードを保ったまま 通り抜けます。それ以外はメッセージ付きの 500 になります。ワークフローは すでに意味のある AppError を throw するので、サーバー側で形を整え直す 必要はありません。

優雅なシャットダウン

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

Ctrl+CLocalLensApp.close() が走り、QVAC を片付けます。これがないと、 プロセスを kill してもモデルランタイムがバックグラウンドに残ることがあります。

なぜルートをこれだけに絞っているのか?

UI にはこの 5 ルートで十分です。ルートを追加するのは安くつきますが、 役に立つ機能のほとんどは、新しい HTTP エンドポイントではなく LocalLensAppにあるべきです。サーバーの仕事は、すでにあるものを 公開することです。

次はブラウザ UI、ちょうどこれらのエンドポイントと やり取りする層です。

On this page