執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

全棧開發伺服器

使用 Bun 整合的開發伺服器建置全棧應用,它能夠打包前端資源並處理 API 路由

首先,匯入 HTML 檔案並將它們傳遞給 Bun.serve() 的 routes 選項。

app.ts
import { serve, sql } 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}) RETURNING *`;
        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}`);
terminal
bun run app.ts

HTML 路由

將 HTML 匯入作為路由

要為前端指定入口點,請將 HTML 檔案匯入 JavaScript/TypeScript/TSX/JSX 檔案。

app.ts
import dashboard from "./dashboard.html";
import homepage from "./index.html";

將匯入的 HTML 檔案作為路由傳遞給 Bun.serve()。

app.ts
Bun.serve({
  routes: {
    "/": homepage,
    "/dashboard": dashboard,
  },

  fetch(req) {
    // ... API 請求
  },
});

當你請求 /dashboard 或 / 時,Bun 會自動打包 HTML 檔案中的 <script> 和 <link> 標籤,將它們暴露為靜態路由並提供結果。

HTML 處理範例

一個 index.html 檔案如下:

index.html
<!DOCTYPE html>
<html>
  <head>
    <title>首頁</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>

經過處理後可能成為:

index.html
<!DOCTYPE html>
<html>
  <head>
    <title>首頁</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 並渲染你的應用。

src/backend.ts
import dashboard from "../public/dashboard.html";
import { serve } from "bun";

serve({
  routes: {
    "/": dashboard,
  },
  async fetch(req) {
    // ...api 請求
    return new Response("hello world");
  },
});
src/frontend.tsx
import { createRoot } from "react-dom/client";
import App from "./app";

const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
public/dashboard.html
<!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>
src/app.tsx
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 啟用開發模式。

src/backend.ts
import homepage from "./index.html";
import dashboard from "./dashboard.html";

Bun.serve({
  routes: {
    "/": homepage,
    "/dashboard": dashboard,
  },

  development: true,

  fetch(req) {
    // ... API 請求
  },
});

開發模式功能

當 development 為 true 時,Bun:

  • 在響應中包含 SourceMap 標頭,以便開發者工具顯示原始原始碼
  • 停用程式碼壓縮
  • 每次請求 .html 檔案時重新打包資源
  • 啟用熱模組過載(除非設定 hmr: false)

高階開發設定

要將瀏覽器中的控制台日誌回顯到終端,請在 Bun.serve() 的 development 物件中傳入 console: true。

src/backend.ts
import homepage from "./index.html";

Bun.serve({
  // development 也可以是一個物件
  development: {
    // 啟用熱模組過載
    hmr: true,

    // 將瀏覽器中的控制台日誌回顯到終端
    console: true,
  },

  routes: {
    "/": homepage,
  },
});

Bun 通過現有的 HMR WebSocket 連線傳送日誌。

開發與生產

功能開發環境生產環境
源對映✅ 啟用❌ 停用
程式碼壓縮❌ 停用✅ 啟用
熱過載✅ 啟用❌ 停用
資源打包🔄 每次請求💾 快取
控制台日誌🖥️ 瀏覽器 → 終端❌ 停用
錯誤詳情📝 詳細🔒 簡潔

生產模式

熱過載和 development: true 有助於快速迭代,但在生產環境中,你的伺服器應儘可能快速,並儘量減少外部相依。

預建置打包(推薦)

從 Bun v1.2.17 開始,你可以使用 Bun.build 或 bun build 提前打包完整的全棧應用。

terminal
bun build --target=bun --production --outdir=dist ./src/index.ts

當 Bun 的打包器從伺服器端程式碼中發現 HTML 匯入時,它會將引用的 JavaScript/TypeScript/TSX/JSX 和 CSS 檔案打包到一個清單物件中,供 Bun.serve() 用於提供資源。

src/backend.ts
import { serve } from "bun";
import index from "./index.html";

serve({
  routes: { "/": index },
});

執行環境打包

如果你不想新增建置步驟,可以在 Bun.serve() 中設定 development: false。

使用此設定時,Bun 會:

  • 啟用打包資源的記憶體快取。Bun 會在首次請求 .html 檔案時延遲打包資源,並將結果快取到記憶體中,直到伺服器重啟。
  • 啟用 Cache-Control 和 ETag 響應頭
  • 壓縮 JavaScript/TypeScript/TSX/JSX 檔案
src/backend.ts
import { serve } from "bun";
import homepage from "./index.html";

serve({
  routes: {
    "/": homepage,
  },

  // 生產模式
  development: false,
});

API 路由

HTTP 方法處理器

通過 HTTP 方法處理器定義 API 端點:

src/backend.ts
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 引數:

src/backend.ts
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);
    },
  },
});

請求處理

src/backend.ts
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: "資料已處理",
          page: parseInt(page),
          authenticated: !!auth,
        });
      },
    },
  },
});

外掛

Bun 還支援在打包靜態路由時使用打包器外掛。

要為 Bun.serve 設定外掛,在 bunfig.toml 的 [serve.static] 部分新增 plugins 陣列。

TailwindCSS 外掛

要使用 TailwindCSS,請安裝 tailwindcss 包和 bun-plugin-tailwind 外掛。

terminal
bun add tailwindcss bun-plugin-tailwind
bunfig.toml
[serve.static]
plugins = ["bun-plugin-tailwind"]

現在,你可以在 HTML 和 CSS 檔案中使用 TailwindCSS 工具類。在專案中的某個位置匯入 tailwindcss:

index.html
<!doctype html>
<html>
  <head>
    <!-- [!code ++] -->
    <link rel="stylesheet" href="tailwindcss" />
  </head>
  <!-- 其餘 HTML... -->
</html>

或者在 CSS 檔案中匯入 TailwindCSS:

style.css
@import "tailwindcss";

.custom-class {
  @apply bg-red-500 text-white;
}
index.html
<!doctype html>
<html>
  <head>
    <!-- [!code ++] -->
    <link rel="stylesheet" href="./style.css" />
  </head>
  <!-- 其餘 HTML... -->
</html>

自定義外掛

plugins 陣列接受任何匯出有效打包器外掛物件的 JS 檔案或模組(包含 name 和 setup 欄位的物件):

bunfig.toml
[serve.static]
plugins = ["./my-plugin-implementation.ts"]
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 會延遲解析並載入每個外掛,並使用它們來打包你的路由。

內聯環境變數

Bun 可以在建置時將前端 JavaScript 和 TypeScript 中的 process.env.* 引用替換為其值。在 bunfig.toml 中設定 env 選項:

bunfig.toml
[serve.static]
env = "PUBLIC_*"  # 僅內聯以 PUBLIC_ 開頭的環境變數(推薦)
# env = "inline"  # 內聯所有環境變數
# env = "disable" # 停用環境變數替換(預設)

有關建置時設定和範例,請參閱 HTML 和靜態網站。

源對映

在開發環境中,Bun 會為打包後的路由生成關聯的源對映,並與 JavaScript 和 CSS 分塊一起提供。在生產環境中(development: false),預設停用源對映,以免伺服器暴露原始原始碼。

要覆蓋預設設定,請在 bunfig.toml 中設定 sourcemap 選項:

bunfig.toml
[serve.static]
sourcemap = "linked" # serve sourcemaps in production too
# sourcemap = "inline"   # embed sourcemaps in the chunks
# sourcemap = "external" # emit .map files without a sourceMappingURL comment
# sourcemap = false      # never generate sourcemaps

工作原理

Bun 使用 HTMLRewriter 掃描 HTML 檔案中的 <script> 和 <link> 標籤,並將它們作為 Bun 打包器的入口點。然後,Bun 為 JavaScript/TypeScript/TSX/JSX 和 CSS 檔案生成最佳化後的打包結果,並提供該結果。

處理流程

  1. 1. <script> 處理
    • 轉譯 <script> 標籤中的 TypeScript、JSX 和 TSX
    • 打包匯入的相依
    • 在開發環境中生成用於除錯的 sourcemaps
    • 當 Bun.serve() 中的 development 不為 true 時進行壓縮
    index.html
    <script type="module" src="./counter.tsx"></script>
    
  2. 2. <link> 處理
    • 處理 CSS 匯入及 <link> 標籤
    • 合併 CSS 檔案
    • 重寫 url 及資源路徑,在 URL 中加入內容定址雜湊
    index.html
    <link rel="stylesheet" href="./styles.css" />
    
  3. 3. <img> 和資源處理
    • 重寫資源連結,在 URL 中加入內容定址雜湊
    • 將 CSS 檔案中的小型資源內聯為 data: URL,減少網路請求總數
  4. 4. HTML 重寫
    • 合併所有 <script> 標籤為一個帶內容定址雜湊的 <script> 標籤
    • 合併所有 <link> 標籤為一個帶內容定址雜湊的 <link> 標籤
    • 輸出新的 HTML 檔案
  5. 5. 提供服務
    • 將打包器生成的所有輸出檔案暴露為靜態路由,其內部使用的機制與將 Response 物件傳遞給 Bun.serve() 中的 routes 時相同。
    • 此流程與 Bun.build 處理 HTML 檔案的方式類似。

完整範例

server.ts
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: "郵箱已存在" }, { 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: "使用者未找到" }, { 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: "使用者未找到" }, { 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("未找到", { status: 404 });
  },
});

console.log(`🚀 Server running on ${server.url}`);
public/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>全棧 Bun 應用</title>
    <link rel="stylesheet" href="../src/styles.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="../src/main.tsx"></script>
  </body>
</html>
src/main.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";

const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(<App />);
src/App.tsx
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("建立使用者失敗");
    } finally {
      setLoading(false);
    }
  };

  const deleteUser = async (id: number) => {
    if (!confirm("確定嗎?")) return;

    try {
      const response = await fetch(`/api/users/${id}`, {
        method: "DELETE",
      });

      if (response.ok) {
        await fetchUsers();
      }
    } catch (error) {
      alert("刪除使用者失敗");
    }
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <div className="container">
      <h1>使用者管理</h1>

      <form onSubmit={createUser} className="form">
        <input type="text" placeholder="姓名" value={name} onChange={e => setName(e.target.value)} required />
        <input type="email" placeholder="郵箱" value={email} onChange={e => setEmail(e.target.value)} required />
        <button type="submit" disabled={loading}>
          {loading ? "正在建立..." : "建立使用者"}
        </button>
      </form>

      <div className="users">
        <h2>使用者({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">
              刪除
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
src/styles.css
* {
  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;
}

最佳實踐

專案結構

Code
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

基於環境的設定

server/config.ts
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 || "*",
  },
};

錯誤處理

server/middleware.ts
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 響應輔助函式

server/utils.ts
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);
}

型別安全

types/api.ts
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;
}

部署

生產建置

terminal
# 建置生產版本
bun build --target=bun --production --outdir=dist ./server/index.ts

# 執行生產伺服器
NODE_ENV=production bun dist/index.js

Docker 部署

Dockerfile
FROM oven/bun:1 as base
WORKDIR /usr/src/app

# 安裝相依
COPY package.json bun.lock ./
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"]

環境變數

.env.production
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
CORS_ORIGIN=https://myapp.com

從其他框架遷移

從 Express + Webpack

server.ts
// 之前(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 路由

server.ts
// 之前(Next.js)
export default function handler(req, res) {
  if (req.method === 'GET') {
    res.json(users);
  }
}

// 之後(Bun)
"/api/users": {
  GET() { return Response.json(users); }
}

限制與未來計劃

當前限制

  • 尚未實現 API 路由的自動發現
  • 未內建伺服器端渲染(SSR)

規劃功能

  • 基於檔案的 API 端點路由
  • 內建 SSR 支援
  • 更完善的外掛生態系統