Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit f3e24b1

Browse files
authored
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‎

Lines changed: 12 additions & 2 deletions
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‎

Lines changed: 147 additions & 0 deletions
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+
})
Lines changed: 36 additions & 0 deletions
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+
}
Lines changed: 5 additions & 0 deletions
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.
Lines changed: 10 additions & 0 deletions
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+
}
Lines changed: 20 additions & 0 deletions
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+
}
Lines changed: 21 additions & 0 deletions
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+
}
Lines changed: 11 additions & 0 deletions
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+
}
Lines changed: 19 additions & 0 deletions
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‎

Lines changed: 1 addition & 0 deletions
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',

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /