BunとElysiaJSで超高速REST APIを構築する:型安全なバリデーション、Eden Treaty、本番デプロイまで

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

アプローチの比較:Node.js + Express vs. Bun + ElysiaJS

10年以上にわたり、JavaScriptバックエンドの立ち上げといえばNode.jsにExpressまたはFastifyを組み合わせるのが定番でした。このスタックは今も数多くの本番システムを支えています。しかし現代のプロジェクトでは、TypeScriptのトランスパイル(tsx/esbuild)、環境変数の読み込み(dotenv)、テストランナー(jest/vitest)、スキーマバリデータ(zod)など、個別ツールの複雑な組み合わせに依存しがちです。

Bunは、JavaScriptCoreをベースとした単一のネイティブバイナリにこれらのツールを統合します。さらに、BunのHTTPプリミティブ専用に設計されたWebフレームワークであるElysiaJSと組み合わせることで、15ms未満のコールドスタート、ネイティブなTypeScript実行、静的型推論が実現します。合成ベンチマークでも実際のI/Oワークロードでも、Expressが約20,000 req/sにとどまるのに対し、Elysiaはベースラインメモリ60MB未満で毎秒150,000リクエスト以上を一貫して処理できます。

BunとElysiaJSを採用するメリットとデメリット

メリット

  • 圧倒的なスループットと低レイテンシ: Bunは低レベルのC++バインディング経由でHTTP処理を実行し、Expressの4倍から8倍のリクエスト処理能力(req/s)を発揮します。
  • 設定不要のTypeScriptサポート: tsconfig.jsonのパス設定やビルド手順に頭を悩ませることなく、.tsや.tsxファイルを直接実行できます。
  • ネイティブなTypeBoxバリデーション: TypeBoxのJITコンパイルによりスキーマがコンパイルされ、Joiなどの一般的なランタイムライブラリと比較して最大100倍高速にペイロードを検証します。
  • Eden Treatyによるエンドツーエンドの型安全性: サーバーの型定義をエクスポートするだけで、フロントエンド側で自動補完とコンパイル時エラーチェックを備えた完全型安全なAPIエンドポイントを利用できます。

デメリット

  • Node.jsのエッジケースにおける互換性: Bunは主要なnpmパッケージのほとんどをサポートしていますが、非公開のV8内部APIや古いC++ネイティブアドオンに依存するレガシーライブラリでは、互換性の問題が生じる場合があります。
  • ミドルウェアエコシステム: Elysiaのプラグインエコシステムは認証、OpenAPI、CORS、レート制限などをカバーしているものの、10年以上の歴史を持つExpressに比べるとサードパーティ製プラグインの層はまだ発展途上です。

推奨環境と事前準備

チュートリアルを進めるには、ローカル環境にBunがインストールされている必要があります。BunはmacOS、Linux、Windows Subsystem for Linux(WSL)向けの公式バイナリを提供しています。

# Linux / macOSにBunをインストール
curl -fsSL https://bun.sh/install | bash

# インストールを確認(バージョン1.1以上を推奨)
bun --version

コードエディタには、公式のElysiaおよびTailwind/TypeScript拡張機能を導入したVisual Studio Codeを使用すると、シームレスな自動補完とインラインスキーマ診断が利用できるためおすすめです。

ステップ・バイ・ステップ実装ガイド

ステップ1:プロジェクトの初期化

新規プロジェクトディレクトリを作成し、コアパッケージをインストールします。Bunはグローバルなハードリンクを使用するため、約50〜200ミリ秒でパッケージのインストールが完了します。

mkdir bun-elysia-api
cd bun-elysia-api
bun init -y
bun add elysia @elysiajs/cors @elysiajs/swagger

ステップ2:コアサーバーとRESTエンドポイントの構築

index.tsファイルを作成します。ここでは、Elysiaのスキーマビルダー(t)による高速ペイロードバリデーションを活用し、型定義されたGET・POSTルートを持つインメモリ商品カタログを定義します。

import { Elysia, t } from 'elysia';
import { swagger } from '@elysiajs/swagger';
import { cors } from '@elysiajs/cors';

interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

const products: Product[] = [
  { id: '1', name: 'メカニカルキーボード', price: 120, inStock: true },
  { id: '2', name: 'エルゴノミクスマウス', price: 65, inStock: false },
];

export const app = new Elysia()
  .use(cors())
  .use(swagger({
    documentation: {
      info: {
        title: '商品カタログAPI',
        version: '1.0.0',
      },
    },
  }))
  .get('/api/products', () => products)
  .get(
    '/api/products/:id',
    ({ params, error }) => {
      const product = products.find((item) => item.id === params.id);
      if (!product) {
        return error(404, { message: '商品が見つかりません' });
      }
      return product;
    },
    {
      params: t.Object({
        id: t.String(),
      }),
    }
  )
  .post(
    '/api/products',
    ({ body, set }) => {
      const newProduct: Product = {
        id: (products.length + 1).toString(),
        ...body,
      };
      products.push(newProduct);
      set.status = 201;
      return newProduct;
    },
    {
      body: t.Object({
        name: t.String({ minLength: 2 }),
        price: t.Number({ minimum: 0 }),
        inStock: t.Boolean(),
      }),
    }
  )
  .listen(3000);

console.log(`サーバーが起動しました: http://${app.server?.hostname}:${app.server?.port}`);
export type App = typeof app;

ホットリロードを有効にしてサーバーを起動します:

bun run --watch index.ts

ブラウザで http://localhost:3000/swagger を開きます。ElysiaはTypeBoxスキーマの制約から、完全なリクエスト・レスポンス定義を備えた対話型Swagger UIを自動生成します。

ステップ3:Eden Treatyを使ったフロントエンド接続

Eden Treatyを使うと、コード生成ツールやビルドスクリプト、手動でのOpenAPI同期を一切行わずに、バックエンドのルート定義をフロントエンドコードと同期できます。まず、クライアントのワークスペースにEdenをインストールします:

bun add @elysiajs/eden

エクスポートされた App 型定義をクライアントスクリプトにインポートし、Treatyクライアントを初期化します:

import { treaty } from '@elysiajs/eden';
import type { App } from './index';

const client = treaty<App>('http://localhost:3000');

async function fetchAndCreateProducts() {
  // 1. URLパスの自動補完に対応した完全型安全なGET呼び出し
  const { data: allProducts, error: listError } = await client.api.products.get();
  if (listError) {
    console.error('商品の取得に失敗しました:', listError.value);
    return;
  }
  console.log(`${allProducts.length}件の商品を読み込みました:`, allProducts);

  // 2. 型チェック付きPOST呼び出し:不正なフィールド型はTypeScriptにより即座に検出されます
  const { data: created, error: createError } = await client.api.products.post({
    name: 'USB-C ドッキングステーション',
    price: 89,
    inStock: true,
  });

  if (createError) {
    console.error('バリデーションエラー:', createError.value);
    return;
  }
  console.log(`商品を作成しました (ID: ${created.id})`);
}

fetchAndCreateProducts();

ステップ4:Dockerによる本番デプロイ

Bunアプリケーションをコンテナ化することで、極めて軽量なデプロイ成果物が完成します。標準的なAlpineベースのBunイメージは100MB未満で、約20ミリ秒で高速起動します。

プロジェクトのルートディレクトリに Dockerfile を作成します:

# マルチステージ本番ビルド
FROM oven/bun:1-alpine AS base
WORKDIR /app

# 依存関係のキャッシュとインストール
COPY package.json bun.lock* bun.lockb* ./
RUN bun install --frozen-lockfile --production

# ソースファイルのコピー
COPY . .

# セキュリティ確保のため非rootユーザーでコンテナを実行
USER bun
EXPOSE 3000
ENV NODE_ENV=production

CMD ["bun", "run", "index.ts"]

コンテナイメージをビルドし、ローカルでテストします:

docker build -t bun-elysia-api .
docker run -p 3000:3000 bun-elysia-api

これで、瞬時に起動し、ワイヤースピードで厳格なスキーマ検証を行い、クライアント側のボイラープレートを一切必要としないエンドツーエンド型安全なAPIが完成しました。

Share: