始めるには、HTML ファイルをインポートして Bun.serve() の routes オプションに渡します。
import { serve } from "bun";
import dashboard from "./dashboard.html";
import homepage from "./index.html";
const server = serve({
routes: {
// ** HTML インポート **
// index.html を "/" にバンドルしてルーティング。HTMLRewriter を使用して
// HTML 内の `<script>` および `<link>` タグをスキャンし、
// Bun の JavaScript および CSS バンドラーを実行し、TypeScript をトランスパイルし、
// JSX、TSX をトランスパイルし、Bun の CSS パーサーで CSS をダウングレードして結果を提供します。
"/": homepage,
// dashboard.html を "/dashboard" にバンドルしてルーティング
"/dashboard": dashboard,
// ** API エンドポイント ** (Bun v1.2.3+ が必要)
"/api/users": {
async GET(req) {
const users = await sql`SELECT * FROM users`;
return Response.json(users);
},
async POST(req) {
const { name, email } = await req.json();
const [user] = await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;
return Response.json(user);
},
},
"/api/users/:id": async req => {
const { id } = req.params;
const [user] = await sql`SELECT * FROM users WHERE id = ${id}`;
return Response.json(user);
},
},
// 開発モードを有効にして以下を提供:
// - 詳細なエラーメッセージ
// - ホットリローディング (Bun v1.2.3+ が必要)
development: true,
});
console.log(`Listening on ${server.url}`);bun run app.tsHTML ルート
HTML インポートをルートとして
Web は HTML から始まり、Bun のフルスタック開発サーバーも同様です。
フロントエンドのエントリーポイントを指定するには、HTML ファイルを JavaScript/TypeScript/TSX/JSX ファイルにインポートします。
import dashboard from "./dashboard.html";
import homepage from "./index.html";これらの HTML ファイルは、Bun.serve() に渡すことができる Bun の開発サーバーのルートとして使用されます。
Bun.serve({
routes: {
"/": homepage,
"/dashboard": dashboard,
},
fetch(req) {
// ... API リクエスト
},
});/dashboard または / にリクエストすると、Bun は自動的に HTML ファイル内の <script> および <link> タグをバンドルし、それらを静的ルートとして公開し、結果を提供します。
HTML 処理例
このような index.html ファイル:
<!DOCTYPE html>
<html>
<head>
<title>Home</title>
<link rel="stylesheet" href="./reset.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./sentry-and-preloads.ts"></script>
<script type="module" src="./my-app.tsx"></script>
</body>
</html>これは次のようになります:
<!DOCTYPE html>
<html>
<head>
<title>Home</title>
<link rel="stylesheet" href="/index-[hash].css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/index-[hash].js"></script>
</body>
</html>React 統合
クライアントサイドコードで React を使用するには、react-dom/client をインポートしてアプリをレンダリングします。
import dashboard from "../public/dashboard.html";
import { serve } from "bun";
serve({
routes: {
"/": dashboard,
},
async fetch(req) {
// ...api リクエスト
return new Response("hello world");
},
});import { createRoot } from 'react-dom/client';
import App from './app';
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(<App />);<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<link rel="stylesheet" href="../src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/frontend.tsx"></script>
</body>
</html>import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
</div>
);
}開発モード
ローカルでビルドする際は、Bun.serve() で development: true を設定して開発モードを有効にします。
import homepage from "./index.html";
import dashboard from "./dashboard.html";
Bun.serve({
routes: {
"/": homepage,
"/dashboard": dashboard,
},
development: true,
fetch(req) {
// ... API リクエスト
},
});開発モードの機能
development が true の場合、Bun は以下を行います:
- 元のソースコードを表示できるようにデベロッパーツールにソースマップヘッダーをレスポンスに含める
- ミニファイを無効にする
.htmlファイルへのリクエストごとにアセットを再バンドルする- ホットモジュールリローディングを有効にする(
hmr: falseが設定されていない場合) - ブラウザからのコンソールログをターミナルにエコーする
高度な開発設定
Bun.serve() は、ブラウザからのコンソールログをターミナルにエコーすることをサポートしています。
これを有効にするには、Bun.serve() の開発オブジェクトで console: true を渡します。
import homepage from "./index.html";
Bun.serve({
// development はオブジェクトにもできます。
development: {
// ホットモジュールリローディングを有効にする
hmr: true,
// ブラウザからのコンソールログをターミナルにエコーする
console: true,
},
routes: {
"/": homepage,
},
});console: true が設定されている場合、Bun はブラウザからのコンソールログをターミナルにストリーミングします。これは、HMR からの既存の WebSocket 接続を再利用してログを送信します。
開発 vs 本番
| 機能 | 開発 | 本番 |
|---|---|---|
| ソースマップ | ✅ 有効 | ❌ 無効 |
| ミニファイ | ❌ 無効 | ✅ 有効 |
| ホットリロード | ✅ 有効 | ❌ 無効 |
| アセットバンドル | 🔄 リクエストごと | 💾 キャッシュ |
| コンソールログ | 🖥️ ブラウザ → ターミナル | ❌ 無効 |
| エラー詳細 | 📝 詳細 | 🔒 最小限 |
本番モード
ホットリローディングと development: true は素早く反復するのに役立ちますが、本番環境では、サーバーは可能な限り高速で、外部依存が最小限である必要があります。
事前バンドル(推奨)
Bun v1.2.17 以降、Bun.build または bun build を使用してフルスタックアプリケーションを事前にバンドルできます。
bun build --target=bun --production --outdir=dist ./src/index.tsBun のバンドラーがサーバーサイドコードから HTML インポートを検出すると、参照されている JavaScript/TypeScript/TSX/JSX および CSS ファイルをマニフェストオブジェクトにバンドルし、Bun.serve() がアセットを提供するために使用できます。
import { serve } from "bun";
import index from "./index.html";
serve({
routes: { "/": index },
});ランタイムバンドル
ビルドステップを追加するのが複雑すぎる場合は、Bun.serve() で development: false を設定できます。
これにより以下が有効になります:
- バンドルされたアセットのインメモリキャッシングを有効にする。Bun は
.htmlファイルへの最初のリクエストで遅延評価され、サーバーが再起動するまで結果をメモリにキャッシュします。 Cache-ControlヘッダーとETagヘッダーを有効にする- JavaScript/TypeScript/TSX/JSX ファイルをミニファイする
import { serve } from "bun";
import homepage from "./index.html";
serve({
routes: {
"/": homepage,
},
// 本番モード
development: false,
});API ルート
HTTP メソッドハンドラー
HTTP メソッドハンドラーで API エンドポイントを定義します。
import { serve } from "bun";
serve({
routes: {
"/api/users": {
async GET(req) {
// GET リクエストを処理
const users = await getUsers();
return Response.json(users);
},
async POST(req) {
// POST リクエストを処理
const userData = await req.json();
const user = await createUser(userData);
return Response.json(user, { status: 201 });
},
async PUT(req) {
// PUT リクエストを処理
const userData = await req.json();
const user = await updateUser(userData);
return Response.json(user);
},
async DELETE(req) {
// DELETE リクエストを処理
await deleteUser(req.params.id);
return new Response(null, { status: 204 });
},
},
},
});動的ルート
ルートで URL パラメーターを使用します。
serve({
routes: {
// 単一パラメーター
"/api/users/:id": async req => {
const { id } = req.params;
const user = await getUserById(id);
return Response.json(user);
},
// 複数のパラメーター
"/api/users/:userId/posts/:postId": async req => {
const { userId, postId } = req.params;
const post = await getPostByUser(userId, postId);
return Response.json(post);
},
// ワイルドカードルート
"/api/files/*": async req => {
const filePath = req.params["*"];
const file = await getFile(filePath);
return new Response(file);
},
},
});リクエスト処理
serve({
routes: {
"/api/data": {
async POST(req) {
// JSON ボディを解析
const body = await req.json();
// ヘッダーにアクセス
const auth = req.headers.get("Authorization");
// URL パラメーターにアクセス
const { id } = req.params;
// クエリパラメーターにアクセス
const url = new URL(req.url);
const page = url.searchParams.get("page") || "1";
// レスポンスを返す
return Response.json({
message: "Data processed",
page: parseInt(page),
authenticated: !!auth,
});
},
},
},
});プラグイン
Bun のバンドラープラグインは、静的ルートをバンドルする際にもサポートされています。
Bun.serve 用にプラグインを設定するには、bunfig.toml の [serve.static] セクションに plugins 配列を追加します。
TailwindCSS プラグイン
tailwindcss パッケージと bun-plugin-tailwind プラグインをインストールして追加することで、TailwindCSS を使用できます。
bun add tailwindcss bun-plugin-tailwind[serve.static]
plugins = ["bun-plugin-tailwind"]これにより、HTML および CSS ファイルで TailwindCSS ユーティリティクラスを使用できるようになります。必要なのは、どこかで tailwindcss をインポートすることだけです。
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="tailwindcss" />
</head>
<!-- その他の HTML... -->
</html>または、CSS ファイルで TailwindCSS をインポートすることもできます。
@import "tailwindcss";
.custom-class {
@apply bg-red-500 text-white;
}<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./style.css" />
</head>
<!-- その他の HTML... -->
</html>カスタムプラグイン
有効なバンドラープラグインオブジェクト(基本的に name と setup フィールドを持つオブジェクト)をエクスポートする JS ファイルまたはモジュールは、plugins 配列に配置できます。
[serve.static]
plugins = ["./my-plugin-implementation.ts"]import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "my-custom-plugin",
setup(build) {
// プラグイン実装
build.onLoad({ filter: /\.custom$/ }, async args => {
const text = await Bun.file(args.path).text();
return {
contents: `export default ${JSON.stringify(text)};`,
loader: "js",
};
});
},
};
export default myPlugin;Bun は各プラグインを遅延評価して読み込み、ルートをバンドルするために使用します。
NOTE
これは現在 `bunfig.toml` にあります。これは、最終的に `bun build` CLI と統合する際に、どのプラグインが使用されているかを静的に把握できるようにするためです。これらのプラグインは `Bun.build()` の JS API で機能しますが、CLI ではまだサポートされていません。インライン環境変数
Bun は、フロントエンドの JavaScript と TypeScript 内の process.env.* 参照を実際の値にビルド時に置き換えることができます。bunfig.toml で env オプションを設定します。
[serve.static]
env = "PUBLIC_*" # PUBLIC_ で始まる環境変数のみインライン(推奨)
# env = "inline" # すべての環境変数をインライン
# env = "disable" # 環境変数の置き換えを無効(デフォルト)注
これはリテラルの process.env.FOO 参照でのみ機能し、import.meta.env や const env = process.env; env.FOO のような間接的なアクセスでは機能しません。
環境変数が設定されていない場合、ブラウザで ReferenceError: process is not defined のようなランタイムエラーが発生する可能性があります。
ビルド時設定と例の詳細については、HTML と静的サイトのドキュメント を参照してください。
仕組み
Bun は HTMLRewriter を使用して HTML ファイル内の <script> および <link> タグをスキャンし、それらを Bun のバンドラーのエントリーポイントとして使用し、JavaScript/TypeScript/TSX/JSX および CSS ファイルの最適化されたバンドルを生成し、結果を提供します。
処理パイプライン
1. script 処理
<script>タグ内の TypeScript、JSX、TSX をトランスパイル- インポートされた依存関係をバンドル
- デバッグ用のソースマップを生成
Bun.serve()でdevelopmentがtrueでない場合にミニファイ
<script type="module" src="./counter.tsx"></script>2. 処理
- CSS インポートと
<link>タグを処理 - CSS ファイルを連結
- url とアセットパスを書き換えて、URL にコンテンツアドレス指定ハッシュを含める
<link rel="stylesheet" href="./styles.css" />3.
とアセット処理
- アセットへのリンクを書き換えて、URL にコンテンツアドレス指定ハッシュを含める
- CSS ファイル内の小さなアセットは
data:URL にインライン化され、ネットワークを介して送信される HTTP リクエストの総数を削減
4. HTML 書き換え
- すべての
<script>タグを単一の<script>タグに結合し、URL にコンテンツアドレス指定ハッシュを含める - すべての
<link>タグを単一の<link>タグに結合し、URL にコンテンツアドレス指定ハッシュを含める - 新しい HTML ファイルを出力
5. 提供
- バンドラーからのすべての出力ファイルは、
Bun.serve()でstaticに Response オブジェクトを渡すときに内部的に使用されるのと同じメカニズムを使用して、静的ルートとして公開されます。 - これは
Bun.buildが HTML ファイルを処理するのと同様に機能します。
完全な例
ここに完全なフルスタックアプリケーションの例があります。
import { serve } from "bun";
import { Database } from "bun:sqlite";
import homepage from "./public/index.html";
import dashboard from "./public/dashboard.html";
// データベースを初期化
const db = new Database("app.db");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const server = serve({
routes: {
// フロントエンドルート
"/": homepage,
"/dashboard": dashboard,
// API ルート
"/api/users": {
async GET() {
const users = db.query("SELECT * FROM users").all();
return Response.json(users);
},
async POST(req) {
const { name, email } = await req.json();
try {
const result = db.query("INSERT INTO users (name, email) VALUES (?, ?) RETURNING *").get(name, email);
return Response.json(result, { status: 201 });
} catch (error) {
return Response.json({ error: "Email already exists" }, { status: 400 });
}
},
},
"/api/users/:id": {
async GET(req) {
const { id } = req.params;
const user = db.query("SELECT * FROM users WHERE id = ?").get(id);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json(user);
},
async DELETE(req) {
const { id } = req.params;
const result = db.query("DELETE FROM users WHERE id = ?").run(id);
if (result.changes === 0) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return new Response(null, { status: 204 });
},
},
// ヘルスチェックエンドポイント
"/api/health": {
GET() {
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
});
},
},
},
// 開発モードを有効化
development: {
hmr: true,
console: true,
},
// マッチしないルートのフォールバック
fetch(req) {
return new Response("Not Found", { status: 404 });
},
});
console.log(`🚀 Server running on ${server.url}`);<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fullstack Bun App</title>
<link rel="stylesheet" href="../src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/main.tsx"></script>
</body>
</html>import { createRoot } from "react-dom/client";
import { App } from "./App";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(<App />);import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
created_at: string;
}
export function App() {
const [users, setUsers] = useState<User[]>([]);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const fetchUsers = async () => {
const response = await fetch("/api/users");
const data = await response.json();
setUsers(data);
};
const createUser = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
});
if (response.ok) {
setName("");
setEmail("");
await fetchUsers();
} else {
const error = await response.json();
alert(error.error);
}
} catch (error) {
alert("Failed to create user");
} finally {
setLoading(false);
}
};
const deleteUser = async (id: number) => {
if (!confirm("Are you sure?")) return;
try {
const response = await fetch(`/api/users/${id}`, {
method: "DELETE",
});
if (response.ok) {
await fetchUsers();
}
} catch (error) {
alert("Failed to delete user");
}
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div className="container">
<h1>User Management</h1>
<form onSubmit={createUser} className="form">
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} required />
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required />
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create User"}
</button>
</form>
<div className="users">
<h2>Users ({users.length})</h2>
{users.map(user => (
<div key={user.id} className="user-card">
<div>
<strong>{user.name}</strong>
<br />
<span>{user.email}</span>
</div>
<button onClick={() => deleteUser(user.id)} className="delete-btn">
Delete
</button>
</div>
))}
</div>
</div>
);
}* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: #2563eb;
margin-bottom: 2rem;
}
.form {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form input {
flex: 1;
min-width: 200px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.form button {
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.form button:hover {
background: #1d4ed8;
}
.form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.users {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.user-card:last-child {
border-bottom: none;
}
.delete-btn {
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #b91c1c;
}ベストプラクティス
プロジェクト構造
my-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ └── UserList.tsx
│ ├── styles/
│ │ ├── globals.css
│ │ └── components.css
│ ├── utils/
│ │ └── api.ts
│ ├── App.tsx
│ └── main.tsx
├── public/
│ ├── index.html
│ ├── dashboard.html
│ └── favicon.ico
├── server/
│ ├── routes/
│ │ ├── users.ts
│ │ └── auth.ts
│ ├── db/
│ │ └── schema.sql
│ └── index.ts
├── bunfig.toml
└── package.json環境ベースの設定
export const config = {
development: process.env.NODE_ENV !== "production",
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL || "./dev.db",
},
cors: {
origin: process.env.CORS_ORIGIN || "*",
},
};エラーハンドリング
export function errorHandler(error: Error, req: Request) {
console.error("Server error:", error);
if (process.env.NODE_ENV === "production") {
return Response.json({ error: "Internal server error" }, { status: 500 });
}
return Response.json(
{
error: error.message,
stack: error.stack,
},
{ status: 500 },
);
}API レスポンスヘルパー
export function json(data: any, status = 200) {
return Response.json(data, { status });
}
export function error(message: string, status = 400) {
return Response.json({ error: message }, { status });
}
export function notFound(message = "Not found") {
return error(message, 404);
}
export function unauthorized(message = "Unauthorized") {
return error(message, 401);
}型安全性
export interface User {
id: number;
name: string;
email: string;
created_at: string;
}
export interface CreateUserRequest {
name: string;
email: string;
}
export interface ApiResponse<T> {
data?: T;
error?: string;
}デプロイ
本番ビルド
# 本番用にビルド
bun build --target=bun --production --outdir=dist ./server/index.ts
# 本番サーバーを実行
NODE_ENV=production bun dist/index.jsDocker デプロイ
FROM oven/bun:1 as base
WORKDIR /usr/src/app
# 依存関係をインストール
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# ソースコードをコピー
COPY . .
# アプリケーションをビルド
RUN bun build --target=bun --production --outdir=dist ./server/index.ts
# 本番ステージ
FROM oven/bun:1-slim
WORKDIR /usr/src/app
COPY --from=base /usr/src/app/dist ./
COPY --from=base /usr/src/app/public ./public
EXPOSE 3000
CMD ["bun", "index.js"]環境変数
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
CORS_ORIGIN=https://myapp.com他のフレームワークからの移行
Express + Webpack から
// 以前(Express + Webpack)
app.use(express.static("dist"));
app.get("/api/users", (req, res) => {
res.json(users);
});
// 以降(Bun フルスタック)
serve({
routes: {
"/": homepage, // express.static を置き換え
"/api/users": {
GET() {
return Response.json(users);
},
},
},
});Next.js API ルートから
// 以前(Next.js)
export default function handler(req, res) {
if (req.method === 'GET') {
res.json(users);
}
}
// 以降(Bun)
"/api/users": {
GET() { return Response.json(users); }
}制限事項と将来の計画
現在の制限事項
- フルスタックアプリ用の
bun buildCLI 統合はまだ利用できません - API ルートの自動検出は実装されていません
- サーバーサイドレンダリング(SSR)は組み込まれていません
予定されている機能
bun buildCLI との統合- API エンドポイント用のファイルベースルーティング
- 組み込み SSR サポート
- 強化されたプラグインエコシステム
NOTE
これは進行中の作業です。Bun の進化に伴い、機能と API は変更される可能性があります。