From 1159e6415ae2ff9a71478ac74222242bc203ada2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 17:45:42 +0000 Subject: [PATCH] =?UTF-8?q?test+docs:=20=E9=98=B2=20i18n=20matcher=20?= =?UTF-8?q?=E6=BC=8F=E6=8E=92=20backend=20rewrite=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=86=8D=E7=BF=BB=E8=BD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #335 修了登录炸的 hotfix(next-intl middleware matcher 漏排 /oauth /auth /analytics 三条 rewrite-to-backend 路径)。补防御让同 样的 bug 不再发生: 1. tests/proxy-matcher.test.ts - 静态扫 next.config.mjs 提取所有 rewrites() 函数体内的 source - 解析 proxy.ts matcher 字符串里 negative-lookahead 的排除组 - 对每个 source 第一段 path,断言它在排除组里 - 用 test.each 每条 source 一个 case,错误信息直接指引修法 - 加新 rewrite 不带 /api/ 前缀但忘改 matcher → CI fail 2. dev_docs/i18n_url_routing.md - 新章节「加新 backend rewrite」直接告诉以后写代码的人: 新增 next.config rewrite 必须同步更新 proxy.ts matcher - 列了 PR #335 事故 + 正确流程示例 跑了 pnpm test 19 个 case 全过(含 16 条现有 rewrite + 2 sanity check)。 --- dev_docs/i18n_url_routing.md | 27 +++++++++ tests/proxy-matcher.test.ts | 106 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/proxy-matcher.test.ts diff --git a/dev_docs/i18n_url_routing.md b/dev_docs/i18n_url_routing.md index da94afa2..fe485b64 100644 --- a/dev_docs/i18n_url_routing.md +++ b/dev_docs/i18n_url_routing.md @@ -213,6 +213,33 @@ export async function generateMetadata({ params }: Props): Promise { 5. **layout.tsx 嵌套时也要调 `setRequestLocale`**。Next.js 独立渲染 layout 和 page;page 调了 layout 没调照样退化 dynamic。每层都要补。 +## 加新 backend rewrite(next.config.mjs) + +**⚠️ 任何不带 `/api/` 前缀的 rewrite source,都要同步更新 `proxy.ts` 的 +matcher 排除组**,否则 next-intl middleware 会把请求 redirect 到 +`///...`,rewrite source 不匹配带 locale 的 URL,落到 +`[locale]//...` 404。 + +历史事故(PR #335):`/oauth/render/github` 被 redirect 到 +`/en/oauth/render/github`,登录炸了 3 分钟。 + +正确流程: + +```ts +// 1. next.config.mjs +async rewrites() { + return [ + { source: "/foobar/:path*", destination: `${backendUrl}/foobar/:path*` }, + ]; +} + +// 2. proxy.ts ← 必须同步加 foobar 到排除组 +matcher: "/((?!api|trpc|auth|oauth|analytics|foobar|_next|_vercel|.*\\..*).*)", +``` + +`tests/proxy-matcher.test.ts` 静态扫 `next.config.mjs` 所有 rewrite source, +对每个第一段路径 verify 它在 matcher 排除组里;忘了同步会 CI fail。 + ## 切换语言 `` 用 next-intl 的 `useRouter().replace(pathname, { locale })`。 diff --git a/tests/proxy-matcher.test.ts b/tests/proxy-matcher.test.ts new file mode 100644 index 00000000..359dc37a --- /dev/null +++ b/tests/proxy-matcher.test.ts @@ -0,0 +1,106 @@ +/** + * 防御 i18n middleware 误吃 backend rewrite 路径 + * + * 历史背景(PR #335 hotfix): + * i18n PR (#330) 改 proxy.ts 用 next-intl middleware 接管全站 locale + * routing,但 matcher 的 negative-lookahead 排除组只列了 + * `api|trpc|_next|_vercel|.*\..*`,漏掉了 next.config.mjs rewrites + * 里非 /api/ 前缀的 backend proxy 路径(/auth, /oauth, /analytics)。 + * 后果:用户访问 /oauth/render/github → 被 308 redirect 到 + * /en/oauth/render/github → rewrite source 不匹配带 locale 的版本 → + * 落到 [locale]/oauth/... 404 → 登录炸。 + * + * 这个测试就是防同样的事再发生:扫 next.config.mjs 里所有 rewrite + * source,对每个 source 验证它的第一段路径在 proxy.ts matcher 的排除 + * 组里。任何人加新 rewrite 但忘了改 matcher,CI 会 fail。 + */ + +import { describe, expect, test } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(__dirname, ".."); + +/** + * 静态扫 next.config.mjs,提取所有 rewrites() 函数体里的 source 列表。 + * 不去 require/import 文件(它依赖 sentry wrap + env,加载麻烦), + * 直接 regex 抓字面量。 + */ +function extractRewriteSources(): string[] { + const content = readFileSync(join(ROOT, "next.config.mjs"), "utf-8"); + // 提取 async rewrites() { ... } 函数体(非贪婪到下一个同级 async/closing) + const match = content.match( + /async rewrites\(\)[^{]*\{([\s\S]*?)^\s{2}\},?$/m, + ); + if (!match) { + throw new Error( + "无法定位 next.config.mjs 的 async rewrites() 函数体;正则可能要更新", + ); + } + const body = match[1]; + // 抓所有 source: "..." 的字面量(含跨行的 source: \n "...") + const sources = [...body.matchAll(/source:\s*\n?\s*["']([^"']+)["']/g)].map( + (m) => m[1], + ); + return sources; +} + +/** + * 解析 proxy.ts matcher 字符串,提取 negative-lookahead 里的排除项。 + * 形如 "/((?!api|trpc|auth|oauth|...|_next|_vercel|.*\\..*).*)" + * 返回 ['api', 'trpc', 'auth', 'oauth', ...] + */ +function extractMatcherExclusions(): string[] { + const content = readFileSync(join(ROOT, "proxy.ts"), "utf-8"); + // 找 matcher: "..." 字符串 + const matcherMatch = content.match(/matcher:\s*["'`]([^"'`]+)["'`]/); + if (!matcherMatch) { + throw new Error("无法定位 proxy.ts 的 matcher 字段"); + } + const matcher = matcherMatch[1]; + // 提取 (?!...) 里的内容 + const lookaheadMatch = matcher.match(/\(\?!([^)]+)\)/); + if (!lookaheadMatch) { + throw new Error("matcher 不是 negative-lookahead 形式,测试需要改写"); + } + return lookaheadMatch[1].split("|").map((s) => s.replace(/\\/g, "")); +} + +/** + * 取 path 的第一个非空段。 + * /oauth/render/github → 'oauth' + * /auth/:path* → 'auth' + * /api/admin/events → 'api' + */ +function firstSegment(pathLike: string): string { + return pathLike.split("/").filter(Boolean)[0] ?? ""; +} + +describe("proxy.ts matcher 必须排除所有 backend rewrite 路径", () => { + const rewriteSources = extractRewriteSources(); + const exclusions = extractMatcherExclusions(); + + test("能扫到 rewrite sources(防 regex 失效静默通过)", () => { + expect(rewriteSources.length).toBeGreaterThan(0); + }); + + test("matcher 形如 negative-lookahead,能解析出排除组", () => { + expect(exclusions).toContain("api"); + expect(exclusions).toContain("_next"); + }); + + test.each(rewriteSources)( + 'rewrite source "%s" 的第一段必须在 matcher 排除组里', + (source) => { + const seg = firstSegment(source); + // 跳过参数段(如 :path* 不会作为 path 第一段,但兜底) + if (!seg || seg.startsWith(":")) return; + expect( + exclusions, + `加了新 rewrite 不带 /api/ 前缀的话,必须同步更新 proxy.ts 的 matcher 排除组。 +当前缺少 "${seg}",否则 next-intl middleware 会把请求 redirect 到 //${seg}/... +导致 rewrite 不匹配,落到 [locale]/${seg}/... 404(参考 PR #335 登录炸事故)。`, + ).toContain(seg); + }, + ); +});