HTTP 服务:Bun.serve
本教程共 34 篇 · 第 27 篇 · 更新于 2026-08-06
本节目标:
- 用
Bun.serve的routes配置静态、动态、按 HTTP 方法、通配符与重定向路由。- 理解
fetch兜底函数与路由优先级,知道未匹配请求如何被处理。- 掌握端口、hostname、
idleTimeout、TLS 与export default写法,以及server实例的生命周期方法。- 用
error处理器统一兜住异常,用server.upgrade()接入 WebSocket。- 清楚 Bun 没有内置中间件机制,知道如何手动组织请求处理逻辑(或交给上层框架)。
Bun.serve 是 Bun 运行时的内置 HTTP 服务入口。它完全按 Web 标准设计:fetch 处理器收到的是标准 Request,要返回的是标准 Response。
这一点的价值在于心智模型统一。你在浏览器里怎么用 fetch 和 Response,在服务端就怎么写;同一个构造响应的函数,既能给 Bun.serve 用,也能给 Service Worker 或 Cloudflare Workers 用。不需要再学一套 req.send()、res.end() 的私有约定。
先记住一份最小可运行的骨架,后面所有配置都是往这个对象上加字段:
Bun.serve({
port: 3000,
fetch(req) {
return new Response("Hello Bun!");
},
});
27.1 最简服务与路由配置
Bun.serve 的 routes 字段(需要 Bun v1.2.3+)让你用声明式的方式定义路由,比只用 fetch 手写 if/else 清晰得多。
const server = Bun.serve({
routes: {
// 静态路由:直接返回内容
"/api/status": new Response("OK"),
// 动态路由:用 :id 捕获路径参数
"/users/:id": req => {
return new Response(`Hello User ${req.params.id}!`);
},
// 按 HTTP 方法分别处理
"/api/posts": {
GET: () => new Response("List posts"),
POST: async req => {
const body = await req.json();
return Response.json({ created: true, ...body });
},
},
// 通配符:匹配所有以 /api/ 开头、且没有被其他规则命中的路径
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
// 重定向
"/blog/hello": Response.redirect("/blog/hello/world"),
// 惰性读取文件返回(不会一次性读进内存)
"/favicon.ico": Bun.file("./favicon.ico"),
},
// 兜底:所有未匹配 routes 的请求都会走到这里
fetch(req) {
return new Response("Not Found", { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
Note
routes处理器接收的是BunRequest,它继承自标准Request,额外带有params(路径参数)和cookies(CookieMap)。返回类型可以是Response或其Promise,因此用async函数完全没问题。
路由优先级
当多条规则可能命中同一请求时,匹配顺序由「具体程度」决定:
- 精确路由(如
/users/all) - 参数路由(如
/users/:id) - 通配符路由(如
/users/*) - 全局兜底(
/*)
Bun.serve({
routes: {
"/api/users/me": () => new Response("Current user"), // 1 最具体
"/api/users/:id": req => new Response(`User ${req.params.id}`), // 2
"/api/*": () => new Response("API catch-all"), // 3
"/*": () => new Response("Global catch-all"), // 4
},
});
fetch 兜底
routes 只覆盖显式声明的路径;任何没被命中的请求都会交给 fetch。如果你的 Bun 版本低于 v1.2.3,则必须提供 fetch(那时还没有 routes)。两者可以共存:routes 处理主体,其余一律走 fetch。
有个细节值得留意。routes 里的静态值(如 new Response("OK"))在服务启动时就构造好了,每次命中直接复用同一个响应对象,开销接近于零。函数则每次请求都会调用一次。所以像健康检查、robots.txt 这类内容固定的端点,直接写静态值比写函数更划算。
27.2 端口、hostname 与随机端口
Bun.serve({
port: 8080, // 未设置时依次读取 $BUN_PORT、$PORT、$NODE_PORT,再回退到 3000
hostname: "mydomain.com", // 默认 "0.0.0.0"
fetch(req) {
return new Response("404!");
},
});
把 port 设为 0,Bun 会自动挑一个空闲端口;运行后从 server.port / server.url 读取实际值:
const server = Bun.serve({
port: 0, // 随机端口
fetch() { return new Response("404!"); },
});
console.log(server.port); // 例如 51234
console.log(server.url); // http://localhost:51234
Tip写测试时让端口随机、再读取
server.port,可以避免端口冲突。生产部署则一般用Bun.serve({ port: 3000 })或在bunfig.toml/ 环境变量里指定。
除了 TCP 端口,Bun.serve 也能监听 Unix 域套接字,常用于 Nginx 反向代理到本机服务:
Bun.serve({
unix: "/tmp/my-socket.sock",
fetch(req) {
return new Response("via unix socket");
},
});
idleTimeout:连接空闲多久被断开
这是一个新手常常踩到、却很少有人提前讲的配置。Bun.serve 默认在连接空闲 10 秒后关闭它——空闲的判定是「既没收到数据也没发出数据」,注意这包括你的处理器还在跑但一个字节都还没写出去的情况。客户端看到的现象是连接被重置。
用 idleTimeout 调整,单位是秒,最大 255,设为 0 表示完全关闭超时:
Bun.serve({
idleTimeout: 30, // 默认 10 秒
fetch(req) {
return new Response("Bun!");
},
});
流式响应同样受这把尺子约束。做 SSE(Server-Sent Events)或长轮询时,与其把全局 idleTimeout 调大,不如只给这一个请求松绑:
Bun.serve({
routes: {
"/events": (req, server) => {
server.timeout(req, 0); // 只对本次请求关闭空闲超时
return new Response(
async function* () {
yield "data: hello\n\n";
},
{ headers: { "Content-Type": "text/event-stream" } },
);
},
},
});
27.3 export default 写法与生命周期方法
除了直接 Bun.serve(...),也可以把服务对象作为模块的默认导出,便于按文件组织:
export default {
port: 3000,
fetch(req) {
return new Response("Hello from default export");
},
};
Bun 运行一个含 fetch 默认导出的文件时,会自动把它喂给 Bun.serve。这种写法天然支持 --hot 热重载,改代码不重启进程。
Bun.serve 返回的是一个 Server 实例,常用成员如下:
| 成员 | 作用 |
|---|---|
server.reload(options) | 不重启地替换处理器;只有 fetch、error、routes、websocket 可被更新 |
await server.stop(closeSockets?) | 停止接受新连接(默认等待在途请求完成);传 true 立即断开所有连接 |
server.ref() / server.unref() | 控制服务是否保持 bun 进程存活 |
server.timeout(req, seconds) | 单独覆盖某个请求的空闲超时;传 0 为不超时 |
server.requestIP(req) | 返回 { address, port, family } 对象;连接已关闭或走 Unix 套接字时为 null |
server.pendingRequests / pendingWebSockets | 当前在途请求数 / 活跃 WebSocket 数 |
server.url / server.port | 服务地址与端口 |
const server = Bun.serve({
fetch(req, server) {
const addr = server.requestIP(req);
return new Response(`来自 ${addr?.address}:${addr?.port}`);
},
});
// 不停机换路由:灰度发新版本时很好用
server.reload({
routes: { "/api/version": Response.json({ version: "2.0.0" }) },
});
// 优雅关闭:等在途请求跑完
await server.stop();
Warning
server.requestIP()返回的是对象不是字符串,写`IP: ${server.requestIP(req)}`只会得到[object Object]。取.address字段才是 IP。另外它必须传入真实进来的那个req,自己new Request(...)造一个是查不到的。
27.4 TLS 与 HTTP/3
启用 HTTPS 只要传 tls 字段,键和证书都可以直接用 Bun.file() 惰性引用:
Bun.serve({
tls: {
key: Bun.file("./key.pem"),
cert: Bun.file("./cert.pem"),
// passphrase: "super-secret", // 私钥有口令时才需要
},
fetch(req) {
return new Response("Hello over TLS");
},
});
Notev1.3.14 开始,
Bun.serve支持通过 QUIC 提供 HTTP/3,设置http3: true即可,但必须同时配置tls(HTTP/3 强制加密)。这项能力目前标注为实验性,接口可能在后续版本调整,生产环境请谨慎评估。
27.5 直接返回 HTML 与全栈开发
Bun.serve 的 routes 可以直接返回 Bun.file() 指向的 HTML,也可以配合 Bun 的 HTML import 能力做全栈开发:
import myReactSinglePageApp from "./index.html";
Bun.serve({
routes: {
"/": myReactSinglePageApp,
},
});
- 开发模式(
bun --hot):Bun 在运行时按需打包资源,并启用热更新(HMR)。 - 生产模式(
bun build --target=bun):import index from "./index.html"解析成一个预构建的资源清单对象,运行时不再打包,直接由Bun.serve提供静态资源。
Note这是一种「框架无关」的全栈方式。如果你想要路由、中间件、校验等更完整的体验,可叠加 Elysia、Hono 等框架(见第 31 章)。
27.6 WebSocket:升级连接与事件处理
Bun.serve 内置服务端 WebSocket,支持实时压缩、TLS,以及 Bun 原生的发布/订阅(pub/sub)API。
升级连接在 fetch 处理器里完成;事件逻辑统一声明在 websocket 对象中:
Bun.serve({
fetch(req, server) {
// 把请求升级为 WebSocket 连接
if (server.upgrade(req)) {
return; // 升级成功后不要返回 Response
}
return new Response("Upgrade failed", { status: 500 });
},
websocket: {
open(ws) { console.log("connected"); },
message(ws, message) { ws.send(message); }, // 回显
close(ws, code, message) { console.log("closed"); },
drain(ws) { /* socket 可继续写入时 */ },
},
});
Note与浏览器端
WebSocket(基于onmessage/onopen/onclose)不同,Bun 的服务端处理器是每个 server 只声明一次,而非每个连接各声明一份。服务器常开大量连接,复用同一个处理器对象能节省内存与事件监听开销。
可以用 server.upgrade(req, { headers, data }) 在升级时附带响应头或上下文数据;上下文数据之后在处理器里通过 ws.data 访问。ws.send() 支持字符串、ArrayBuffer、TypedArray/DataView、Blob 等多种类型。
把用户身份放进 data,是最常见的用法——因为一旦升级完成,你就再也拿不到原始的 Request 了:
Bun.serve({
fetch(req, server) {
const userId = new URL(req.url).searchParams.get("uid");
if (!userId) return new Response("Unauthorized", { status: 401 });
if (server.upgrade(req, { data: { userId } })) return;
return new Response("Upgrade failed", { status: 500 });
},
websocket: {
open(ws) {
ws.subscribe("room:lobby"); // 订阅频道
},
message(ws, message) {
ws.publish("room:lobby", `${ws.data.userId}: ${message}`);
},
},
});
subscribe / publish 是 Bun 原生的发布订阅,广播时不需要自己维护连接数组,也不必逐个 send。想知道某个频道有多少人,用 server.subscriberCount("room:lobby")。
27.7 静态文件服务
Bun.serve 没有独立的「静态目录」配置项,但用 routes 配合 Bun.file() 就能直接返回文件,且 Bun.file 是惰性读取(按需流式返回,不会一次性载入内存):
Bun.serve({
routes: {
"/favicon.ico": Bun.file("./public/favicon.ico"),
"/robots.txt": Bun.file("./public/robots.txt"),
},
fetch(req) {
return new Response("Not Found", { status: 404 });
},
});
如果要按 URL 路径映射整个目录,可在 fetch 里拼出真实路径再用 Bun.file() 返回。务必对用户输入做校验,否则 /static/../../etc/passwd 这种目录穿越(path traversal)就能读到服务器上的任意文件:
import { join, normalize, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "public");
Bun.serve({
async fetch(req) {
const pathname = decodeURIComponent(new URL(req.url).pathname);
const target = resolve(join(ROOT, normalize(pathname)));
// 关键一步:确认解析后的路径仍在 ROOT 之内
if (!target.startsWith(ROOT)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(target);
if (!(await file.exists())) {
return new Response("Not Found", { status: 404 });
}
return new Response(file);
},
});
Tip从 v1.3.13 起,
Bun.serve对BunFile响应支持 Range 请求。这意味着直接返回一个视频文件,浏览器就能拖动进度条断点续播,你不用自己解析Range头。
27.8 关于「中间件」的思路
Bun 的 routes / fetch 原生没有 Express 风格的中间件(middleware)链。这不是缺陷,而是定位差异:Bun 提供的是底层、高性能的 Web 标准原语,中间件属于上层抽象。
// 手动「组合」逻辑:用一个包装函数做日志 + 鉴权
function withAuth(handler: (req: Request) => Response) {
return (req: Request) => {
const token = req.headers.get("authorization");
if (!token) return new Response("Unauthorized", { status: 401 });
return handler(req);
};
}
Bun.serve({
routes: {
"/me": withAuth(() => new Response("secret data")),
},
});
Warning不要把
routes或fetch当成完整 Web 框架来用。路由分组、参数校验、序列化、OpenAPI 等需求,建议直接使用 Elysia、Hono 这类构建在Bun.serve之上的框架(详见第 31 章)。
27.9 统一响应与 error 错误处理器
真实服务里,未匹配路由应返回结构化 JSON 而非裸文本。把兜底 fetch 写成统一出口即可:
Bun.serve({
routes: {
"/api/ping": () => Response.json({ ok: true }),
},
fetch(req) {
return Response.json(
{ error: "not_found", path: new URL(req.url).pathname },
{ status: 404 },
);
},
});
处理器里抛出的异常怎么办?Bun.serve 提供了专门的 error 字段。它接收错误对象,返回一个 Response,这个响应会覆盖 Bun 的默认错误页:
Bun.serve({
fetch(req) {
throw new Error("woops!");
},
error(error) {
console.error(error); // 落日志
return Response.json(
{ error: "internal_error", message: error.message },
{ status: 500 },
);
},
});
不写 error 时也不会「连接直接断掉」:Bun.serve 有内置的 500 页面。开发模式(development: true,NODE_ENV 不为 production 时默认开启)下这个页面会直接把错误栈渲染到浏览器里,方便定位;生产环境务必自己实现 error,把栈信息挡在服务端,只回给客户端一个中性提示。
需要注意的是,error 只兜住同步抛出和 Promise 拒绝这一层。响应体开始流式写出之后再出错,头已经发走了,就没法再改状态码了——这类场景要在生成流的那段逻辑里自己 try/catch。
Tip想在本机快速验证,加
--hot启动:bun --hot run index.ts。改代码后服务会自动重载,浏览器无需手动刷新;配合 HTML import(27.5 节)还能获得前端 HMR。
27.10 小结与常见坑
Bun.serve基于 Web 标准:处理器收Request、回Response,与fetch客户端一致。routes优先于fetch;匹配顺序为「精确 > 参数 > 通配符 > 兜底」。server.upgrade()之后必须return,否则会同时返回 101 与 Response 而报错。- 静态文件用
Bun.file()惰性返回即可,没有专门的静态目录开关;映射目录时记得防目录穿越。 - 异常统一交给
error处理器,生产环境别把错误栈发给客户端。 - 需要中间件/校验/路由分组时,交给 Elysia、Hono 等框架,而非手写。
几个高频疑问
问:port 不写会怎样?
按 $BUN_PORT → $PORT → $NODE_PORT 的顺序读环境变量,都没有才回退到 3000。部署到 PaaS 平台时通常不需要硬编码端口,平台会注入 PORT。
问:请求刚跑到一半连接就断了,是 Bun 的 bug 吗?
先看是不是撞上了默认 10 秒的 idleTimeout。慢查询、大文件生成、SSE 都容易触发。全局调大用 idleTimeout,只放行单个请求用 server.timeout(req, 0)。
问:routes 里能写异步函数吗?
能。处理器返回 Response 或 Promise<Response> 都合法,直接写 async 即可。
问:怎么优雅停机?
await server.stop() 会停止接受新连接、等在途请求跑完再返回。想强制断开就传 true。配合 process.on("SIGTERM", ...) 使用,容器滚动更新时不会丢请求。