我以前已经让 Bun 跑过 Next.js 的 standalone server.js,但开发和构建仍由 Node.js 执行。那只能叫“生产运行时换成 Bun”,还不是完整迁移。
这次我把边界推到底:依赖安装、workspace 命令、next dev、next build、测试、本地脚本、Docker builder 和最终容器都使用 Bun 1.4。迁移后保留了一小部分 node: import,因为 Bun 官方把 Node.js 兼容性当作运行时能力,清除所有 Node API 并不是目标。Bun 的 Node.js 兼容说明也明确把 Next.js 列为兼容目标之一。
先确认 bun run dev 到底启动了谁
最早的脚本看起来已经像 Bun:
{
"scripts": {
"dev": "next dev",
"build": "next build"
}
}执行 bun run dev 时,Bun 负责找到 package script,却会尊重 Next CLI 文件中的 #!/usr/bin/env node。结果就是外层命令是 Bun,实际 CLI 仍可能由 Node.js 执行。
Bun Runtime 文档说明,--bun 会覆盖这类 Node shebang。Bun 官方的 Next.js 指南也给出了对应写法:
{
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start"
}
}真正需要验证的是进程,而不是 package.json 里的第一个单词。在 Windows 开发环境和 Docker 中,我都检查了进程命令,确认最终是 Bun。
workspace 命令统一为 Bun
仓库根目录生成 bun.lock,workspace 仍保持原来的应用边界。跨 workspace 执行命令时显式指定目标:
bun run --filter ./apps/blog check
bun run --filter ./apps/blog test
bun run --filter ./apps/blog build
bun run --filter ./content checkDocker 安装也只选择 Blog 需要的 workspace:
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --filter ./apps/blog--frozen-lockfile 让容器构建在 manifest 与锁文件不一致时直接失败。缓存目录单独挂载,避免每次重建都重新下载依赖。
迁移锁文件时不应顺手把实验目录或不相关包纳入 workspace。包管理器迁移已经足够大,再扩大边界只会让错误更难定位。
测试不再依赖 tsx
原来的测试命令通过 Node.js test runner 和 tsx 执行 TypeScript:
"test": "node --import tsx --test ..."迁移后直接使用:
"test": "bun test"测试文件改为从 bun:test 导入:
import { expect, test } from 'bun:test';这里还做了一个与运行时无关、但很重要的整理:不要只读取 package.json,然后断言脚本字符串中含有 bun。这种测试证明配置文件写了什么,证明不了代码能不能工作。测试应该直接调用 API、读取附件、初始化内容和验证响应。
测试中的 node:fs、node:os 和 node:path 没有全部删除。创建临时目录、递归建目录和递归清理时,Bun 官方的文件 I/O 文档本身也建议继续使用兼容的 node:fs。由 bun test 执行这些模块,不等于测试退回 Node.js。
本地脚本迁移成 TypeScript
两个 .mjs 脚本通过 git mv 改成 .ts:
- 百度 URL 推送脚本。
- 访问压力测试脚本。
迁移后的脚本直接使用 Bun.argv、Bun.env、Bun.file() 和 Bun.write(),不再需要 tsx。content workspace 的 SEO 检查也改为 Bun 直接运行:
{
"scripts": {
"seo:audit": "bun scripts/audit-seo-metadata.ts",
"typecheck": "bun --bun tsc --noEmit"
}
}脚本帮助命令和使用文档是两个不同边界。脚本中的示例应该使用通用地址,避免复制后误压线上;专门的压测文档可以保留真实域名,作为我自己的操作记录。
内容扫描换成 Bun API
原实现使用同步 node:fs 递归扫描 Markdown,再同步读取每篇文章。迁移后使用:
const glob = new Bun.Glob('**/*');
for await (const relativePath of glob.scan({
cwd: contentDir,
onlyFiles: true,
})) {
// 收集文章和附件
}读取正文改为惰性的 Bun.file():
const sourceFile = Bun.file(filePath);
if (!(await sourceFile.exists())) return null;
const source = await sourceFile.text();Bun Glob 文档说明 scan() 返回异步迭代器;Bun 文件 I/O 文档则说明 Bun.file() 创建对象时不会立刻读取磁盘。这个组合很适合内容目录,但路径拼接和目录边界检查仍然保留 node:path。
import.meta.dir 在 bundle 边界上踩了一次坑
content workspace 自己运行时,可以这样导出目录:
export const contentDirectory = import.meta.dir;问题出在 Blog 把这个 workspace 模块交给 Turbopack 后。打包后的模块位置不再等于原始 workspace 目录,生产构建最终拿到错误路径,path.resolve() 收到 undefined 并失败。
最后我没有继续猜 import.meta.dir、import.meta.resolve() 或包入口的打包后含义,而是取消 Blog 对 content 包的运行时依赖。Blog 根据 standalone 的明确工作目录解析根内容目录,next.config.ts 再通过 outputFileTracingIncludes 把内容文件纳入产物。
这个问题和我之前记录的 Next standalone Turbopack trace 源码复制修复属于同一类:开发时能找到文件,不代表 standalone 一定包含它。
Docker builder 和 runner 一起换成 Bun
最终 Dockerfile 的主体如下:
FROM oven/bun:1.4.0-alpine AS builder
WORKDIR /repo
COPY package.json bun.lock bunfig.toml ./
COPY apps/blog/package.json ./apps/blog/package.json
COPY content/package.json ./content/package.json
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --filter ./apps/blog
COPY apps/blog ./apps/blog
COPY content ./content
RUN --mount=type=cache,target=/repo/apps/blog/.next/cache \
bun run --filter ./apps/blog build
FROM oven/bun:1.4.0-alpine AS runner
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder --chown=1000:1000 \
/repo/apps/blog/.next/standalone/node_modules ./node_modules
COPY --from=builder --chown=1000:1000 \
/repo/apps/blog/.next/standalone/content ./content
COPY --from=builder --chown=1000:1000 \
/repo/apps/blog/.next/standalone/apps/blog ./apps/blog
COPY --from=builder --chown=1000:1000 \
/repo/apps/blog/.next/static ./apps/blog/.next/static
COPY --from=builder --chown=1000:1000 \
/repo/apps/blog/public ./apps/blog/public
USER bun
WORKDIR /app/apps/blog
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bun", "server.js"]Next standalone 不会自动把 public 和 .next/static 放到最小服务器目录,必须显式复制。monorepo 产物还会保留应用相对路径,不能照抄单包项目中的 .next/standalone/server.js 位置。
为什么保留 tini,单独写在 Docker 容器为什么需要 tini中。它与 Bun 性能无关,处理的是 Linux PID 1、信号转发和僵尸进程回收。
API 迁移没有交给 hono/bun
健康检查、搜索、附件和 MCP 后来统一交给 Hono,但服务器仍由 Next.js 管理。Route Handler 直接调用 api.fetch(request),没有使用 hono/bun 启动第二个服务器。
这个边界容易混淆,我另写了一篇 在 Next.js Route Handler 中引入 Hono。如果只记一条:框架适配器和 JavaScript 执行器是两回事。
最后验证什么
迁移不能停在 bun run dev 打开首页。我的验证顺序是:
bun run --filter ./apps/blog format
bun run --filter ./apps/blog check
bun run --filter ./apps/blog test
bun run --filter ./content check
bun run --filter ./apps/blog build
docker build -f apps/blog/Dockerfile -t blog-bun:test .容器启动后检查:
docker top显示tini -> bun server.js。- 健康检查返回 200。
- 搜索 API 返回 JSON。
- 附件接口返回正确的 MIME 和文件内容。
- MCP 初始化返回事件流。
- standalone 中存在
content、public和.next/static。
这次迁移最终通过了 29 个测试、TypeScript、ESLint、内容 SEO 审计、258 个页面的生产构建和 Docker 冒烟测试。
我现在怎样定义“迁移完成”
开发、构建、测试、脚本和生产进程都由 Bun 执行,才是这次项目里的“完整迁移”。代码里仍然出现 node:http、node:path、process.env 和 @types/node,这些属于 Next.js 与 Bun 共同支持的接口边界。
如果以后再看到这些字符串,不应该立刻开启下一轮替换。具体判断规则见 Next.js 的 nodejs runtime 为什么仍能由 Bun 执行。