全棧開發伺服器
使用 Bun 整合的開發伺服器建置全棧應用,它能夠打包前端資源並處理 API 路由
首先,匯入 HTML 檔案並將它們傳遞給 Bun.serve() 的 routes 選項。
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}`);
bun run app.ts
HTML 路由
將 HTML 匯入作為路由
要為前端指定入口點,請將 HTML 檔案匯入 JavaScript/TypeScript/TSX/JSX 檔案。
import dashboard from "./dashboard.html";
import homepage from "./index.html";
將匯入的 HTML 檔案作為路由傳遞給 Bun.serve()。
Bun.serve({
routes: {
"/": homepage,
"/dashboard": dashboard,
},
fetch(req) {
// ... API 請求
},
});
當你請求 /dashboard 或 / 時,Bun 會自動打包 HTML 檔案中的 <script> 和 <link> 標籤,將它們暴露為靜態路由並提供結果。
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>
經過處理後可能成為:
<!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 並渲染你的應用。
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:
- 在響應中包含 SourceMap 標頭,以便開發者工具顯示原始原始碼
- 停用程式碼壓縮
- 每次請求
.html檔案時重新打包資源 - 啟用熱模組過載(除非設定
hmr: false)
高階開發設定
要將瀏覽器中的控制台日誌回顯到終端,請在 Bun.serve() 的 development 物件中傳入 console: true。
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 提前打包完整的全棧應用。
bun build --target=bun --production --outdir=dist ./src/index.ts
當 Bun 的打包器從伺服器端程式碼中發現 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 會:
- 啟用打包資源的記憶體快取。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: "資料已處理",
page: parseInt(page),
authenticated: !!auth,
});
},
},
},
});
外掛
Bun 還支援在打包靜態路由時使用打包器外掛。
要為 Bun.serve 設定外掛,在 bunfig.toml 的 [serve.static] 部分新增 plugins 陣列。
TailwindCSS 外掛
要使用 TailwindCSS,請安裝 tailwindcss 包和 bun-plugin-tailwind 外掛。
bun add tailwindcss bun-plugin-tailwind
[serve.static]
plugins = ["bun-plugin-tailwind"]
現在,你可以在 HTML 和 CSS 檔案中使用 TailwindCSS 工具類。在專案中的某個位置匯入 tailwindcss:
<!doctype html>
<html>
<head>
<!-- [!code ++] -->
<link rel="stylesheet" href="tailwindcss" />
</head>
<!-- 其餘 HTML... -->
</html>
或者在 CSS 檔案中匯入 TailwindCSS:
@import "tailwindcss";
.custom-class {
@apply bg-red-500 text-white;
}
<!doctype html>
<html>
<head>
<!-- [!code ++] -->
<link rel="stylesheet" href="./style.css" />
</head>
<!-- 其餘 HTML... -->
</html>
自定義外掛
plugins 陣列接受任何匯出有效打包器外掛物件的 JS 檔案或模組(包含 name 和 setup 欄位的物件):
[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 會延遲解析並載入每個外掛,並使用它們來打包你的路由。
內聯環境變數
Bun 可以在建置時將前端 JavaScript 和 TypeScript 中的 process.env.* 引用替換為其值。在 bunfig.toml 中設定 env 選項:
[serve.static]
env = "PUBLIC_*" # 僅內聯以 PUBLIC_ 開頭的環境變數(推薦)
# env = "inline" # 內聯所有環境變數
# env = "disable" # 停用環境變數替換(預設)
有關建置時設定和範例,請參閱 HTML 和靜態網站。
源對映
在開發環境中,Bun 會為打包後的路由生成關聯的源對映,並與 JavaScript 和 CSS 分塊一起提供。在生產環境中(development: false),預設停用源對映,以免伺服器暴露原始原始碼。
要覆蓋預設設定,請在 bunfig.toml 中設定 sourcemap 選項:
[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. <script> 處理
- 轉譯
<script>標籤中的 TypeScript、JSX 和 TSX - 打包匯入的相依
- 在開發環境中生成用於除錯的 sourcemaps
- 當
Bun.serve()中的development不為true時進行壓縮
index.html<script type="module" src="./counter.tsx"></script> - 轉譯
- 2. <link> 處理
- 處理 CSS 匯入及
<link>標籤 - 合併 CSS 檔案
- 重寫 url 及資源路徑,在 URL 中加入內容定址雜湊
index.html<link rel="stylesheet" href="./styles.css" /> - 處理 CSS 匯入及
- 3. <img> 和資源處理
- 重寫資源連結,在 URL 中加入內容定址雜湊
- 將 CSS 檔案中的小型資源內聯為
data:URL,減少網路請求總數
- 4. HTML 重寫
- 合併所有
<script>標籤為一個帶內容定址雜湊的<script>標籤 - 合併所有
<link>標籤為一個帶內容定址雜湊的<link>標籤 - 輸出新的 HTML 檔案
- 合併所有
- 5. 提供服務
- 將打包器生成的所有輸出檔案暴露為靜態路由,其內部使用的機制與將 Response 物件傳遞給
Bun.serve()中的routes時相同。 - 此流程與
Bun.build處理 HTML 檔案的方式類似。
- 將打包器生成的所有輸出檔案暴露為靜態路由,其內部使用的機制與將 Response 物件傳遞給
完整範例
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}`);
<!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>
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("建立使用者失敗");
} 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>
);
}
* {
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.js
Docker 部署
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"]
環境變數
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); }
}
限制與未來計劃
當前限制
- 尚未實現 API 路由的自動發現
- 未內建伺服器端渲染(SSR)
規劃功能
- 基於檔案的 API 端點路由
- 內建 SSR 支援
- 更完善的外掛生態系統