Skip to content

Commit f3e24b1

Browse files
authoredFeb 25, 2025
fix: narrow down middleware i18n locale matcher to concrete locales (#2768)
* test: add test cases to i18n middleware with exclusions * fix: narrow down middleware i18n locale matcher to concrete locales
1 parent 28217d4 commit f3e24b1

File tree

11 files changed

+283
-2
lines changed

11 files changed

+283
-2
lines changed
 

‎src/build/functions/edge.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,23 @@ const augmentMatchers = (
3636
matchers: NextDefinition['matchers'],
3737
ctx: PluginContext,
3838
): NextDefinition['matchers'] => {
39-
if (!ctx.buildConfig.i18n) {
39+
const i18NConfig = ctx.buildConfig.i18n
40+
if (!i18NConfig) {
4041
return matchers
4142
}
4243
return matchers.flatMap((matcher) => {
4344
if (matcher.originalSource && matcher.locale !== false) {
4445
return [
45-
matcher,
46+
matcher.regexp
47+
? {
48+
...matcher,
49+
// https://github.com/vercel/next.js/blob/5e236c9909a768dc93856fdfad53d4f4adc2db99/packages/next/src/build/analysis/get-page-static-info.ts#L332-L336
50+
// Next is producing pretty broad matcher for i18n locale. Presumably rest of their infrastructure protects this broad matcher
51+
// from matching on non-locale paths. For us this becomes request entry point, so we need to narrow it down to just defined locales
52+
// otherwise users might get unexpected matches on paths like `/api*`
53+
regexp: matcher.regexp.replace(/\[\^\/\.]+/g, `(${i18NConfig.locales.join('|')})`),
54+
}
55+
: matcher,
4656
{
4757
...matcher,
4858
regexp: pathToRegexp(matcher.originalSource).source,

‎tests/e2e/edge-middleware.test.ts

+147
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,150 @@ test('json data rewrite works', async ({ middlewarePages }) => {
6969

7070
expect(data.pageProps.message).toBeDefined()
7171
})
72+
73+
// those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
74+
// hiding any potential edge/server issues
75+
test.describe('Middleware with i18n and excluded paths', () => {
76+
const DEFAULT_LOCALE = 'en'
77+
78+
/** helper function to extract JSON data from page rendering data with `<pre>{JSON.stringify(data)}</pre>` */
79+
function extractDataFromHtml(html: string): Record<string, any> {
80+
const match = html.match(/<pre>(?<rawInput>[^<]+)<\/pre>/)
81+
if (!match || !match.groups?.rawInput) {
82+
console.error('<pre> not found in html input', {
83+
html,
84+
})
85+
throw new Error('Failed to extract data from HTML')
86+
}
87+
88+
const { rawInput } = match.groups
89+
const unescapedInput = rawInput.replaceAll('&quot;', '"')
90+
try {
91+
return JSON.parse(unescapedInput)
92+
} catch (originalError) {
93+
console.error('Failed to parse JSON', {
94+
originalError,
95+
rawInput,
96+
unescapedInput,
97+
})
98+
}
99+
throw new Error('Failed to extract data from HTML')
100+
}
101+
102+
// those tests hit paths ending with `/json` which has special handling in middleware
103+
// to return JSON response from middleware itself
104+
test.describe('Middleware response path', () => {
105+
test('should match on non-localized not excluded page path', async ({
106+
middlewareI18nExcludedPaths,
107+
}) => {
108+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/json`)
109+
110+
expect(response.headers.get('x-test-used-middleware')).toBe('true')
111+
expect(response.status).toBe(200)
112+
113+
const { nextUrlPathname, nextUrlLocale } = await response.json()
114+
115+
expect(nextUrlPathname).toBe('/json')
116+
expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
117+
})
118+
119+
test('should match on localized not excluded page path', async ({
120+
middlewareI18nExcludedPaths,
121+
}) => {
122+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/json`)
123+
124+
expect(response.headers.get('x-test-used-middleware')).toBe('true')
125+
expect(response.status).toBe(200)
126+
127+
const { nextUrlPathname, nextUrlLocale } = await response.json()
128+
129+
expect(nextUrlPathname).toBe('/json')
130+
expect(nextUrlLocale).toBe('fr')
131+
})
132+
})
133+
134+
// those tests hit paths that don't end with `/json` while still satisfying middleware matcher
135+
// so middleware should pass them through to origin
136+
test.describe('Middleware passthrough', () => {
137+
test('should match on non-localized not excluded page path', async ({
138+
middlewareI18nExcludedPaths,
139+
}) => {
140+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/html`)
141+
142+
expect(response.headers.get('x-test-used-middleware')).toBe('true')
143+
expect(response.status).toBe(200)
144+
expect(response.headers.get('content-type')).toMatch(/text\/html/)
145+
146+
const html = await response.text()
147+
const { locale, params } = extractDataFromHtml(html)
148+
149+
expect(params).toMatchObject({ catchall: ['html'] })
150+
expect(locale).toBe(DEFAULT_LOCALE)
151+
})
152+
153+
test('should match on localized not excluded page path', async ({
154+
middlewareI18nExcludedPaths,
155+
}) => {
156+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/html`)
157+
158+
expect(response.headers.get('x-test-used-middleware')).toBe('true')
159+
expect(response.status).toBe(200)
160+
expect(response.headers.get('content-type')).toMatch(/text\/html/)
161+
162+
const html = await response.text()
163+
const { locale, params } = extractDataFromHtml(html)
164+
165+
expect(params).toMatchObject({ catchall: ['html'] })
166+
expect(locale).toBe('fr')
167+
})
168+
})
169+
170+
// those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
171+
// without going through middleware
172+
test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
173+
test('should NOT match on non-localized excluded API path', async ({
174+
middlewareI18nExcludedPaths,
175+
}) => {
176+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/api/html`)
177+
178+
expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
179+
expect(response.status).toBe(200)
180+
181+
const { params } = await response.json()
182+
183+
expect(params).toMatchObject({ catchall: ['html'] })
184+
})
185+
186+
test('should NOT match on non-localized excluded page path', async ({
187+
middlewareI18nExcludedPaths,
188+
}) => {
189+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/excluded`)
190+
191+
expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
192+
expect(response.status).toBe(200)
193+
expect(response.headers.get('content-type')).toMatch(/text\/html/)
194+
195+
const html = await response.text()
196+
const { locale, params } = extractDataFromHtml(html)
197+
198+
expect(params).toMatchObject({ catchall: ['excluded'] })
199+
expect(locale).toBe(DEFAULT_LOCALE)
200+
})
201+
202+
test('should NOT match on localized excluded page path', async ({
203+
middlewareI18nExcludedPaths,
204+
}) => {
205+
const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/excluded`)
206+
207+
expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
208+
expect(response.status).toBe(200)
209+
expect(response.headers.get('content-type')).toMatch(/text\/html/)
210+
211+
const html = await response.text()
212+
const { locale, params } = extractDataFromHtml(html)
213+
214+
expect(params).toMatchObject({ catchall: ['excluded'] })
215+
expect(locale).toBe('fr')
216+
})
217+
})
218+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextResponse } from 'next/server'
2+
import type { NextRequest } from 'next/server'
3+
4+
export async function middleware(request: NextRequest) {
5+
const url = request.nextUrl
6+
7+
// if path ends with /json we create response in middleware, otherwise we pass it through
8+
// to next server to get page or api response from it
9+
const response = url.pathname.includes('/json')
10+
? NextResponse.json({
11+
requestUrlPathname: new URL(request.url).pathname,
12+
nextUrlPathname: request.nextUrl.pathname,
13+
nextUrlLocale: request.nextUrl.locale,
14+
})
15+
: NextResponse.next()
16+
17+
response.headers.set('x-test-used-middleware', 'true')
18+
19+
return response
20+
}
21+
22+
// matcher copied from example in https://nextjs.org/docs/pages/building-your-application/routing/middleware#matcher
23+
// with `excluded` segment added to exclusion
24+
export const config = {
25+
matcher: [
26+
/*
27+
* Match all request paths except for the ones starting with:
28+
* - api (API routes)
29+
* - excluded (for testing localized routes and not just API routes)
30+
* - _next/static (static files)
31+
* - _next/image (image optimization files)
32+
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
33+
*/
34+
'/((?!api|excluded|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
35+
],
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
output: 'standalone',
3+
eslint: {
4+
ignoreDuringBuilds: true,
5+
},
6+
i18n: {
7+
locales: ['en', 'fr'],
8+
defaultLocale: 'en',
9+
},
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "middleware-i18n-excluded-paths",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"postinstall": "next build",
7+
"dev": "next dev",
8+
"build": "next build"
9+
},
10+
"dependencies": {
11+
"next": "latest",
12+
"react": "18.2.0",
13+
"react-dom": "18.2.0"
14+
},
15+
"devDependencies": {
16+
"@types/node": "^17.0.12",
17+
"@types/react": "18.2.47",
18+
"typescript": "^5.2.2"
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { GetStaticPaths, GetStaticProps } from 'next'
2+
3+
export default function CatchAll({ params, locale }) {
4+
return <pre>{JSON.stringify({ params, locale }, null, 2)}</pre>
5+
}
6+
7+
export const getStaticPaths: GetStaticPaths = () => {
8+
return {
9+
paths: [],
10+
fallback: 'blocking',
11+
}
12+
}
13+
14+
export const getStaticProps: GetStaticProps = ({ params, locale }) => {
15+
return {
16+
props: {
17+
params,
18+
locale,
19+
},
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next'
2+
3+
type ResponseData = {
4+
params: {
5+
catchall?: string[]
6+
}
7+
}
8+
9+
export default function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
10+
res.status(200).json({ params: req.query })
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2017",
4+
"lib": ["dom", "dom.iterable", "esnext"],
5+
"allowJs": true,
6+
"skipLibCheck": true,
7+
"strict": false,
8+
"noEmit": true,
9+
"incremental": true,
10+
"module": "esnext",
11+
"esModuleInterop": true,
12+
"moduleResolution": "node",
13+
"resolveJsonModule": true,
14+
"isolatedModules": true,
15+
"jsx": "preserve"
16+
},
17+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
18+
"exclude": ["node_modules"]
19+
}

‎tests/prepare.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const e2eOnlyFixtures = new Set([
2323
'after',
2424
'cli-before-regional-blobs-support',
2525
'dist-dir',
26+
'middleware-i18n-excluded-paths',
2627
// There is also a bug on Windows on Node.js 18.20.6, that cause build failures on this fixture
2728
// see https://github.com/opennextjs/opennextjs-netlify/actions/runs/13268839161/job/37043172448?pr=2749#step:12:78
2829
'middleware-og',

‎tests/utils/create-e2e-fixture.ts

+1
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export const fixtureFactories = {
333333
pnpm: () => createE2EFixture('pnpm', { packageManger: 'pnpm' }),
334334
bun: () => createE2EFixture('simple', { packageManger: 'bun' }),
335335
middleware: () => createE2EFixture('middleware'),
336+
middlewareI18nExcludedPaths: () => createE2EFixture('middleware-i18n-excluded-paths'),
336337
middlewareOg: () => createE2EFixture('middleware-og'),
337338
middlewarePages: () => createE2EFixture('middleware-pages'),
338339
pageRouter: () => createE2EFixture('page-router'),

0 commit comments

Comments
 (0)
Please sign in to comment.