发布日期

第 21 讲:Edge Runtime:V8 isolate 与 Node 兼容层

Edge Runtime 的 V8 isolate 沙箱、Node 兼容层与 Web API 限制

前面我们一直在讲 Node.js runtime 下的渲染:next-server.ts 里的 NextNodeServerrenderToPipeableStream、Node Streams。但 Next.js 还有另一个完整的渲染分支——Edge Runtime,跑在 V8 isolate(Cloudflare Workers / Vercel Edge / Deno Deploy 等"无 Node API"环境)里。

本讲拆 Edge Runtime 的内部实现:@edge-runtime/vmadapter()、sandbox context、Web Streams、和 Node Runtime 的兼容层。

学习目标

读完本讲,你能:

  1. 解释 Edge Runtime 和 Node Runtime 的本质差异:没有 require、没有 fs、没有 Buffer(除非 polyfill)、只有 Web API。
  2. 看懂 adapter() 函数——它是 middleware / edge route handler / edge page 的统一入口。
  3. 理解 sandbox:dev 模式下用 edge-runtime 包在 V8 isolate 里跑用户代码;生产打包成"自包含的 worker bundle"。
  4. 解释 process.env.NEXT_RUNTIME === 'edge' 这个 build-time flag 是如何在源码里被 DCE 用来精简 bundle 的。
  5. 在生产排障时定位 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.tssandbox.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?

  1. 冷启动快:V8 isolate 的启动毫秒级,比 Node 容器秒级冷启动快两个数量级。
  2. 分布式部署:Vercel/Cloudflare 把 worker bundle 部署到全球 300+ POP,用户访问最近节点。延迟从 200ms 降到 30ms。
  3. 资源限制:Edge 通常对 CPU/内存/响应时间有严格约束(Vercel 默认 30s、CF Workers 默认 30ms CPU)。这对 middleware 这种"轻量但高频"的代码完美。

代价:

  • 没有 Node APIfsnetchild_processcrypto(部分)、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()

130:packages/next/src/server/web/adapter.ts
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 类似但精简:

  1. ensureInstrumentationRegistered():edge 也支持 instrumentation.ts,但只能用 web-compatible 的 export。
  2. normalizeRscURL:把 RSC 请求 URL 标准化。
  3. 构造 NextURL + NextRequest:edge 里没有 Node 的 IncomingMessage,直接用 Web Fetch API 的 Request/URL
  4. isEdgeRendering:通过判断 __BUILD_MANIFEST 全局是否存在区分 middleware vs page 渲染。
  5. 执行用户的 handler(page render / middleware function / route handler)。

注意 adapter 的结尾:

return { response: result, waitUntil: Promise.resolve() };

返回 FetchEventResult,被 sandbox 包装后吐回 router-server。

四、NextRequestHint:在 edge 里增强 NextRequest

71:packages/next/src/server/web/adapter.ts
export class NextRequestHint extends NextRequest {
  // ...
}

NextRequestHintNextRequest(用户能用的 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:

280:packages/next/src/server/web/sandbox/context.ts
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
      },
    })

关键设计:

  1. new EdgeRuntime({ codeGeneration }):创建一个独立 V8 context,dev 允许 string code generation(让 hot reload 注入新代码)。
  2. extend(context):注入 polyfill:
    • context.process:edge runtime 本身没有 process,但用户代码可能读 process.env,所以注入一个最小 polyfill。
    • context.require:edge 里不应该有 require,但 Next.js 把白名单的 native module(node:crypto 的 web 兼容子集等)通过 NativeModuleMap 暴露——只有白名单内能用。
  3. __next_eval____next_webassembly_*__:dev 模式下保留 eval/WebAssembly,但记录警告,提示用户生产会失败。
305:packages/next/src/server/web/sandbox/context.ts
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

120:packages/next/src/server/web/sandbox/sandbox.ts
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}`]

工作流程:

  1. getRuntimeContext(params):拿到(或创建)这个 page/middleware 对应的 EdgeRuntime instance。
  2. evaluate user code:在 sandbox 里执行用户代码,把 _ENTRIES[middleware_${name}] 设为用户 export 的 default handler。
  3. invoke handler:用上面构造的 NextRequest 调用 handler,得到 Response
  4. 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。它会:

  1. 收集所有 runtime = 'edge' 的 page / route / middleware entries
  2. 用 webpack 的 'webworker' target 打包(DCE 删 Node 专属代码)
  3. 输出 .next/server/edge/<entry>.js
  4. 生成 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) &#123;
  return Response.json(...)
&#125;

它的入口流程:

  1. router-server 看到这是 edge route → 走 sandbox / edge bundle
  2. edgeRouteModuleWrapper.handle(req) 内部读 runtime = 'edge'
  3. 调用 routeModule.handle(req, ctx)
  4. 返回 Response

Edge Page 走的是另一条路径:通过 adapter()isEdgeRendering = true 分支,进入 routeModule.render(req, res, ctx)——本质上是 NextWebServer.handleRequest 路径的入口。

十三、Edge Middleware:最经典的 edge 用法

// middleware.ts
import &#123; NextResponse &#125; from 'next/server'
import type &#123; NextRequest &#125; from 'next/server'

export async function middleware(req: NextRequest) &#123;
  // 1. 鉴权
  const token = req.cookies.get('token')?.value
  if (!token && req.nextUrl.pathname.startsWith('/dashboard')) &#123;
    return NextResponse.redirect(new URL('/login', req.url))
  &#125;

  // 2. AB 测试 cookie 注入
  const bucket = Math.random() > 0.5 ? 'a' : 'b'
  const res = NextResponse.next()
  res.cookies.set('ab-bucket', bucket)

  return res
&#125;

export const config = &#123;
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
&#125;

每个请求都会跑一次 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 limitbundle 太大把大依赖(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://')ForbiddenEdge 默认不允许跨大区 / 同区请求自己检查目标 URL、CF Workers 的 outbound fetch 限制
process.env.X 在 edge 是 undefinededge 不像 Node 自动加载 .envnext.config.jsenv 字段或 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

观察点:

  1. build log 里 λ /node/health vs ƒ /edge/health (Edge Runtime),标记不同。
  2. curl -w '%{time_starttransfer}\n' 对比两端的 TTFB。
  3. 删除 /edge-error/page.tsx 顶部的注释,让它用 Buffer.from('x'),重新 build 会失败。
  4. curl -i http://localhost:3021/dashboard 触发 middleware 重定向到 /login。

十六、本讲小结

  1. Edge Runtime = V8 isolate + Web API only;冷启动 ms 级、全球部署、严格资源限制。
  2. adapter() 是 edge code 的统一入口,等价于 Node 下的 BaseServer.handleRequest
  3. dev 用 @edge-runtime/vm 跑沙箱、生产打包成 self-contained worker bundle。
  4. process.env.NEXT_RUNTIME === 'edge' 是 build-time flag,配合 DCE 让 edge bundle 精简。
  5. 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 的耦合。