Skip to content

Commit 1159e64

Browse files
test+docs: 防 i18n matcher 漏排 backend rewrite 路径再翻车
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)。
1 parent 6704d10 commit 1159e64

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

dev_docs/i18n_url_routing.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,33 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
213213
5. **layout.tsx 嵌套时也要调 `setRequestLocale`**。Next.js 独立渲染 layout
214214
和 page;page 调了 layout 没调照样退化 dynamic。每层都要补。
215215

216+
## 加新 backend rewrite(next.config.mjs)
217+
218+
**⚠️ 任何不带 `/api/` 前缀的 rewrite source,都要同步更新 `proxy.ts`
219+
matcher 排除组**,否则 next-intl middleware 会把请求 redirect 到
220+
`/<locale>/<your-path>/...`,rewrite source 不匹配带 locale 的 URL,落到
221+
`[locale]/<your-path>/...` 404。
222+
223+
历史事故(PR #335):`/oauth/render/github` 被 redirect 到
224+
`/en/oauth/render/github`,登录炸了 3 分钟。
225+
226+
正确流程:
227+
228+
```ts
229+
// 1. next.config.mjs
230+
async rewrites() {
231+
return [
232+
{ source: "/foobar/:path*", destination: `${backendUrl}/foobar/:path*` },
233+
];
234+
}
235+
236+
// 2. proxy.ts ← 必须同步加 foobar 到排除组
237+
matcher: "/((?!api|trpc|auth|oauth|analytics|foobar|_next|_vercel|.*\\..*).*)",
238+
```
239+
240+
`tests/proxy-matcher.test.ts` 静态扫 `next.config.mjs` 所有 rewrite source,
241+
对每个第一段路径 verify 它在 matcher 排除组里;忘了同步会 CI fail。
242+
216243
## 切换语言
217244

218245
`<LocaleToggle />` 用 next-intl 的 `useRouter().replace(pathname, { locale })`

tests/proxy-matcher.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* 防御 i18n middleware 误吃 backend rewrite 路径
3+
*
4+
* 历史背景(PR #335 hotfix):
5+
* i18n PR (#330) 改 proxy.ts 用 next-intl middleware 接管全站 locale
6+
* routing,但 matcher 的 negative-lookahead 排除组只列了
7+
* `api|trpc|_next|_vercel|.*\..*`,漏掉了 next.config.mjs rewrites
8+
* 里非 /api/ 前缀的 backend proxy 路径(/auth, /oauth, /analytics)。
9+
* 后果:用户访问 /oauth/render/github → 被 308 redirect 到
10+
* /en/oauth/render/github → rewrite source 不匹配带 locale 的版本 →
11+
* 落到 [locale]/oauth/... 404 → 登录炸。
12+
*
13+
* 这个测试就是防同样的事再发生:扫 next.config.mjs 里所有 rewrite
14+
* source,对每个 source 验证它的第一段路径在 proxy.ts matcher 的排除
15+
* 组里。任何人加新 rewrite 但忘了改 matcher,CI 会 fail。
16+
*/
17+
18+
import { describe, expect, test } from "vitest";
19+
import { readFileSync } from "node:fs";
20+
import { join } from "node:path";
21+
22+
const ROOT = join(__dirname, "..");
23+
24+
/**
25+
* 静态扫 next.config.mjs,提取所有 rewrites() 函数体里的 source 列表。
26+
* 不去 require/import 文件(它依赖 sentry wrap + env,加载麻烦),
27+
* 直接 regex 抓字面量。
28+
*/
29+
function extractRewriteSources(): string[] {
30+
const content = readFileSync(join(ROOT, "next.config.mjs"), "utf-8");
31+
// 提取 async rewrites() { ... } 函数体(非贪婪到下一个同级 async/closing)
32+
const match = content.match(
33+
/async rewrites\(\)[^{]*\{([\s\S]*?)^\s{2}\},?$/m,
34+
);
35+
if (!match) {
36+
throw new Error(
37+
"无法定位 next.config.mjs 的 async rewrites() 函数体;正则可能要更新",
38+
);
39+
}
40+
const body = match[1];
41+
// 抓所有 source: "..." 的字面量(含跨行的 source: \n "...")
42+
const sources = [...body.matchAll(/source:\s*\n?\s*["']([^"']+)["']/g)].map(
43+
(m) => m[1],
44+
);
45+
return sources;
46+
}
47+
48+
/**
49+
* 解析 proxy.ts matcher 字符串,提取 negative-lookahead 里的排除项。
50+
* 形如 "/((?!api|trpc|auth|oauth|...|_next|_vercel|.*\\..*).*)"
51+
* 返回 ['api', 'trpc', 'auth', 'oauth', ...]
52+
*/
53+
function extractMatcherExclusions(): string[] {
54+
const content = readFileSync(join(ROOT, "proxy.ts"), "utf-8");
55+
// 找 matcher: "..." 字符串
56+
const matcherMatch = content.match(/matcher:\s*["'`]([^"'`]+)["'`]/);
57+
if (!matcherMatch) {
58+
throw new Error("无法定位 proxy.ts 的 matcher 字段");
59+
}
60+
const matcher = matcherMatch[1];
61+
// 提取 (?!...) 里的内容
62+
const lookaheadMatch = matcher.match(/\(\?!([^)]+)\)/);
63+
if (!lookaheadMatch) {
64+
throw new Error("matcher 不是 negative-lookahead 形式,测试需要改写");
65+
}
66+
return lookaheadMatch[1].split("|").map((s) => s.replace(/\\/g, ""));
67+
}
68+
69+
/**
70+
* 取 path 的第一个非空段。
71+
* /oauth/render/github → 'oauth'
72+
* /auth/:path* → 'auth'
73+
* /api/admin/events → 'api'
74+
*/
75+
function firstSegment(pathLike: string): string {
76+
return pathLike.split("/").filter(Boolean)[0] ?? "";
77+
}
78+
79+
describe("proxy.ts matcher 必须排除所有 backend rewrite 路径", () => {
80+
const rewriteSources = extractRewriteSources();
81+
const exclusions = extractMatcherExclusions();
82+
83+
test("能扫到 rewrite sources(防 regex 失效静默通过)", () => {
84+
expect(rewriteSources.length).toBeGreaterThan(0);
85+
});
86+
87+
test("matcher 形如 negative-lookahead,能解析出排除组", () => {
88+
expect(exclusions).toContain("api");
89+
expect(exclusions).toContain("_next");
90+
});
91+
92+
test.each(rewriteSources)(
93+
'rewrite source "%s" 的第一段必须在 matcher 排除组里',
94+
(source) => {
95+
const seg = firstSegment(source);
96+
// 跳过参数段(如 :path* 不会作为 path 第一段,但兜底)
97+
if (!seg || seg.startsWith(":")) return;
98+
expect(
99+
exclusions,
100+
`加了新 rewrite 不带 /api/ 前缀的话,必须同步更新 proxy.ts 的 matcher 排除组。
101+
当前缺少 "${seg}",否则 next-intl middleware 会把请求 redirect 到 /<locale>/${seg}/...
102+
导致 rewrite 不匹配,落到 [locale]/${seg}/... 404(参考 PR #335 登录炸事故)。`,
103+
).toContain(seg);
104+
},
105+
);
106+
});

0 commit comments

Comments
 (0)