- 发布日期
第 21 讲:Edge Runtime:V8 isolate 与 Node 兼容层
Edge Runtime 的 V8 isolate 沙箱、Node 兼容层与 Web API 限制
前面我们一直在讲 Node.js runtime 下的渲染:
next-server.ts里的NextNodeServer、renderToPipeableStream、Node Streams。但 Next.js 还有另一个完整的渲染分支——Edge Runtime,跑在 V8 isolate(Cloudflare Workers / Vercel Edge / Deno Deploy 等"无 Node API"环境)里。本讲拆 Edge Runtime 的内部实现:
@edge-runtime/vm、adapter()、sandbox context、Web Streams、和 Node Runtime 的兼容层。
学习目标
读完本讲,你能:
- 解释 Edge Runtime 和 Node Runtime 的本质差异:没有
require、没有 fs、没有 Buffer(除非 polyfill)、只有 Web API。 - 看懂
adapter()函数——它是 middleware / edge route handler / edge page 的统一入口。 - 理解 sandbox:dev 模式下用
edge-runtime包在 V8 isolate 里跑用户代码;生产打包成"自包含的 worker bundle"。 - 解释
process.env.NEXT_RUNTIME === 'edge'这个 build-time flag 是如何在源码里被 DCE 用来精简 bundle 的。 - 在生产排障时定位 Edge 特有错误("Dynamic code evaluation not allowed"、"Module not found at edge")。
本讲对应代码:
packages/next/src/server/web/adapter.ts(edge 入口)packages/next/src/server/web/sandbox/context.ts、sandbox.ts(dev 沙箱)packages/next/src/server/web/edge-route-module-wrapper.ts(Edge Route Handler 入口)packages/next/src/build/webpack/plugins/middleware-plugin.ts(edge 打包逻辑)
一、为什么需要 Edge Runtime
Node Runtime 已经能跑 React + RSC,为什么还要 Edge?
- 冷启动快:V8 isolate 的启动毫秒级,比 Node 容器秒级冷启动快两个数量级。
- 分布式部署:Vercel/Cloudflare 把 worker bundle 部署到全球 300+ POP,用户访问最近节点。延迟从 200ms 降到 30ms。
- 资源限制:Edge 通常对 CPU/内存/响应时间有严格约束(Vercel 默认 30s、CF Workers 默认 30ms CPU)。这对 middleware 这种"轻量但高频"的代码完美。
代价:
- 没有 Node API:
fs、net、child_process、crypto(部分)、Buffer等都不可用 - 包大小受限:Vercel Edge 默认 1MB
- 运行时受限:禁止
eval()、new Function()、WebAssembly.compile(生产)
业务示例:你的 middleware 做用户鉴权 + AB 测试 cookie 设置,跑 Edge 完美;但商品详情页要查 MySQL,就只能跑 Node Runtime。
二、声明 Edge:runtime = 'edge'
每个 page / route / middleware 可以独立选择 runtime:
// app/api/health/route.ts
export const runtime = "edge"; // 默认 'nodejs'
export async function GET(req: Request) {
return Response.json({ ok: true, at: new Date().toISOString() });
}
// middleware.ts
import { NextResponse } from "next/server";
export const config = { runtime: "experimental-edge" }; // 旧写法,现已是默认
export function middleware(req: NextRequest) {
// ...
}
build 时这些段会被打包成 edge bundle(不同的 chunk);runtime 时由 router-server 决定走 NextNodeServer 还是 NextWebServer。
三、Edge Runtime 的核心入口:adapter()
export async function adapter(
params: AdapterOptions
): Promise<FetchEventResult> {
ensureTestApisIntercepted()
await ensureInstrumentationRegistered()
// TODO-APP: use explicit marker for this
const isEdgeRendering =
typeof (globalThis as any).__BUILD_MANIFEST !== 'undefined'
params.request.url = normalizeRscURL(params.request.url)
const requestURL = params.bypassNextUrl
? new URL(params.request.url)
: new NextURL(params.request.url, {
headers: params.request.headers,
nextConfig: params.request.nextConfig,
})
adapter() 是 所有 edge 代码的统一入口。它做的事情和 NextNodeServer.handleRequest 类似但精简:
ensureInstrumentationRegistered():edge 也支持instrumentation.ts,但只能用 web-compatible 的 export。normalizeRscURL:把 RSC 请求 URL 标准化。- 构造
NextURL+NextRequest:edge 里没有 Node 的IncomingMessage,直接用 Web Fetch API 的Request/URL。 isEdgeRendering:通过判断__BUILD_MANIFEST全局是否存在区分 middleware vs page 渲染。- 执行用户的 handler(page render / middleware function / route handler)。
注意 adapter 的结尾:
return { response: result, waitUntil: Promise.resolve() };
返回 FetchEventResult,被 sandbox 包装后吐回 router-server。
四、NextRequestHint:在 edge 里增强 NextRequest
export class NextRequestHint extends NextRequest {
// ...
}
NextRequestHint 在 NextRequest(用户能用的 API)基础上加了 page、__isData 等内部字段。它们不在公开类型里,方便 Next.js 内部传 metadata 而不污染用户 API。
五、sandbox:dev 模式下的 V8 isolate
dev 模式不能把 edge code 打成生产 bundle,因为代码每秒可能改。Next.js 用 @edge-runtime/vm(vendored 在 next/dist/compiled/edge-runtime)跑一个真实的 V8 isolate:
const runtime = new EdgeRuntime({
codeGeneration:
process.env.NODE_ENV !== 'production'
? { strings: true, wasm: true }
: undefined,
extend: (context) => {
context.process = createProcessPolyfill(edgeFunctionEntry.env)
Object.defineProperty(context, 'require', {
enumerable: false,
value: (id: string) => {
const value = NativeModuleMap.get(id)
if (!value) {
throw TypeError('Native module not found: ' + id)
}
return value
},
})
关键设计:
new EdgeRuntime({ codeGeneration }):创建一个独立 V8 context,dev 允许 string code generation(让 hot reload 注入新代码)。extend(context):注入 polyfill:context.process:edge runtime 本身没有 process,但用户代码可能读process.env,所以注入一个最小 polyfill。context.require:edge 里不应该有 require,但 Next.js 把白名单的 native module(node:crypto的 web 兼容子集等)通过NativeModuleMap暴露——只有白名单内能用。
__next_eval__、__next_webassembly_*__:dev 模式下保留 eval/WebAssembly,但记录警告,提示用户生产会失败。
context.__next_eval__ = function __next_eval__(fn: Function) {
const key = fn.toString()
if (!warnedEvals.has(key)) {
const warning = getServerError(
new Error(
`Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Edge Runtime
Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation`
),
COMPILER_NAMES.edgeServer
)
warning.name = 'DynamicCodeEvaluationWarning'
Error.captureStackTrace(warning, __next_eval__)
warnedEvals.add(key)
options.onWarning(warning)
}
return fn()
}
这是为什么 dev 下能跑、生产突然报错——__next_eval__ 是 build-time SWC transform 注入的"包装函数",生产 webpack 会拒绝任何 eval 调用。
六、sandbox.ts: getRuntimeContext 与 run
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
const runtime = await getRuntimeContext(params)
const subreq = params.request.headers[`x-subrequest-id`]
// ...
await runtime.context._ENTRIES[`middleware_${params.name}`]
工作流程:
getRuntimeContext(params):拿到(或创建)这个 page/middleware 对应的 EdgeRuntime instance。- evaluate user code:在 sandbox 里执行用户代码,把
_ENTRIES[middleware_${name}]设为用户 export 的 default handler。 - invoke handler:用上面构造的
NextRequest调用 handler,得到Response。 - wrap result:包装成
FetchEventResult。
每次 dev hot reload 后,对应 EdgeRuntime 会被 clearModuleContext(path) 销毁,下一次请求重新创建。
七、生产打包:自包含的 worker bundle
dev 用 sandbox + EdgeRuntime VM,但生产部署到 Vercel/Cloudflare 时,每个 edge entry 都被打包成 单文件 self-contained bundle:
- 入口:
adapter()的调用 - 用户 page module + 所有 dependencies
- React server runtime(edge 版)+ RSC payload encoder
- next/server 的 polyfill(NextResponse、NextRequest 等)
打包入口在 packages/next/src/build/webpack/plugins/middleware-plugin.ts。它会:
- 收集所有
runtime = 'edge'的 page / route / middleware entries - 用 webpack 的
'webworker'target 打包(DCE 删 Node 专属代码) - 输出
.next/server/edge/<entry>.js - 生成
middleware-manifest.json,告诉 router-server "哪些路由走 edge"
八、process.env.NEXT_RUNTIME:build-time flag
整个 Next.js 源码里到处可见:
if (process.env.NEXT_RUNTIME === "edge") {
// 不能用 Node API 的实现
} else {
// 用 fs / Buffer / etc
}
NEXT_RUNTIME 不是普通环境变量,而是 webpack/turbopack 的 define plugin 在 build 时根据 entry runtime 替换:
- node entry:替换为
'nodejs' - edge entry:替换为
'edge'
DCE 直接消掉对应分支,edge bundle 里完全不包含 fs/Buffer 这种代码。这就是为什么 edge bundle 能压到 < 1MB。
实战:你在
apple-pay/server-action.ts里写import fs from 'node:fs',部署到 Vercel 生产 edge → build 失败Module not found at edge。修复:要么把 server-action 改成 nodejs runtime、要么 dynamic import 并用if (process.env.NEXT_RUNTIME !== 'edge')守护。
九、NextWebServer 与 NextNodeServer 的对照
NextNodeServer (next-server.ts)
├─ findPageComponents (require page module)
├─ renderHTMLImpl → lazyRenderAppPage
├─ Node Streams (renderToPipeableStream)
├─ Node fs / Buffer / IncrementalCache (filesystem)
└─ Node HTTP req/res
NextWebServer (next-server-web.ts)
├─ 没有 findPageComponents,page module 已经在 bundle 里
├─ renderHTMLImpl → 直接调 routeModule.render(同 module)
├─ Web Streams (renderToReadableStream)
├─ Web Cache API(如果可用)/ NoFlushIncrementalCache
└─ Web Request/Response
base-server 把这些差异抽象掉。所有上层(matchers、ResponseCache、AppPageRouteModule)都用统一接口。
十、Edge RSC:与 Node RSC 的差异
回顾第 16 讲,Node 下 RSC payload 通过 react-server-dom-webpack/server.node.renderToPipeableStream 生成;Edge 下用 react-server-dom-webpack/server.edge.renderToReadableStream(注意是 server.edge,专为 Web Streams 编写)。
差异:
- Buffer 编码:Edge 用
TextEncoder/Uint8Array;Node 用Buffer。 - 错误堆栈:Edge 没有 Node 的源码映射,错误显示路径是 webpack module ID(如
(app-pages-browser)/./app/page.tsx)。 - fetch:Edge 的
fetch是原生 Web Fetch;Node 的fetch是 undici(被 patchFetch 包装)。两边的拦截逻辑略不一样。
十一、Edge 不能做的事 vs 替代方案
| 不能直接做 | Edge 替代方案 |
|---|---|
import fs from 'node:fs' | 把数据移到 KV/Cache/D1 |
Buffer.from(...) | new TextEncoder().encode(...) / Uint8Array |
child_process.exec | 不可替代——必须 Node |
crypto.createHash('sha256') | crypto.subtle.digest('SHA-256', ...)(Web Crypto) |
| MySQL/Postgres 直连 | Vercel Postgres / PlanetScale serverless / Neon serverless(HTTP 协议) |
native module(.node 文件) | 找 pure JS / WASM 替代 |
| 长任务(> 30s CPU) | 不可在 edge 跑——拆到 Node API route |
eval / new Function | 重构成静态代码 |
十二、Edge Route Handler vs Edge Page
edge-route-module-wrapper.ts 包装的是 Route Handler(app/api/*/route.ts):
export async function GET(req: Request) {
return Response.json(...)
}
它的入口流程:
- router-server 看到这是 edge route → 走 sandbox / edge bundle
edgeRouteModuleWrapper.handle(req)内部读runtime = 'edge'- 调用
routeModule.handle(req, ctx) - 返回
Response
Edge Page 走的是另一条路径:通过 adapter() 的 isEdgeRendering = true 分支,进入 routeModule.render(req, res, ctx)——本质上是 NextWebServer.handleRequest 路径的入口。
十三、Edge Middleware:最经典的 edge 用法
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
// 1. 鉴权
const token = req.cookies.get('token')?.value
if (!token && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.url))
}
// 2. AB 测试 cookie 注入
const bucket = Math.random() > 0.5 ? 'a' : 'b'
const res = NextResponse.next()
res.cookies.set('ab-bucket', bucket)
return res
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
每个请求都会跑一次 middleware(matcher 决定)。它在 router-server 的 resolveRoutes 流程里被调用,通过 sandbox 跑用户代码,结果(rewrite / redirect / 继续)被 router-server 应用。
业务示例:一个新闻站把 middleware 用来:
- 检查 Geo IP,把美国用户重定向到
/us、中国用户到/cn(用req.geo,Vercel/CF 注入)- 给所有响应加自定义 header
x-request-id- 检查 cookie 决定深色/浅色主题(rewrite 加 query),不影响 cache key
整套逻辑 P99 在 5~10ms 内完成,且无需启动 Node 容器。
十四、生产排障实战清单
| 现象 | 原因 | 排查 |
|---|---|---|
Dynamic Code Evaluation not allowed in Edge Runtime | 用了 eval / new Function | 改成静态代码 |
The Edge Function "middleware" size is X KB and exceeds the limit | bundle 太大 | 把大依赖(lodash full、 moment)换成 tree-shakeable / 移到 nodejs route |
Module not found at edge | 尝试 import Node-only module | 用 process.env.NEXT_RUNTIME 守护、或拆成 nodejs route |
| Edge 函数 ETIMEOUT / CPU 超限 | 长任务 | 移到 nodejs / 拆解 / 用 ReadableStream 流式输出 |
用 fetch('https://') 报 Forbidden | Edge 默认不允许跨大区 / 同区请求自己 | 检查目标 URL、CF Workers 的 outbound fetch 限制 |
process.env.X 在 edge 是 undefined | edge 不像 Node 自动加载 .env | 用 next.config.js 的 env 字段或 experimental.serverRuntimeConfig |
| dev OK、生产白屏 | dev 的 __next_eval__ 警告变成生产报错 | 找出 eval 用法 |
| Edge 调用了 React 19 新 API 但报错 | edge bundle 的 React 版本不一致 | 检查 react-server-dom-webpack 的 edge entry 是否正确 |
十五、配套 fixture:Edge vs Node 对比
fixtures/lecture-21/ 准备了:
/node/health:Node Route Handler/edge/health:Edge Route Handler(看 startup 速度差异)/middleware-demo:演示 middleware 注入 header / redirect / rewrite/edge-page:一个runtime = 'edge'的 page(注意 dynamic 渲染)/edge-error:故意在 edge 用Buffer.from('x'),看 build 错误
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-21
pnpm install
pnpm build
pnpm start
观察点:
- build log 里
λ /node/healthvsƒ /edge/health (Edge Runtime),标记不同。 - 用
curl -w '%{time_starttransfer}\n'对比两端的 TTFB。 - 删除
/edge-error/page.tsx顶部的注释,让它用Buffer.from('x'),重新 build 会失败。 curl -i http://localhost:3021/dashboard触发 middleware 重定向到 /login。
十六、本讲小结
- Edge Runtime = V8 isolate + Web API only;冷启动 ms 级、全球部署、严格资源限制。
adapter()是 edge code 的统一入口,等价于 Node 下的BaseServer.handleRequest。- dev 用
@edge-runtime/vm跑沙箱、生产打包成 self-contained worker bundle。 process.env.NEXT_RUNTIME === 'edge'是 build-time flag,配合 DCE 让 edge bundle 精简。- middleware / route handler / page 都能跑 edge,但限制各不同:middleware 跑次数最多、route/page 自由度高些。
下讲预告
第 22 讲《Route Handlers / Server Actions:HTTP 接口的两套范式》。我们会对比 app/api/.../route.ts 与 Server Actions(第 12 讲已部分讲过)的内部实现差异,覆盖 method dispatch、streaming response、cookies / headers 写入、错误传播、以及它们和 cache / revalidate 的耦合。