Next.js 的 nodejs runtime 为什么仍能由 Bun 执行

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

我把 Next.js 博客的开发、构建和 Docker 进程全部换成 Bun 后,代码里仍然有这些内容:

export const runtime = 'nodejs';

if (process.env.NEXT_RUNTIME === 'nodejs') {
  await import('./instrumentation-node');
}

还有 node:httpnode:path@types/node。乍看很矛盾:既然已经迁移到 Bun,为什么还保留一堆 Node 字样?

原因是这里混在一起的其实有三层:谁启动 JavaScript、框架按哪套服务器接口编译、业务代码调用哪些 API。

实际执行器看进程命令

开发脚本使用:

{
  "scripts": {
    "dev": "bun --bun next dev",
    "build": "bun --bun next build",
    "start": "bun --bun next start"
  }
}

Docker 使用:

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bun", "server.js"]

这两处决定实际执行器。bun --bun 会覆盖 CLI 文件里的 Node shebang;Bun 的运行时文档专门说明了这个行为。容器里的进程树则直接显示 tini -> bun server.js

所以判断是不是 Bun,应该看命令和进程,不应该搜索源码里有没有 node:

Next.js 的 runtime 是接口模型

Next.js Route Segment Confignodejs 作为默认 runtime。它表示路由可以使用完整的 Node.js API 模型和兼容 npm 包,而不是要求操作系统必须启动名为 node 的二进制文件。

Bun 的目标之一就是实现 Node.js API 兼容。Bun 官方说明会运行 Node.js 测试套件,并把“在 Node.js 可用、在 Bun 失败”的兼容问题视为 Bun bug。于是下面两件事可以同时成立:

  • Next.js 按 nodejs runtime 构建路由。
  • Bun 执行构建结果并提供兼容 API。

这和 Wine 运行 Windows API 程序有一点相似:程序面向哪套接口,与底层由哪个实现提供接口,不是同一个问题。

NEXT_RUNTIME 判断仍然有用

Next.js 的 instrumentation 文档明确使用 process.env.NEXT_RUNTIME 区分运行目标:

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./instrumentation-node');
  }
}

我的 instrumentation-node.ts 会读取本地文件,还会接入 Next 内部 HTTP Server。条件导入的作用是阻止不兼容的编译目标提前加载它。

即使项目从不主动选择 Edge,这个判断也记录了模块边界。删掉判断不会让代码“更 Bun”,只会让环境限定变得不明确。

Edge 不是 Bun 的另一种写法

我一度想过把路由改成:

export const runtime = 'edge';

但这和 Bun 迁移没有直接关系。Edge Runtime 使用受限的 Web API,不支持本地文件系统和完整 Node.js API;Next.js 的 Edge Runtime 文档列出了这些限制。我的博客在服务器启动时扫描 Markdown,附件也来自容器文件系统,不能只改一个常量就放到边缘平台。

Next.js 16.3.3 的 runtime 文档已经把 Route Segment 的 Edge Runtime 标为废弃,并建议移除 edge runtime 配置。就算部署平台仍支持旧入口,我也不会把它当成 Bun 迁移的目标。

真正的边缘迁移需要重新设计内容存储、附件、缓存、MCP 依赖和部署适配器。那是一项部署架构工作,不是字符串替换。

为什么保留 node:http

博客需要记录页面、静态资源、API 和 404 的完整访问日志。Next.js 没有在 Route Handler 层暴露所有这些请求,所以实现接入了它内部使用的 HTTP Server:

import {
  Server,
  type IncomingMessage,
  type ServerResponse,
} from 'node:http';

Bun 执行这段代码,并提供 node:http 兼容实现。把 API 路由交给 Hono 也不能替代它,因为 Hono 看不到 Next.js 页面和静态资源。

只有两个合理的删除条件:不再需要全站访问日志,或者不再由 Next.js 管理 HTTP Server。为了消除 import 而重写服务器,成本远大于收益。

为什么保留 node:path

内容加载要处理 Windows 和 Linux 路径、相对目录边界、扩展名和文件名。Bun 提供 fileURLToPath()pathToFileURL(),但没有一套完整原生 API 覆盖 resolve()relative()dirname()basename()extname()

继续使用 Bun 已兼容的 node:path,比自己实现一套跨平台路径算法可靠。Bun 的文件 I/O 文档也会在原生 API 未覆盖时直接使用 node:fspath,并不要求“纯 Bun 代码”清空所有 Node 模块。

process.env 是框架接口

Next.js 用 process.env.NODE_ENVprocess.env.NEXT_RUNTIMEprocess.env.NEXT_PUBLIC_* 处理构建期与运行期变量。把它们改成 Bun.env 可能让普通脚本更统一,却会离开 Next.js 的公开约定。

我的原则是:

  • Bun 独占的本地脚本可以优先用 Bun.env
  • Next.js 明确规定的环境变量继续用 process.env

同样,命令行脚本中的 process.exitCode = 1 也不必换成 Bun.exit(1)。前者设置最终退出状态,脚本仍有机会完成日志和清理;后者会立即退出,语义并不相同。

为什么 @types/node 不能删除

运行时和类型声明是两回事。安装 @types/bun 之后,Bun API 有了类型,但项目仍然使用 node:http,Next.js 自己也暴露 Node.js 服务器类型。

因此当前类型依赖是:

{
  "devDependencies": {
    "@types/bun": "^1.4.0",
    "@types/node": "^26.4.0"
  }
}

删除 @types/node 不会让产物更小,因为它只是开发依赖;只会让类型检查失去声明。

我现在怎样判断要不要迁移

看到 Node API 时,我先问四个问题:

  1. Bun 是否有覆盖现有行为的稳定 API?
  2. 替换后是否真的更短、更清楚?
  3. Windows、Linux、测试和生产构建是否都能验证?
  4. 这个 API 是否属于 Next.js 的公开约定?

fs.readFile() 读取正文可以换成 Bun.file(),递归扫描可以换成 Bun.Glob,高精度计时可以换成 Bun.nanoseconds()node:path 和 Next.js 的 process.env 则应该留下。

我不会再用“源码中还有多少个 node 字符串”衡量迁移完成度。更可靠的标准是:开发、构建、测试和生产进程由谁执行,保留的兼容层有没有明确用途,生产构建和容器是否真正跑过。

完整迁移步骤记录在 Next.js 16 博客完整迁移到 Bun 1.4,具体 API 取舍则见从 Node.js API 迁移到 Bun 原生 API