Bunワークスペース活用:モノレポプロジェクトの効率的な管理
Bunワークスペースとは
Bunワークスペースは、単一のリポジトリ内で複数のパッケージを管理するための機能です。npm/yarn/pnpmのワークスペースと互換性がありながら、Bunの高速なパフォーマンスを活かした開発体験を提供します。
主な特徴
- 高速インストール: Bunの高速パッケージマネージャー
- シンボリックリンク: ローカルパッケージの自動リンク
- 依存関係の最適化: 共通依存関係の重複排除
- TypeScript統合: パッケージ間の型安全性
- 互換性: 既存のnpm/yarn/pnpmプロジェクトからの移行が容易
プロジェクトセットアップ
基本構造の作成
# プロジェクトディレクトリを作成
mkdir my-monorepo && cd my-monorepo
bun init -y
# ワークスペース用のディレクトリ構造
mkdir -p packages/ui packages/utils packages/api apps/web apps/mobile
package.jsonの設定
{
"name": "my-monorepo",
"version": "1.0.0",
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "bun --filter './apps/*' dev",
"build": "bun --filter './packages/*' build && bun --filter './apps/*' build",
"test": "bun test",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.3.0"
}
}
パッケージの作成
UIコンポーネントライブラリ
cd packages/ui
bun init -y
// packages/ui/package.json
{
"name": "@my-monorepo/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": {
"import": "./dist/button.js",
"types": "./dist/button.d.ts"
}
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --format esm --splitting",
"dev": "bun build src/index.ts --outdir dist --format esm --watch",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"react": "^18.2.0",
"typescript": "^5.3.0"
}
}
// packages/ui/src/button.tsx
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
}
export function Button({
variant = "primary",
size = "md",
className = "",
children,
...props
}: ButtonProps) {
const baseStyles = "rounded font-medium transition-colors";
const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-600 text-white hover:bg-gray-700",
outline: "border-2 border-blue-600 text-blue-600 hover:bg-blue-50"
};
const sizeStyles = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg"
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
// packages/ui/src/index.ts
export { Button, type ButtonProps } from "./button";
export { Input, type InputProps } from "./input";
export { Card, type CardProps } from "./card";
// packages/ui/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
ユーティリティパッケージ
// packages/utils/package.json
{
"name": "@my-monorepo/utils",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./string": "./dist/string.js",
"./date": "./dist/date.js"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --format esm",
"test": "bun test"
}
}
// packages/utils/src/string.ts
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + "...";
}
// packages/utils/src/date.ts
export function formatDate(date: Date, format: string = "YYYY-MM-DD"): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return format
.replace("YYYY", String(year))
.replace("MM", month)
.replace("DD", day);
}
export function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export function diffDays(date1: Date, date2: Date): number {
const ms = date2.getTime() - date1.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
// packages/utils/src/index.ts
export * from "./string";
export * from "./date";
export * from "./array";
export * from "./object";
APIパッケージ
// packages/api/package.json
{
"name": "@my-monorepo/api",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "bun build src/index.ts --outdir dist --format esm",
"dev": "bun --watch src/server.ts",
"test": "bun test"
},
"dependencies": {
"@my-monorepo/utils": "workspace:*",
"hono": "^3.12.0"
},
"devDependencies": {
"@types/bun": "latest"
}
}
// packages/api/src/client.ts
import type { ApiResponse, User, Post } from "./types";
export class ApiClient {
constructor(private baseUrl: string) {}
async get<T>(path: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${path}`);
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
}
async post<T>(path: string, data: unknown): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
}
// ユーザーAPI
async getUser(id: string): Promise<User> {
const response = await this.get<User>(`/users/${id}`);
return response.data;
}
async getPosts(): Promise<Post[]> {
const response = await this.get<Post[]>("/posts");
return response.data;
}
}
// packages/api/src/server.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
const app = new Hono();
app.use("*", cors());
app.use("*", logger());
app.get("/health", (c) => {
return c.json({ status: "ok", timestamp: Date.now() });
});
app.get("/users/:id", (c) => {
const id = c.req.param("id");
return c.json({
data: {
id,
name: "John Doe",
email: "john@example.com"
}
});
});
export default app;
アプリケーションの作成
Webアプリ(React + Vite)
// apps/web/package.json
{
"name": "web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@my-monorepo/ui": "workspace:*",
"@my-monorepo/utils": "workspace:*",
"@my-monorepo/api": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}
// apps/web/src/App.tsx
import { useState, useEffect } from "react";
import { Button } from "@my-monorepo/ui";
import { capitalize, formatDate } from "@my-monorepo/utils";
import { ApiClient } from "@my-monorepo/api";
const api = new ApiClient("http://localhost:3000");
function App() {
const [user, setUser] = useState<any>(null);
useEffect(() => {
api.getUser("123").then(setUser);
}, []);
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-4">
{capitalize("welcome to my app")}
</h1>
{user && (
<div className="mb-4">
<p>User: {user.name}</p>
<p>Email: {user.email}</p>
<p>Date: {formatDate(new Date())}</p>
</div>
)}
<div className="space-x-2">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
</div>
</div>
);
}
export default App;
// apps/web/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});
ワークスペースコマンド
パッケージのインストール
# すべてのワークスペースの依存関係をインストール
bun install
# 特定のワークスペースに依存関係を追加
bun add react --cwd packages/ui
# ルートに開発依存関係を追加
bun add -D prettier
ワークスペース間の依存関係
# apps/webからpackages/uiを参照
cd apps/web
bun add @my-monorepo/ui@workspace:*
スクリプトの実行
# すべてのワークスペースでビルド
bun run build
# 特定のワークスペースでスクリプト実行
bun --filter @my-monorepo/ui build
# パターンマッチング
bun --filter './packages/*' build
bun --filter './apps/*' dev
# 並列実行
bun --filter './packages/*' --parallel build
共通設定の管理
TypeScript設定の共有
// tsconfig.base.json(ルート)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
ESLint設定の共有
// eslint.config.js(ルート)
import js from "@eslint/js";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default [
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json"
}
},
plugins: {
"@typescript-eslint": tsPlugin
},
rules: {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn"
}
}
];
ビルドパイプライン
ルートレベルのビルドスクリプト
// scripts/build.ts
import { $ } from "bun";
const packages = [
"packages/utils",
"packages/ui",
"packages/api"
];
const apps = [
"apps/web"
];
console.log("Building packages...");
for (const pkg of packages) {
console.log(`Building ${pkg}...`);
await $`cd ${pkg} && bun run build`;
}
console.log("\nBuilding apps...");
for (const app of apps) {
console.log(`Building ${app}...`);
await $`cd ${app} && bun run build`;
}
console.log("\nBuild completed!");
タスクランナーの統合
// package.json
{
"scripts": {
"build": "bun run scripts/build.ts",
"dev": "bun --filter './apps/*' dev",
"test": "bun test --recursive",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "bun run typecheck:packages && bun run typecheck:apps",
"typecheck:packages": "bun --filter './packages/*' typecheck",
"typecheck:apps": "bun --filter './apps/*' typecheck",
"clean": "bun run clean:packages && bun run clean:apps",
"clean:packages": "rm -rf packages/*/dist packages/*/node_modules",
"clean:apps": "rm -rf apps/*/dist apps/*/node_modules"
}
}
依存関係の管理
バージョンの統一
// package.json(ルート)
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.3.0",
"prettier": "^3.1.0",
"eslint": "^8.56.0"
}
}
パッケージのホイスティング
// bunfig.toml
[install]
# 共通依存関係をルートにホイスト
hoisting = true
# ピアデペンデンシーの自動インストール
auto-install-peers = true
# キャッシュ設定
cache-dir = ".bun-cache"
テスト戦略
パッケージごとのテスト
// packages/utils/src/string.test.ts
import { describe, test, expect } from "bun:test";
import { capitalize, slugify, truncate } from "./string";
describe("String utilities", () => {
test("capitalize", () => {
expect(capitalize("hello")).toBe("Hello");
expect(capitalize("HELLO")).toBe("HELLO");
expect(capitalize("")).toBe("");
});
test("slugify", () => {
expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("Hello World!")).toBe("hello-world");
expect(slugify(" Hello World ")).toBe("hello-world");
});
test("truncate", () => {
expect(truncate("Hello World", 5)).toBe("Hello...");
expect(truncate("Hello", 10)).toBe("Hello");
});
});
統合テスト
// tests/integration/api.test.ts
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import app from "@my-monorepo/api/server";
describe("API Integration", () => {
let server: any;
beforeAll(() => {
server = Bun.serve({
port: 3001,
fetch: app.fetch
});
});
afterAll(() => {
server.stop();
});
test("GET /health", async () => {
const response = await fetch("http://localhost:3001/health");
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe("ok");
});
test("GET /users/:id", async () => {
const response = await fetch("http://localhost:3001/users/123");
const data = await response.json();
expect(response.status).toBe(200);
expect(data.data.id).toBe("123");
});
});
CI/CD設定
GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Type check
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build
パフォーマンス最適化
選択的ビルド
// scripts/selective-build.ts
import { $ } from "bun";
import { readFileSync } from "fs";
// 変更されたファイルを検出
const changedFiles = await $`git diff --name-only HEAD~1`.text();
const files = changedFiles.split("\n").filter(Boolean);
// 影響を受けるパッケージを特定
const affectedPackages = new Set<string>();
for (const file of files) {
if (file.startsWith("packages/")) {
const pkg = file.split("/")[1];
affectedPackages.add(`packages/${pkg}`);
} else if (file.startsWith("apps/")) {
const app = file.split("/")[1];
affectedPackages.add(`apps/${app}`);
}
}
// 影響を受けるパッケージのみビルド
for (const pkg of affectedPackages) {
console.log(`Building ${pkg}...`);
await $`cd ${pkg} && bun run build`;
}
キャッシュの活用
// bunfig.toml
[install]
cache-dir = ".bun-cache"
[build]
# ビルドキャッシュを有効化
cache = true
ベストプラクティス
1. パッケージの分離原則
- 各パッケージは単一責任を持つ
- 循環依存を避ける
- パブリックAPIを明確に定義
2. バージョン管理
# changesets を使用した自動バージョニング
bun add -D @changesets/cli
bunx changeset init
3. ドキュメント
各パッケージにREADME.mdを配置:
# @my-monorepo/ui
UIコンポーネントライブラリ
## インストール
\`\`\`bash
bun add @my-monorepo/ui
\`\`\`
## 使い方
\`\`\`tsx
import { Button } from "@my-monorepo/ui";
<Button variant="primary">Click me</Button>
\`\`\`
まとめ
Bunワークスペースを使うことで:
- 高速なパッケージ管理とビルド
- 型安全なパッケージ間の連携
- 効率的な依存関係の管理
- 統一された開発環境
適切な構造設計、共通設定の活用、CI/CDの整備により、大規模モノレポでも快適な開発体験を実現できます。