陌上人如玉
公子世无双

9.8 Edge Runtime

9.8 Edge Runtime

一句话:Edge Runtime 不是「Node.js 的另一个版本」,而是「一个基于 V8 isolate 的、只有 Web 标准 API 的轻量运行时」——它的价值是「冷启动 5ms 级 + 就近部署」,而它的约束(没有 fs/net/child_process、内存与 CPU 时间受限、不保证实例存活)决定了「它只适合『轻量的请求处理』」。

一、本质:V8 isolate ≠ Node.js 容器

⭐⭐⭐ 「Edge Runtime 是什么」的一句话版本:

   它是「**把一段 JS 跑在离用户最近的边缘节点上,且用 V8 isolate(而不是容器)隔离**」的运行时。
   ⭐ 所以它不是「Node.js 的一个子集」,而是「**另一套宿主环境**」:
     · ✅ 只有 **Web 标准 API**(fetch / Request / Response / crypto / streams…)
     · ❌ 没有 **Node 内置模块**(fs / path / net / child_process / crypto(node)…)
     · ❌ 没有 **原生扩展**(任何「需要编译原生代码」的依赖都跑不了)

   ⭐ 于是「能不能用 Edge」这个问题的答案通常是:
     · ✅ 能:「鉴权、重定向、A/B 分桶、地理路由、抓取+改写、轻量 API、流式响应」
     · ❌ 不能:「读写文件、连传统的 TCP 数据库、用原生依赖、跑长任务」

⭐⭐⭐ 与 Node.js Runtime 的对比(这是最该背下来的一张表):

   | 维度 | Node.js Runtime | Edge Runtime |
   | --- | --- | --- |
   | 启动 | ⚠️ 容器/进程(**冷启动 100ms~1s**) | ⭐ **V8 isolate(冷启动约 5ms)** |
   | 位置 | 固定区域(如 `us-east-1`) | ⭐ **就近(全球边缘节点)** |
   | 可用 API | ⭐ 全部 Node 内置 + npm 生态 | ⚠️ **只有 Web 标准 API** |
   | `fs`/`net`/`child_process` | ✅ | ❌ **完全没有** |
   | 内存 | 较大(可配到 GB 级) | ⚠️ **受限**(如 128MB) |
   | 执行时间 | 较长(可配几十秒到几分钟) | ⚠️ **受限**(CPU 时间通常几秒;墙钟时间看平台) |
   | 实例存活 | ✅ 可以「常驻」 | ⚠️ **不保证**(会被回收,**不要依赖内存里的全局状态**) |
   | 长连接 | ✅(WebSocket 服务端、TCP) | ⚠️ 受限(部分平台支持 WS,但**没有 TCP 客户端**) |
   | 传统数据库驱动 | ✅(TCP 连接池) | ❌ **无 TCP** → 必须用 **HTTP 驱动**或**连接池代理** |
   | 依赖体积 | 大(node_modules) | ⚠️ **越小越好**(影响冷启动/部署) |
   | 适合 | ⭐ 「重活」:SSR 渲染、复杂 API、DB 密集 | ⭐ **「轻活」:中间件、鉴权、路由、改写** |

二、Edge 能用的 API:白名单(必须记)

⭐⭐⭐ 「Edge Runtime 有什么」——⭐ 背这张清单比背「没有什么」有用:

   ✅ 【Web 标准(核心)】
     · `fetch`、`Request`、`Response`、`Headers`、`FormData`、`URL`、`URLSearchParams`
     · `ReadableStream` / `WritableStream` / `TransformStream`、`TextEncoder`/`TextDecoder`
     · `AbortController` / `AbortSignal`、`structuredClone`、`Blob` / `File`
     · ⭐ **`crypto`(WebCrypto)**:`crypto.subtle`、`crypto.randomUUID`、`crypto.getRandomValues`
     · `atob` / `btoa`、`setTimeout` / `setInterval` / `queueMicrotask`、`console`
     · ⭐ `Cache` API(`caches.open()`,见 10.11)
   ✅ 【平台扩展(看具体平台)】
     · ⭐ **`waitUntil(promise)`**(Vercel/Cloudflare)——「**响应返回后继续做的工作**」
       ⭐ 这是「边缘里做『异步副作用』(埋点/日志/缓存写)」的正确方式
     · `after()`(Next.js 15 的新 API,语义与 `waitUntil` 类似)
     · 平台专有:KV / D1 / R2 / Queues(Cloudflare)、`geolocation`、`request.cf`(Cloudflare 的地理信息)

   ❌ 【没有】
     · `fs` / `path` / `os` / `net` / `tls` / `dns` / `child_process` / `worker_threads`
     · ⚠️ **Node 的 `crypto` 模块**(但有 WebCrypto,⭐ 注意「API 完全不同」)
     · `Buffer`(⭐ 有些平台提供 polyfill,但**不要依赖**;用 `Uint8Array`/`TextEncoder`)
     · ⚠️ **`process`**(有些字段只读或被 polyfill;⭐ 别用它做「读环境变量之外的事」)
     · ❌ **`eval` / `new Function`**(⭐ CSP 默认禁止;Cloudflare 明确不支持)
     · ⚠️ **「请求结束后还在跑的定时器」**(会被杀掉;要延续就 `waitUntil`)
/** ⭐⭐ 一个「只在 Edge 能写」的中间件:鉴权 + A/B 分桶 + 地理路由 + 改写 */
// middleware.ts(Next.js 的 Edge Middleware,跑在**所有请求之前**)
import { NextResponse, type NextRequest } from 'next/server';

export const config = {
  // ⭐ 声明「哪些路径走中间件」(⚠️ 中间件「每个请求都跑」,要尽量减少匹配范围)
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

/** ⭐ 一致性哈希分桶(⭐ 与 8.9 的算法完全一致 —— 这一点极其重要) */
function bucketOf(flagKey: string, userId: string): number {
  let hash = 0x811c9dc5;
  const input = `${flagKey}:${userId}`;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return (hash % 10_000) / 10_000;
}

export async function middleware(request: NextRequest) {
  const { pathname, origin } = request.nextUrl;

  /** ============ ① 鉴权(⭐ Edge 最典型的用途:把「拦截」放到离用户最近处) ============ */
  const session = request.cookies.get('session')?.value;

  if (pathname.startsWith('/app') && !session) {
    const loginUrl = new URL('/login', origin);
    // ⭐ 带上「我想去哪」(⭐ 服务端拼接,天然防开放重定向 —— 因为「只拼站内路径」)
    loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
    return NextResponse.redirect(loginUrl);
  }

  // ⭐ 注意:Edge 里「验证 session 签名」只能做「轻量校验」
  //    (用 WebCrypto 验签,⭐ 或「只检查 cookie 存在性 + 让下游做真正鉴权」)
  if (pathname.startsWith('/app') && session) {
    const valid = await verifySessionSignature(session);
    if (!valid) {
      const res = NextResponse.redirect(new URL('/login', origin));
      res.cookies.delete('session');
      return res;
    }
  }

  /** ============ ② A/B 分桶(⭐ 在边缘做,用户「第一次请求」就拿到正确版本) ============ */
  let response: NextResponse;

  if (pathname === '/') {
    // ⭐ 用「登录用户 id 或匿名 cookie」做分桶(⭐ 保证「同一用户稳定同组」)
    const userId = session ? extractUserId(session) : (request.cookies.get('anon_id')?.value ?? crypto.randomUUID());
    const variant = bucketOf('release_new_home', userId) < 0.5 ? 'b' : 'a';

    // ⭐⭐ 关键:把「变体」写进请求头 → 下游的 Server Component / RSC 直接读它
    //    (⭐ 这样服务端「渲染时就出正确的版本」→ **零闪烁**,见 8.9)
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-ab-variant', variant);

    response = NextResponse.next({ request: { headers: requestHeaders } });

    // ⭐ 首次访问时「种下匿名 id」(⭐ 这样后续请求分桶稳定)
    if (!request.cookies.get('anon_id')) {
      response.cookies.set('anon_id', userId, {
        httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 365,
        secure: process.env.NODE_ENV === 'production',
      });
    }
  } else {
    response = NextResponse.next();
  }

  /** ============ ③ 地理路由(⭐ 边缘的独特能力:拿到「就近节点」的地理信息) ============ */
  // ⚠️ 「地理信息」的来源是「平台提供的头/属性」(⚠️ 不要「前端猜」)
  //    Vercel: request.geo;Cloudflare: request.cf.country
  const country = (request as unknown as { geo?: { country?: string } }).geo?.country;

  if (country && pathname.startsWith('/shop')) {
    // ⭐ 把「地区」注入请求头(下游据此「选货币/语言/合规提示」)
    response.headers.set('x-user-country', country);
  }

  /** ============ ④ 安全头(⭐ 中间件是「统一加安全头」的最佳位置) ============ */
  const securityHeaders: Record<string, string> = {
    'x-content-type-options': 'nosniff',
    'referrer-policy': 'strict-origin-when-cross-origin',
    'x-frame-options': 'SAMEORIGIN',
    // ⚠️ CSP 要小心(⭐ 它会「影响 Worker / 内联脚本」,见 8.10)
  };
  for (const [k, v] of Object.entries(securityHeaders)) response.headers.set(k, v);

  /** ============ ⑤ 边缘缓存(⭐ 让「可缓存的公开页面」在边缘直接命中) ============ */
  if (pathname.startsWith('/blog/')) {
    // ⭐⭐ 「边缘缓存」的正确姿势:让 CDN/平台缓存「这个响应」
    //    ⚠️ 但「带 cookie 的个性化页面」不能这样(⭐ 会「串用户」)
    const isPersonalized = request.cookies.has('session');
    if (!isPersonalized) {
      response.headers.set('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');
    }
  }

  return response;
}

/** ⭐ Edge 里的「验签」(用 WebCrypto,⚠️ 不是 Node 的 crypto) */
async function verifySessionSignature(token: string): Promise<boolean> {
  const [payload, signature] = token.split('.');
  if (!payload || !signature) return false;

  const secret = process.env.SESSION_SECRET;
  if (!secret) return false;

  // ⭐ WebCrypto 的 HMAC(⭐ 与 Node 的 API 完全不同,这是「Edge 迁移」的常见改动点)
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );

  const sigBytes = Uint8Array.from(atob(signature.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));

  return crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
}

function extractUserId(token: string): string {
  // ⚠️ 真实实现要「验签后解析」(这里只做「形态示意」)
  try {
    const payload = JSON.parse(atob(token.split('.')[0] ?? '')) as { sub?: string };
    return payload.sub ?? 'anon';
  } catch {
    return 'anon';
  }
}

三、Edge 的四条「硬约束」与应对

⭐⭐⭐ 约束 ①【没有 TCP → 传统数据库驱动用不了】

   ⚠️ 「在 Edge 里 `new PrismaClient()`」→ 直接失败(Prisma 需要 TCP)
   ✅ 三条出路:
     · ⭐ **HTTP 驱动**:Neon serverless driver(`@neondatabase/serverless`)、
       PlanetScale HTTP、Turso(libSQL over HTTP)、Drizzle 的 HTTP 模式
     · ⭐ **连接池代理**:Prisma Accelerate / Data Proxy、Supabase Supavisor
       (⭐ 「边缘 → 代理(HTTP/WS)→ 数据库(TCP)」,代理负责连接池)
     · ⭐ **不在 Edge 里连库**:Edge 只做「鉴权/路由/改写」,真正的数据请求
       「转发给 Node runtime 或后端服务」(⭐ 这是最稳的做法)

⭐⭐⭐ 约束 ②【不保证实例存活 → 不要依赖「内存里的全局状态」】

   ⚠️ 「把计数器放在模块级变量里」→ 在 Edge 上「不可靠」
     (⭐ 因为「不同请求可能落在不同 isolate」+「实例会被回收」)
   ✅ 需要「共享状态」就用:
     · 平台 KV(Cloudflare KV / Vercel KV)
     · Durable Objects(Cloudflare,⭐ 能做「有状态」的强一致)
     · 外部 Redis(⚠️ 需要 HTTP 接口)
   ⭐ 判据:「**这段状态需要跨请求/跨实例共享吗**」——需要就不该放内存。

⭐⭐⭐ 约束 ③【CPU 时间与内存受限 → 不适合「重活」】

   ⚠️ 「解析大文件、跑复杂计算、生成大报告」→ 会「超时」或「OOM」
   ✅ 这些交给:
     · Node runtime(Serverless Function,可配更长超时与更大内存)
     · ⭐ **后台任务/队列**(如「提交任务 → 异步 Worker 处理 → 轮询/推送结果」)
     · ⭐ 前端 Worker(见 8.10,⭐ 如果「计算可以在客户端做」)

⭐⭐⭐ 约束 ④【依赖要「越小越好」】

   ⚠️ 每个 Edge 函数的代码都会「被打包并分发到全球节点」→
     依赖越大,「部署时间」与「冷启动」都变差
   ✅ 做法:
     · ⭐ 「按需 import」(避免 barrel 文件,见 10.14)
     · 用「Web 标准」的轻量库(如 `jose` 做 JWT,而不是 `jsonwebtoken`)
     · ⭐ 把「重依赖」留给 Node runtime 的接口
/** ⭐⭐ 一个「Edge 抓取 + 改写」的完整例子(这是 Edge 最典型的强项) */
// app/api/proxy/[slug]/route.ts
export const runtime = 'edge';        // ⭐ 声明「这个 route 跑在 Edge」
// ⚠️ 如果没有这行,默认是 Node runtime(Next.js 里)

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;

  // ⭐ ① 边缘读取缓存(⭐ Cache API 在所有 Edge 平台可用)
  const cache = await caches.open('proxy-v1');
  const cacheKey = new Request(new URL(request.url), { method: 'GET' });

  const cached = await cache.match(cacheKey);
  if (cached) {
    // ⭐ 加一个头表明「来自缓存」(便于观测命中率,见 7.13)
    const headers = new Headers(cached.headers);
    headers.set('x-edge-cache', 'HIT');
    return new Response(cached.body, { status: cached.status, headers });
  }

  // ⭐ ② 抓取上游(⭐ Edge 里 `fetch` 是「一等公民」)
  const upstream = await fetch(`https://origin.example.com/content/${encodeURIComponent(slug)}`, {
    headers: {
      // ⭐ 透传「必要的头」(⚠️ 不要「无脑透传所有头」——可能带敏感信息)
      'user-agent': request.headers.get('user-agent') ?? '',
      // ⭐ 透传「追踪 id」→ 让前后端链路串起来(见 7.13)
      traceparent: request.headers.get('traceparent') ?? '',
    },
    signal: AbortSignal.timeout(8000),        // ⭐ Edge 里也要「设超时」
  });

  if (!upstream.ok) {
    return new Response(JSON.stringify({ error: 'upstream failed' }), {
      status: 502,
      headers: { 'content-type': 'application/json' },
    });
  }

  // ⭐ ③ 流式改写(⭐ 这是 Edge 的「独门能力」:不缓冲整个响应就能改写)
  const contentType = upstream.headers.get('content-type') ?? '';
  if (!contentType.includes('text/html') || !upstream.body) {
    return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
  }

  // ⭐⭐ 用 TransformStream「边流边改」(⭐ 用户「更早看到内容」)
  const rewriter = new TransformStream<Uint8Array, Uint8Array>({
    transform(chunk, controller) {
      let text = new TextDecoder().decode(chunk);
      // ⭐ 典型改写:注入「边缘算出来的」信息 / 替换图片 CDN / 加埋点
      text = text.replace('</head>', `<script>window.__EDGE__={region:"${geoOf(request)}"}</script></head>`);
      controller.enqueue(new TextEncoder().encode(text));
    },
  });

  const transformed = upstream.body.pipeThrough(rewriter);

  const response = new Response(transformed, {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
      // ⭐ 边缘缓存(⚠️ 「个性化页面」不能这样)
      'cache-control': 'public, s-maxage=300, stale-while-revalidate=600',
      'x-edge-cache': 'MISS',
    },
  });

  // ⭐⭐ 关键:**「响应返回后」再写缓存**(⭐ 不阻塞用户看到内容)
  //    ⚠️ 用 `waitUntil` 而不是「await 缓存写入」——否则「用户要多等」
  const cacheWrite = cache.put(cacheKey, response.clone());
  // Cloudflare/Vercel 提供 waitUntil;Next.js 15 提供 `after()`
  const ctx = (request as unknown as { waitUntil?: (p: Promise<unknown>) => void });
  if (typeof ctx.waitUntil === 'function') ctx.waitUntil(cacheWrite);
  else void cacheWrite;

  return response;
}

function geoOf(request: Request): string {
  // ⚠️ 地理信息「来自平台」(不同平台字段不同)
  return (request as unknown as { cf?: { country?: string } }).cf?.country ?? 'unknown';
}
⭐⭐⭐ 这个例子里的四个「Edge 特有」的能力(Node runtime 做不到或更贵):

① 【「就近」执行】
   用户在日本 → 请求落在东京边缘节点 → ⭐ 抓上游/改写/返回都在东京(少一次跨洋 RTT)

② 【「流式改写」】⭐
   `body.pipeThrough(new TransformStream(...))` —— **不缓冲整个 HTML 就能改写**
   ⚠️ 这在 Node 里也能做(Node Stream),但在 Edge 里「更轻」(⭐ 且「冷启动」优势明显)

③ 【「响应后继续工作」】⭐
   `waitUntil(cache.put(...))` —— ⭐ **「写缓存」不阻塞用户**
   ⚠️ 这是 Edge 里做「异步副作用」(写缓存/埋点/日志)的**唯一正确方式**
     (因为「请求结束后,普通的定时器/Promise 可能被直接杀掉」)

④ 【「Cache API」】
   `caches.open()` 是 Web 标准(⭐ Service Worker 里也有,见 10.11)→
   ⭐ 「边缘缓存」与「浏览器 Service Worker 缓存」用同一套 API(可复用代码)

四、选型:什么时候该用 Edge

⭐⭐⭐ 「该不该用 Edge」的四个判据(⭐ 按「是否命中」计数):

   ✅ 该用(命中 2 条以上):
     ① ⭐ **延迟敏感 + 需要「就近执行」**(鉴权、重定向、A/B、地理路由、改写)
     ② ⭐ **只是「中间层」**(不读文件、不连传统库、不做重计算)
     ③ ⭐ **需要「流式」处理**(SSE、流式改写、AI 流式输出)
     ④ ⭐ **全球用户 + 想要「冷启动低」**(⭐ 首字节时间敏感)

   ❌ 不该用(命中任意一条就要谨慎):
     ① **需要「传统数据库」**(除非有 HTTP 驱动/代理)
     ② **需要「原生依赖」**(sharp、bcrypt、canvas 等 → ⭐ 直接跑不了)
     ③ **重计算/长任务**(PDF 生成、大数据聚合、图像处理)
     ④ **需要「常驻内存状态」**(连接池、内存缓存、速率限制计数器)
     ⑤ ⚠️ **「想用 Edge 因为它「快」」但「其实只是 API 转发」**——
        ⭐ 如果边缘函数只是「把请求转给后端」,那「省下的 RTT」可能被
        「边缘 → 后端」的那一跳抵消(⭐ 要算清楚)
⭐⭐⭐ 一个「混合架构」的实践建议(⭐ 这是生产里最稳的做法):

   ① 【Edge Middleware】—— 跑在「所有请求之前」,只做「轻活」:
      鉴权(轻量)/ 重定向 / A/B 分桶 / 地理注入 / 安全头 / 部分缓存
      ⭐ 关键:**「不做数据访问」**(除了「读 KV」这类边缘原生的存储)
   ② 【Node Runtime】—— 做「重活」:
      SSR 渲染(需要大量依赖)/ DB 密集的 API / 文件处理 / 长任务
   ③ 【前端 Worker】—— 做「客户端的重活」(见 8.10)
   ④ ⭐ **「按 route 声明 runtime」**(Next.js 的 `export const runtime = 'edge' | 'nodejs'`)
      → ⭐ 「一个项目里混用」(不必「全站二选一」)

五、本篇特有的坑

// ① 在 Edge 里 import Node 内置模块(构建期就失败,或运行时报错)
import fs from 'node:fs';                                      // ❌
// ✅ Edge 里「只 import Web 标准 + 明确支持的库」

// ② 在 Edge 里用「Node 的 crypto」(API 完全不同)
import { createHmac } from 'node:crypto';                      // ❌
// ✅ `crypto.subtle.importKey/sign/verify`(⭐ 是 Promise API,写法完全不同)

// ③ 在 Edge 里用 `Buffer`
const b = Buffer.from(x);                                      // ⚠️ 依赖 polyfill
// ✅ `new TextEncoder().encode(x)` / `Uint8Array`

// ④ ⭐ 在 Edge 里 new PrismaClient(要 TCP)
// ✅ HTTP 驱动(Neon/PlanetScale/Turso)或「连接池代理」或「转给 Node runtime」

// ⑤ ⭐ 用「模块级变量」做「共享状态」(Edge 上不可靠)⭐
let counter = 0; export function inc() { return ++counter; }   // ❌ 跨实例/被回收后不可靠
// ✅ KV / Durable Objects / 外部 Redis

// ⑥ ⭐ 「请求结束后」继续跑异步(不 `waitUntil` 会被杀)
fetch('/analytics', { ... });                                  // ⚠️ 可能被中断
// ✅ `waitUntil(fetch(...))` 或 `after(() => ...)`

// ⑦ ⭐ 「用 Edge 做重计算」(超时/OOM)
// ✅ 交给 Node runtime / 后台任务 / 客户端 Worker

// ⑧ 「Edge 函数的依赖太大」(部署慢 + 冷启动变差)
// ✅ 按需 import + 用轻量库(`jose` 而不是 `jsonwebtoken`)

// ⑨ ⭐ 「A/B 分桶」在边缘与在 Node 用了「不同的哈希算法」(用户分组不一致)
// ✅ ⭐ 「分桶算法」必须是「**一份共享代码**」(⭐ 前端、Edge、Node 都用它,见 8.9)

// ⑩ 「中间件里读 cookie 但没验签」(把「伪造的 cookie」当作「已登录」)
// ⚠️ 症状:能通过「随便设个 cookie」绕过鉴权
// ✅ ⭐ 「轻量验签」(WebCrypto HMAC/JWT)或「只做存在性检查 + 下游真正鉴权」

// ⑪ 「中间件 matcher 太宽」(每个请求都跑,包括静态资源)
// ✅ 用 matcher 排除 `_next/static`、`_next/image`、`favicon`…

// ⑫ ⭐ 「边缘缓存了带 cookie 的个性化页面」(串用户)⭐ 安全级
// ✅ 「检测到 cookie/session → 不加 `s-maxage`」;⭐⭐ 并**绝不能把用户数据放进「共享缓存」**

// ⑬ 「中间件改写了请求头但下游读不到」
// ⚠️ Next.js 里要「用 `NextResponse.next({ request: { headers } })`」才算「把新头传给下游」
// ✅ 写 `requestHeaders`(而不是「在 response 上加」)

// ⑭ 「`redirect` 用了「用户可控的 URL」(开放重定向)」
loginUrl.searchParams.set('redirect', request.nextUrl.searchParams.get('next')!);   // ❌
// ✅ 只拼「站内 pathname + search」;若必须接受外部输入 → 校验同源(见 6.12)

// ⑮ 「Edge 里 `setTimeout` 做延迟任务」(请求结束就被杀)
// ✅ 用「平台的任务队列」或「Node runtime + 后台任务」

// ⑯ 「以为 Edge 一定比 Node 快」(其实要「算清楚」)
// ⚠️ 「边缘 → 后端」的那一跳可能抵消「就近」的优势
// ✅ 用「真实数据」验证(⭐ 对比 P95 而不是「直觉」,见 7.13)

// ⑰ 「Edge 函数的错误处理缺失」(一个异常 → 500,用户看到「无法访问」)
// ✅ try/catch + 「降级到 Node runtime / 返回友好错误」

// ⑱ 「本地开发与线上行为不一致」(本地是 Node,线上是 Edge)
// ⚠️ 症状:「本地好好的,部署到 Edge 后报 `fs is not defined`」
// ✅ 用「支持 Edge 的本地运行时」(如 `next dev --turbo` 会标注 runtime;
//    或 ⭐ 「把 runtime 声明写进代码」并在 CI 里「用 Edge 环境构建」)

// ⑲ 「Edge 里的 `fetch` 默认缓存行为」(⭐ 与 Node 不同)
// ⚠️ 有些平台的 `fetch` 默认「不缓存」,有些「有 Cache API 但 fetch 不自动用」
// ✅ 显式声明缓存策略(⭐ 并用「响应的 `cache-control`」而不是「隐式行为」)

// ⑳ ⭐ 「把『需要强一致』的事放在边缘」(如「库存扣减」「幂等计数」)
// ⚠️ Edge 是「多实例 + 无共享内存」→ 用 KV 做「原子操作」很危险
// ✅ 「强一致」的操作放「有状态的服务」(Durable Objects / 数据库的原子操作,见 7.12)
// ⑥ 的完整处理(`waitUntil` 与 `after` 的正确用法)
import { after } from 'next/server';

export async function POST(request: Request) {
  const data = await request.json();

  // ⭐ 主流程(用户要等)
  const result = await doMainWork(data);

  // ⭐⭐ 副作用(用户不等)—— 「埋点 / 写缓存 / 发通知 / 日志」
  after(async () => {
    // ⚠️ 这里的错误「不会影响响应」,但也要 try/catch(避免「未处理的 rejection」,见 8.7)
    try {
      await Promise.all([
        fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ type: 'done' }) }),
        cache.put(cacheKey, response.clone()),
        logToService({ action: 'done' }),
      ]);
    } catch (err) {
      console.warn('[after] 副作用失败', err);
    }
  });

  return Response.json(result);
}
// ⑫ 的完整处理(边缘缓存的「安全检查」)
function setEdgeCache(response: Response, request: Request, options: { maxAge: number; swr?: number }) {
  // ⭐⭐ 三层检查(任何一层不过 → 不加缓存头)
  // ① 带 cookie / Authorization → 可能是个性化内容 → 不缓存
  if (request.headers.has('cookie') || request.headers.has('authorization')) return;

  // ② 响应的 cache-control 已经声明「private/no-store」→ 尊重它
  const cc = response.headers.get('cache-control') ?? '';
  if (/private|no-store/i.test(cc)) return;

  // ③ 显式「白名单」:只有「确定是公开内容」的路由才缓存
  const url = new URL(request.url);
  if (!/^\/(blog|docs|products)\//.test(url.pathname)) return;

  response.headers.set(
    'cache-control',
    `public, s-maxage=${options.maxAge}${options.swr ? `, stale-while-revalidate=${options.swr}` : ''}`
  );
}

六、面试延伸

  1. 「Edge Runtime 是什么?和 Node.js Runtime 有什么区别?」
    ⭐ 它不是一个「精简版 Node」,而是「一个基于 V8 isolate、只有 Web 标准 API 的运行时」。关键区别:① ⭐ 启动——V8 isolate(冷启动约 5ms)vs 容器/进程(100ms~1s);② ⭐ 位置——就近部署(全球边缘) vs 固定区域;③ ⭐ 可用 API——只有 Web 标准(fetch/Request/Response/crypto(WebCrypto)/streams/Cache)vs 全部 Node 内置 + npm 生态;④ ⭐ 没有 fs/net/child_process,也没有 Node 的 crypto/Buffer/process(完整);⑤ ⭐ 实例不保证存活(⚠️ 不要依赖内存里的全局状态)vs 可以常驻;⑥ CPU 时间与内存受限(不适合重活);⑦ ⭐ 没有 TCP → 传统数据库驱动用不了(必须用 HTTP 驱动或连接池代理)。

  2. 「Edge 里怎么连数据库?」
    ⭐ 核心障碍是「Edge 没有 TCP 客户端」——所以「MySQL/Postgres 的标准驱动(需要 TCP 连接池)」用不了。三条出路:① ⭐ HTTP 驱动——Neon serverless driver、PlanetScale HTTP、Turso(libSQL over HTTP)、Drizzle 的 HTTP 模式;② ⭐ 连接池代理——Prisma Accelerate/Data Proxy、Supabase Supavisor(「边缘 → 代理(HTTP/WS)→ 数据库(TCP)」,代理负责连接池);③ ⭐ 不在 Edge 里连库——Edge 只做「鉴权/路由/改写」,真正的数据请求转发给 Node runtime 或后端服务(⭐ 这是生产里最稳的做法)。另外要注意:⚠️ 「强一致」的操作(库存扣减、幂等计数)不要放在边缘(边缘是「多实例 + 无共享内存」,用 KV 做原子操作很危险)。

  3. 「Edge 里的『全局状态』为什么不可靠?」
    因为 ⭐ Edge 是「多实例 + 实例会被回收」:① 不同请求可能落在不同的 isolate(⭐ 所以「模块级变量里的计数器」不是全局的,只是「这个实例的」);② 实例「不保证存活」(空闲时被回收 → 内存里的东西消失);③ ⚠️ 所以「连接池、内存缓存、速率限制计数器」这类都不可靠。解法:需要「跨请求/跨实例共享」的状态就用「平台提供的存储」——KV(Cloudflare KV / Vercel KV)、⭐ Durable Objects(Cloudflare,能做「有状态的强一致」)、外部 Redis(⚠️ 需要 HTTP 接口)。⭐ 判据:「这段状态需要跨请求/跨实例共享吗」——需要就不该放内存。

  4. 「waitUntil / after 是干什么的?」
    它们解决「请求结束后,异步入队的工作会被杀掉」的问题:⚠️ 在 Edge 上,「响应返回后」普通的 fetch/Promise/定时器「可能被直接终止」(因为「这个 isolate 可能被回收」)。⭐ waitUntil(promise)(Vercel/Cloudflare)与 after(() => ...)(Next.js 15)让「响应返回后继续做的工作」被「正确地等待」——它是 Edge 里做「异步副作用」的唯一正确方式:写缓存、埋点上报、写日志、发通知。⚠️ 两个注意:① 副作用里的错误「不会影响响应」,但仍要 try/catch(否则会「未处理的 rejection」,见 8.7);② 不要让「用户等的关键路径」放进 after(那样「用户拿不到结果」)。

  5. 「Edge 缓存要注意什么?」
    ⭐ 最危险的是「缓存了带 cookie 的个性化页面」→ 串用户(安全级事故):① ⭐ 检查 cookie/authorization 头——有就不加 s-maxage;② ⭐ 尊重响应自己的 cache-control(private/no-store 就不覆盖);③ ⭐ 用「路径白名单」(只有「确定是公开内容的路由」才缓存,如 /blog/*、/docs/*);④ 用「s-maxage + stale-while-revalidate」(⭐ 与 5.16 的 HTTP 缓存黄金组合一致)。另外:① ⭐ Cache API(caches.open())是 Web 标准——边缘与浏览器 Service Worker 用同一套 API(代码可复用,见 10.11);② ⭐ 「写缓存」要放进 waitUntil(不阻塞用户);③ ⚠️ 「边缘缓存」与「Next.js 的 Data Cache / Full Route Cache」是两层(见 9.9,别混淆)。

  6. 「什么时候该用 Edge,什么时候不该?」
    ⭐ 该用(命中 2 条以上):① 延迟敏感 + 需要就近执行(鉴权、重定向、A/B 分桶、地理路由、响应改写);② 只是「中间层」(不读文件、不连传统库、不做重计算);③ 需要流式处理(SSE、流式改写、AI 流式输出);④ 全球用户 + 在意首字节时间。⭐ 不该用(命中任意一条就要谨慎):① 需要传统数据库(除非有 HTTP 驱动/代理);② 需要原生依赖(sharp/bcrypt/canvas → 直接跑不了);③ 重计算/长任务;④ 需要常驻内存状态;⑤ ⚠️ 「为了『快』而用 Edge,但其实只是 API 转发」——「边缘 → 后端」的那一跳可能抵消「就近」的优势(⭐ 要用真实 P95 数据验证,别靠直觉,见 7.13)。实践:⭐ 「Edge Middleware 做轻活 + Node Runtime 做重活 + 前端 Worker 做客户端重活」的混合架构,并在「按 route 声明 runtime」(export const runtime = 'edge' | 'nodejs')。

  7. 「Edge 里的鉴权怎么做?」
    ⭐ 关键纪律:中间件里的鉴权要「轻量」——因为它「每个请求都跑」(⚠️ 而且「中间件的耗时」会加到「每个请求的延迟」上)。做法:① ⭐ 「存在性检查 + 轻量验签」——用 WebCrypto 验「签名/JWT」(⚠️ 注意「Node 的 crypto 与 WebCrypto 的 API 完全不同」,这是迁移时最常见的改动点);② ⭐ 「不做重活」——不要在中间件里「查数据库」(那会「每次都多一次往返」);真正的授权(查权限点、查资源归属)应该在路由/接口里做(见 6.12);③ ⭐ 「重定向时只拼站内路径」——redirect 参数如果「来自用户输入」就是开放重定向漏洞(见 6.12);④ 必须校验「伪造的 cookie」(⚠️ 「随便设个 cookie 就能过鉴权」是最常见的事故)——所以「存在性检查」不够,要验签。

  8. 「如果让你把一个 Node 的 API 迁到 Edge,你会遇到什么?」
    六类问题:① ⭐ Node 内置模块(fs/path/net/crypto/Buffer)——⚠️ 尤其是 crypto 与 Buffer(WebCrypto 是 Promise API、Buffer 要用 Uint8Array/TextEncoder);② ⭐ 依赖里有原生扩展(sharp/bcrypt/canvas)——直接跑不了,要么换「纯 JS/Web 标准」的实现(如 bcryptjs、jose),要么保持在 Node;③ ⭐ 数据库驱动(TCP → 要 HTTP 驱动或代理);④ ⭐ 全局状态与连接池(⚠️ 内存计数/缓存不可靠);⑤ ⭐ 执行时间与内存(重活会超时/OOM);⑥ 本地开发与线上行为不一致(⚠️ 「本地是 Node,线上是 Edge」→ 部署后才发现报错)→ ✅ 「把 runtime 声明写进代码」+ 在 CI 里用 Edge 环境构建。⭐ 我的建议:「先只迁『中间件/轻量 API』,把『数据密集』的部分留在 Node」(⭐ 收益最大、风险最小)。

一句话速记

Edge Runtime = 「V8 isolate + 只有 Web 标准 API + 就近部署」——⭐ 冷启动约 5ms(vs Node 容器 100ms~1s)+ 全球就近,代价是「没有 fs/net/child_process,也没有 Node 的 crypto/Buffer/完整 process」+「实例不保证存活」+「CPU 时间与内存受限」+「⭐ 没有 TCP → 传统数据库驱动用不了」;⭐ 能用的白名单:fetch/Request/Response/Headers/FormData/URL/streams(TransformStream 可做「流式改写」)/WebCrypto/Cache API/AbortController/atob/btoa + ⭐ waitUntil(或 Next 15 的 after)——⭐ 它是「请求结束后做异步副作用」的唯一正确方式;⭐ 四条硬约束的应对:① 没 TCP → HTTP 驱动(Neon/PlanetScale/Turso)、连接池代理(Prisma Accelerate/Supavisor)、或「Edge 只做轻活、数据请求转给 Node」;② 实例不保证存活 → 共享状态用 KV/Durable Objects/Redis(⭐ 判据「需要跨实例共享吗」);③ 时间与内存受限 → 重活交给 Node runtime/后台任务/前端 Worker;④ 依赖越小越好(按需 import + 轻量库如 jose);⭐ 选型判据:「延迟敏感 + 只是中间层 + 流式 + 全球用户」→ 该用;「需要传统 DB/原生依赖/重计算/常驻状态」→ 不该用,⚠️ 且「为了快而用 Edge 但只是 API 转发」可能被「边缘→后端那一跳」抵消(用 P95 验证);⭐ 鉴权要轻量 + 必须验签(不能只查存在性)+ redirect 只拼站内路径;⭐ 缓存三大安全纪律:有 cookie/Authorization 不缓存、尊重响应自己的 private/no-store、路径白名单;生产最佳实践是混合架构(Edge Middleware 轻活 + Node 重活 + 按 route 声明 runtime)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.8 Edge Runtime

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫