首页 / Bun 入门教程 / HTTP 服务:Bun.serve

Bun 入门教程

HTTP 服务:Bun.serve

本教程共 34 篇 · 第 27 篇 · 更新于 2026-08-06

BunBun.serveHTTP路由WebSocket中间件全栈

本节目标:

  • Bun.serveroutes 配置静态、动态、按 HTTP 方法、通配符与重定向路由。
  • 理解 fetch 兜底函数与路由优先级,知道未匹配请求如何被处理。
  • 掌握端口、hostname、idleTimeout、TLS 与 export default 写法,以及 server 实例的生命周期方法。
  • error 处理器统一兜住异常,用 server.upgrade() 接入 WebSocket。
  • 清楚 Bun 没有内置中间件机制,知道如何手动组织请求处理逻辑(或交给上层框架)。

Bun.serve 是 Bun 运行时的内置 HTTP 服务入口。它完全按 Web 标准设计:fetch 处理器收到的是标准 Request,要返回的是标准 Response

这一点的价值在于心智模型统一。你在浏览器里怎么用 fetchResponse,在服务端就怎么写;同一个构造响应的函数,既能给 Bun.serve 用,也能给 Service Worker 或 Cloudflare Workers 用。不需要再学一套 req.send()res.end() 的私有约定。

先记住一份最小可运行的骨架,后面所有配置都是往这个对象上加字段:

Bun.serve({
  port: 3000,
  fetch(req) {
    return new Response("Hello Bun!");
  },
});

27.1 最简服务与路由配置

Bun.serveroutes 字段(需要 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 函数完全没问题。

路由优先级

当多条规则可能命中同一请求时,匹配顺序由「具体程度」决定:

  1. 精确路由(如 /users/all
  2. 参数路由(如 /users/:id
  3. 通配符路由(如 /users/*
  4. 全局兜底(/*
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)不重启地替换处理器;只有 fetcherrorrouteswebsocket 可被更新
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");
  },
});
Note

v1.3.14 开始,Bun.serve 支持通过 QUIC 提供 HTTP/3,设置 http3: true 即可,但必须同时配置 tls(HTTP/3 强制加密)。这项能力目前标注为实验性,接口可能在后续版本调整,生产环境请谨慎评估。

27.5 直接返回 HTML 与全栈开发

Bun.serveroutes 可以直接返回 Bun.file() 指向的 HTML,也可以配合 Bun 的 HTML import 能力做全栈开发:

import myReactSinglePageApp from "./index.html";

Bun.serve({
  routes: {
    "/": myReactSinglePageApp,
  },
});
  • 开发模式(bun --hot:Bun 在运行时按需打包资源,并启用热更新(HMR)。
  • 生产模式(bun build --target=bunimport 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() 支持字符串、ArrayBufferTypedArray/DataViewBlob 等多种类型。

把用户身份放进 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.serveBunFile 响应支持 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

不要把 routesfetch 当成完整 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: trueNODE_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 里能写异步函数吗? 能。处理器返回 ResponsePromise<Response> 都合法,直接写 async 即可。

问:怎么优雅停机? await server.stop() 会停止接受新连接、等在途请求跑完再返回。想强制断开就传 true。配合 process.on("SIGTERM", ...) 使用,容器滚动更新时不会丢请求。