Next.js 16 博客完整迁移到 Bun 1.4

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

我以前已经让 Bun 跑过 Next.js 的 standalone server.js,但开发和构建仍由 Node.js 执行。那只能叫“生产运行时换成 Bun”,还不是完整迁移。

这次我把边界推到底:依赖安装、workspace 命令、next devnext 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 check

Docker 安装也只选择 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:fsnode:osnode:path 没有全部删除。创建临时目录、递归建目录和递归清理时,Bun 官方的文件 I/O 文档本身也建议继续使用兼容的 node:fs。由 bun test 执行这些模块,不等于测试退回 Node.js。

本地脚本迁移成 TypeScript

两个 .mjs 脚本通过 git mv 改成 .ts

  • 百度 URL 推送脚本。
  • 访问压力测试脚本。

迁移后的脚本直接使用 Bun.argvBun.envBun.file()Bun.write(),不再需要 tsxcontent 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.dirimport.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 中存在 contentpublic.next/static

这次迁移最终通过了 29 个测试、TypeScript、ESLint、内容 SEO 审计、258 个页面的生产构建和 Docker 冒烟测试。

我现在怎样定义“迁移完成”

开发、构建、测试、脚本和生产进程都由 Bun 执行,才是这次项目里的“完整迁移”。代码里仍然出现 node:httpnode:pathprocess.env@types/node,这些属于 Next.js 与 Bun 共同支持的接口边界。

如果以后再看到这些字符串,不应该立刻开启下一轮替换。具体判断规则见 Next.js 的 nodejs runtime 为什么仍能由 Bun 执行