Next.js 代理陷阱 — BFF 代理到内部后端
你正在构建或排查一个充当 API 网关 / BFF 的 Next.js 应用:浏览器只和 Next.js 说话,Next.js 再通过 next.config.ts 的 rewrites 或 catch-all route handler 把请求转发给内部后端服务(FastAPI、Express、内部微服务)。这个角色自带一族特定陷阱,其症状几乎总把矛头指向代理之外的地方:后端"超时"、上传"随机失败"、CORS"坏了",或者内部主机名"漏"进了浏览器。先在下文找到匹配的陷阱,再去怪后端。
目录
- 何时使用
- 参数与占位符
- 症状索引
- 心智模型:两种代理机制
- 错误处理约定
- 失败模式与恢复
- 不适用场景
- 陷阱 1 — rewrite 目标地址在构建期就被固化
- 陷阱 2 — 内置 rewrite 代理在约 30 秒处截断
- 陷阱 3 —
Expect: 100-continue打崩大文件上传 - 陷阱 4 — 二次解压把代理回来的响应弄坏
- 陷阱 5 — 尾斜杠重定向泄漏内部后端主机名
- 实践 A — 在 catch-all 代理里过滤路径穿越
何时使用
自动触发:用户描述与下表场景相关时直接启用,不需要点名本技能。
| 用户会这么说 | 实际场景 |
|---|---|
| 「后端地址改了,重启了还是打旧 host」 | 构建期固化 → 陷阱 1 |
| 「超过 30 秒的请求只有走代理才超时」 | rewrite 内置上限 → 陷阱 2 |
| 「大文件上传报 fetch failed,小文件没事」 | Expect: 100-continue → 陷阱 3 |
| 「代理回来的 JSON 乱码了」 | 二次解压 → 陷阱 4 |
| 「控制台报 CORS,里面还有个内网域名」 | 尾斜杠重定向泄漏 → 陷阱 5 |
| 「SSE 一次性全出来,停止按钮没用」 | 流式被缓冲 → 实践 B |
手动调用:对话里说「用 Next.js 代理陷阱处理」,或直接用 @ 引用本技能(平台的完整 slug 可能带作者后缀,不必写全名)。
不适合:纯客户端 fetch 问题、Next.js 之外的 nginx 等反代、只返静态数据的 route——见「不适用场景」。
参数与占位符
动手前先替换下表占位符;未替换就跑等于用错环境。
| 占位符 | 含义 | 从哪里取 |
|---|---|---|
<proxy-host> |
代理对外域名 | 部署清单里 web 入口的域名 |
<backend-host> |
后端内部主机名 | 容器内 getent hosts backend |
<path> |
被代理的路由段 | app/api/[...path]/route.ts 的挂载前缀 |
<proxy-container> |
代理容器名 | docker compose ps --format '{{.Name}}' |
症状索引
| 症状 | 看哪节 |
|---|---|
| 在 .env 里换了后端源站、重启了,请求还是打到旧主机 | 陷阱 1 |
| 只有经代理时,超过约 30 秒的请求才超时而死 | 陷阱 2 |
| 超过约 1MB 的上传失败,报 "fetch failed" / UND_ERR_NOT_SUPPORTED | 陷阱 3 |
| 代理回来的 JSON 乱码,或报 content-encoding / 长度不匹配 | 陷阱 4 |
| 浏览器 CORS 报错里出现内部后端主机名 | 陷阱 5 |
| 构造的 /proxy/../../admin 路径打到了不该到的后端路由 | 实践 A |
| SSE 一次性全部到达;"停止"按钮停不下后端 | 实践 B |
| 需要一个同时尊重客户端断开的超时 | 实践 C |
心智模型:两种代理机制
- rewrites(在
next.config.ts里):零代码,每条路由一行。代价是:destination 的 env 值在构建期求值(陷阱 1)、固定的约 30 秒内部超时(陷阱 2)、默认的请求体大小上限,以及对头部的零控制权——这让陷阱 3–5 在 rewrite 里没法修。 - route handler catch-all(
app/**/[...path]/route.ts):真正的代码。在服务端运行时读process.env,自己设超时,双向控制每一个头部。
决策规则:短、小、发完就不管的内部端点可以走 rewrite。任何长时间运行的(LLM 调用、文档转换、报表)、流式的(SSE)、或重上传的,都走 route handler。真实事故大多以"把这条路由挪到 route handler"收场。
错误处理约定
本技能把出错分成四类,处置与重试策略不同,不要混着来:
| 类别 | 典型情况 | 处置 | 可重试 |
|---|---|---|---|
| 输入错误 | 参数缺失、路径或格式不对 | 直接拒绝,指出缺哪个字段 | 否 |
| 环境错误 | 权限、网络、依赖服务不可用 | 给检查命令,停下等修复 | 修复后 |
| 证据不足 | 日志、指标、状态缺失 | 先补证据,不做判断 | 补证据后 |
| 结论冲突 | 多个判据指向不同根因 | 列冲突点,先安全后性能 | 否 |
统一输出格式(每次给结论都按这四段写,便于他人复核):
判据:<看到的现象与具体取值>
结论:<属于哪一类 / 最可能的根因>
动作:<最小修改,或需要补的证据>
验证:<怎么确认修好了>
本技能常见错误码:
| 代码 | 现象 | 处置 |
|---|---|---|
E-ENV-01 |
401 / 504 只在代理侧出现 | 长请求迁到 route handler 并自管超时(陷阱 2) |
E-ENV-02 |
大文件上传 UND_ERR_NOT_SUPPORTED |
转发前删除 expect 头后重建(陷阱 3) |
E-ENV-03 |
代理回来的 JSON 乱码 | 删 content-encoding 与 content-length(陷阱 4) |
E-ENV-04 |
浏览器里出现内网主机名 | 保留尾斜杠,或设 skipTrailingSlashRedirect(陷阱 5) |
E-ENV-05 |
认证失败(401) | 修头透传;密钥别放前端 |
输入校验清单、重试与退出语义见 references/error-handling.md。
失败模式与恢复
| 失败现象 | 常见原因 | 检查方法 | 恢复动作 | 可否重试 |
|---|---|---|---|---|
| 401 / 504 只在代理侧出现 | 头未透传,或触到 rewrite 30 秒上限 | curl -v 直连后端与经代理各打一次,比状态码与耗时 |
长请求迁到 route handler 并自管超时(陷阱 2) | 可重试 |
大文件上传 UND_ERR_NOT_SUPPORTED |
Expect 头透传给了 undici |
curl -v -H "Expect: 100-continue" -F file=@big.bin https://<proxy>/upload |
转发前删除 expect 头后重建(陷阱 3) |
可重试 |
| 代理回来的 JSON 乱码 | 复制了上游 content-encoding |
curl -sI https://<proxy>/<path> 看响应头 |
删 content-encoding 与 content-length(陷阱 4) |
可重试 |
| 浏览器里出现内网主机名 | 尾斜杠 307 带出 Location | curl -sI https://<proxy>/<path>/ 看 Location |
保留尾斜杠,或设 skipTrailingSlashRedirect(陷阱 5) |
可重试 |
| 认证失败(401) | token 未透传或已过期 | curl -H "Authorization: Bearer <t>" https://<proxy>/<path> |
修头透传;密钥别放前端 | 可重试 |
失败时优先中止:改代理代码前先用 curl 复现并留存证据;一旦发现请求打到了错误的后端实例,先停手确认目标,不要继续改配置。
不适用场景
- 纯粹的客户端
fetch问题,压根不碰 Next.js 服务端代码(浏览器侧 CORS 处理、直连后端调用)。 - Next.js 之外的反向代理——nginx、Traefik、Caddy 有自己的旋钮和故障模式。
- 只返回静态数据、从不转发上游的 API route。
陷阱 1 — rewrite 目标地址在构建期就被固化
症状。 你在 .env 里改了后端源站,重启服务或容器,请求还是打到旧主机。与此同时,读同一个变量的 route handler 却拿到了新值,这让问题更迷惑。客户端 bundle 里的 NEXT_PUBLIC_* 值同样如此。
原因。 next.config.ts 在 next build 运行时求值。把 process.env 插值进 rewrite destination 的模板字符串在那一刻就被解析,并写进构建产物(routes manifest)。生产容器镜像只构建一次;重启容器只是重放已固化的 manifest。NEXT_PUBLIC_* 在同一次构建里被内联进客户端 JS。route handler 之所以有效,是因为它模块级的 process.env 读取发生在服务端进程启动时,而不是镜像构建时。
修复。
- 把任何与 rewrite 相关的 env 变更都当成需要 rebuild,而不是 restart。
- 配置缺失就显式失败:加载配置时,收集每一个回落到默认值的 origin,打一条聚合警告。静默的 ?? "http://127.0.0.1" 兜底,正是一个过期 origin 在生产里长期不被发现的方式。
- 如果某个 origin 必须在无法 rebuild 的情况下变更,把该路由挪到 route handler,在模块加载时读 process.env。
陷阱 2 — 内置 rewrite 代理在约 30 秒处截断
症状。 合法运行超过半分钟的端点——LLM 补全、文档转换、批处理任务——只有经 rewrite 调用时才超时。同一个请求用 curl 直连后端能跑完。
原因。 rewrite 代理有一个内部超时,实测约 30 秒(受 Next.js 版本与运行时影响,不同版本阈值可能不同,以本地复现为准)。它不可配置,所以任何可能超过它的端点都需要代码级代理。
修复。 把长路由挪到 catch-all route handler,并把超时攥在自己手里:
export const maxDuration = 600; // platform hard ceiling (e.g. serverless)
const TIMEOUT_MS = 9 * 60 * 1000; // keep strictly under the ceiling
const upstream = await fetch(target, { signal: AbortSignal.timeout(TIMEOUT_MS) });
把 maxDuration 设成平台的硬上限,把 fetch 超时设在它之下。真正触发的是 fetch 的信号;maxDuration 只是防止平台先一步把函数杀掉。
陷阱 3 — Expect: 100-continue 打崩大文件上传
症状。 经代理、超过约 1MB 的上传失败并报 fetch failed;服务端日志显示 cause 为 UND_ERR_NOT_SUPPORTED。经代理的小文件正常,同一个大文件直传后端也正常。
原因。 curl(以及一些 HTTP 客户端)会给超过约 1MB 的表单上传加上 Expect: 100-continue,意思是"服务端同意之前先别发请求体"。代理把这个头原样转发给自己发出的 fetch,而 Node 的 fetch(undici)没有实现 100-continue 握手——它直接抛错而不是等待。证据之所以让人抓狂是必然的:curl 直连能成,是因为 curl 自己会说这个握手;经代理时,这个头落到 undici 手里就死了。
修复。 转发前把这个头删掉——内部代理从握手里得不到任何好处:
const h = new Headers(request.headers);
h.delete("expect"); // undici has no 100-continue support; forwarding it throws
相关的旋钮:经内置 rewrite 代理的上传还受默认请求体大小上限约束(experimental.proxyClientMaxBodySize,例如 "64mb"——已在 Next.js 16.1.6 上验证;它是 experimental 选项,依赖它之前先确认你的 Next 版本里有)。这也是重上传该放进 route handler 的又一个理由:那里你完全掌控 body。
陷阱 4 — 二次解压把代理回来的响应弄坏
症状。 代理回来的 JSON 乱码,或浏览器抛出内容解码错误,有时是长度不匹配错误。直接检查后端响应却是健康的。
原因。 Node 的 fetch 会透明地解压 gzip/br/deflate 的响应体——但如果你把上游的响应头复制到自己的 Response 上,你就把原始的 content-encoding: gzip(以及已经过期的 content-length)转发出去,它们描述的是已不存在的字节。浏览器照着头来,试图对已经是明文的字节再做一次 gunzip。
修复。 fetch 之后,从响应头里删掉这两个头,让运行时重新计算。顺手把那些描述错误连接的 hop-by-hop 头也去掉:
const out = new Headers(upstream.headers);
out.delete("content-encoding"); // body is already plain; the header triggers a second decode
out.delete("content-length"); // describes the compressed size, not the body you return
out.delete("connection");
out.delete("keep-alive");
out.delete("transfer-encoding");
陷阱 5 — 尾斜杠重定向泄漏内部后端主机名
症状。 浏览器控制台出现针对后端内部主机名或 docker 网络名的 URL 的 CORS 失败——那个地址浏览器本不该知道。只在某些端点上发生。
原因。 重建上游路径的代理容易把尾斜杠丢掉。FastAPI 对斜杠不匹配的路径回 307 redirect_slashes,并用自身 origin——内部主机名——构造 Location 头。代理用 redirect: "manual" 取上游(这是正确设置),于是那个 307 连同 Location 直接透传给浏览器;浏览器随后从用户机器上跟随 Location,直连内部地址。请求失败(外部不可达、也没有 CORS 头),而控制台把你的内部拓扑广播了出去。失败的端点恰好就是后端路由登记了尾斜杠的那些。
修复。 构造目标 URL 时保留原请求的尾斜杠,让重定向根本不触发:
if (request.nextUrl.pathname.endsWith("/") && !target.pathname.endsWith("/")) {
target.pathname += "/";
}
让代理的 fetch 保持 redirect: "manual",这样上游重定向仍然是显式、可调试的,而不是服务端悄悄重发请求;同时考虑在 next.config 里设 skipTrailingSlashRedirect: true,免得 Next.js 自己抢在你前面注入它自己的斜杠重定向。
实践 A — 在 catch-all 代理里过滤路径穿越
[...path] 路由会转发 URL 里的一切。像 /proxy/backend/../../admin 这样的请求——或它的编码形式 %2e%2e——会在构造目标 URL 时被 new URL(...) 规范化,从而逃出预期前缀,代理到任意后端路径(管理面板、内部调试路由)。构造 URL 之前先拦截:只要出现等于 .. / . 的路径段、或大小写不敏感地含 %2e 的段,直接返回 4xx 并终止(不要静默过滤后继续转发——那会让攻击者用编码形式绕过检查)。五行代码;没有它们,这个代理就是通往后端背后一切的大门。
实践 B — 透传 SSE,并把客户端中断记为 499
缓冲会杀死流式:SSE 响应会在结尾一次性冲出来(或者永远不出来),"停止生成"按钮也不可能生效。识别流式并原样透传响应体:
const isSSE = (upstream.headers.get("content-type") ?? "").includes("text/event-stream");
const body = isSSE ? upstream.body : Buffer.from(await upstream.arrayBuffer());
return new Response(body, { status: upstream.status, headers });
非 SSE 响应则刻意缓冲:把分块的普通响应体透传出去会与自动解压相互干扰,还可能把它截断。要让"停止"真正生效,在客户端断开时中止上游 fetch,并返回 499(nginx 对"客户端关闭请求"的约定),这样中断不会以 502 的形式污染错误日志:
if (request.signal.aborted) return new Response(null, { status: 499 });
实践 C — 用 AbortSignal.any 组合中断信号
一个被代理的请求有两个彼此独立的中断理由:你自己的超时,以及客户端离开。两者都应该中止上游 fetch——否则一个已断开的客户端会让后端 LLM 继续生成好几分钟。把两者合成一个信号:
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(TIMEOUT_MS)]);
对完全缓冲的路由,客户端断开信号很少在传输中途触发,但把它带上无害,而且能让所有情况走同一条代码路径。
现成模板
references/proxy-handler-template.ts 是一个自包含、带注释的 catch-all route handler,把上面所有内容合在一起:env 驱动的 origin、Expect 去头、请求/响应头卫生、尾斜杠保留、穿越过滤、SSE 透传、499 中断、组合超时。实现新的代理路由时读它,或当线上事故命中任一陷阱时读它——每个代码块都带一行 why 注释,方便你只摘取需要的那几段。
模板的三条使用注意
- 方法覆盖:Next.js 只认你显式导出的同名函数(
export const GET/POST/PUT/PATCH/DELETE)——没有「一个 methods 数组」这回事。必须再导出OPTIONS(漏了跨域预检直接 405)与HEAD(漏了健康检查类探针会失败);模板已包含这两个导出。 - 大请求体不要整体进内存:模板对
content-length > 1MB的请求改用request.body流式透传,并带duplex: 'half'(Node 18+ 的fetch要求);小请求仍用arrayBuffer(),因为流式时无法预知长度、会退化成 chunked。 - 默认源站会掩盖配置缺失:模板带了兜底源站,未配置目标时请求会「看起来正常」地打到默认地址。上线前请删掉兜底值或改成显式报错,避免静默走错后端。
完整走查(端到端样例)
走查一:大文件经代理上传报 fetch failed(陷阱 3)
场景:大文件经 Next.js 代理上传报 fetch failed,直连后端却成功(陷阱 3)。
输入:用户原话「上传超过 1MB 就失败,报 fetch failed,但直接打后端接口是好的」。
curl -v --max-time 60 -H "Expect: 100-continue" -F "file=@big.bin" https://<proxy-host>/upload
curl -s -o /dev/null -w '%{http_code}\n' -F "file=@big.bin" http://<backend-host>:8000/upload
中间输出(示例):
> POST /upload HTTP/1.1
> Expect: 100-continue
> Content-Length: 12582912
< HTTP/1.1 500 Internal Server Error
< content-type: application/json
{"error":"fetch failed","cause":{"code":"UND_ERR_NOT_SUPPORTED"}}
200
定位与修复:直连 200、经代理 500,且 cause.code 是 UND_ERR_NOT_SUPPORTED → 代理把 Expect 头透传给了 undici →
- 转发前
headers.delete("expect")(陷阱 3); - 把
err.cause打进日志,下次一眼定位; - 重建或重启代理层。
修复后复测:
$ curl -s -o /dev/null -w '%{http_code}\n' -F "file=@big.bin" https://<proxy-host>/upload
200
$ docker logs <proxy-container> --tail 2
POST /upload 200 3.2s 12.4MB
结论:验证陷阱 3——「直连成功 ≠ 经代理成功」时先怀疑头,再怀疑后端。
走查二:尾斜杠重定向把内部主机名泄漏给公网(陷阱 5)
输入:用户原话「浏览器控制台报 CORS,报错里那串地址是我们内网的 backend.internal」。
curl -s -o /dev/null -D - https://app.example.com/api/proxy/users/
curl -s -o /dev/null -D - https://app.example.com/api/proxy/users
中间输出(示例):
HTTP/1.1 307 Temporary Redirect
location: http://backend.internal:8000/users/
content-length: 0
HTTP/1.1 200 OK
content-type: application/json
定位:不带尾斜杠的请求 200,带尾斜杠的被后端 307 到内部主机名——代理重建 target 时把尾斜杠丢了,FastAPI 的 redirect_slashes 用自身 origin 构造 Location,代理又用 redirect: "manual" 把它透传给浏览器(陷阱 5)。先排除 CORS 配置:响应里根本没有 CORS 头,问题在重定向而不是跨域设置。
修复:构造 target 时保留原请求的尾斜杠(target.pathname += "/"),或在 next.config 里设 skipTrailingSlashRedirect: true。
复测:
curl -s -o /dev/null -D - https://app.example.com/api/proxy/users/
HTTP/1.1 200 OK
content-type: application/json
结论:验证陷阱 5 的判据顺序——先看 Location 里有没有内部主机名,再怀疑 CORS 配置。
分工:走查一覆盖陷阱 3(请求头方向),走查二覆盖陷阱 5(响应头方向),一个是「上游收不到」,一个是「内部地址漏出去」。
事故检查清单
当"代理坏了"时,按这个顺序查:
- env 变更真的进到构建里了吗?(陷阱 1)
- 请求跑了多久才死——大约 30 秒?(陷阱 2)
- 失败的请求带
Expect头吗?请求体超过约 1MB 了吗?(陷阱 3) - 响应里还声称
content-encoding: gzip吗?(陷阱 4) - 浏览器里失败的 URL 含内部主机名吗?(陷阱 5)
- 改代理代码之前,先用 curl 直接打后端复现——它能隔离出是哪一侧在骗你。
延伸阅读
references/error-handling.md