Skip to content

Commit fcf2414

Browse files
Lukas Holzerpieh
Lukas Holzer
andauthoredOct 9, 2024··
fix: handle non ASCII characters in cache-tag headers (#2645)
* fix: handle non ASCII characters in cache-tag headers * test: add cases for page router non-ascii paths and cache tags * test: add cases for app router non-ascii paths * test: add comma cases --------- Co-authored-by: pieh <misiek.piechowiak@gmail.com>
1 parent 0b74e13 commit fcf2414

File tree

10 files changed

+114
-22
lines changed

10 files changed

+114
-22
lines changed
 

‎playwright.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default defineConfig({
2525
extraHTTPHeaders: {
2626
/* Add debug logging for netlify cache headers */
2727
'x-nf-debug-logging': '1',
28+
'x-next-debug-logging': '1',
2829
},
2930
},
3031
timeout: 10 * 60 * 1000,

‎src/run/handlers/cache.cts

+12-6
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,17 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
139139
cacheValue.kind === 'APP_ROUTE'
140140
) {
141141
if (cacheValue.headers?.[NEXT_CACHE_TAGS_HEADER]) {
142-
const cacheTags = (cacheValue.headers[NEXT_CACHE_TAGS_HEADER] as string).split(',')
142+
const cacheTags = (cacheValue.headers[NEXT_CACHE_TAGS_HEADER] as string).split(/,|%2c/gi)
143143
requestContext.responseCacheTags = cacheTags
144144
} else if (
145145
(cacheValue.kind === 'PAGE' || cacheValue.kind === 'PAGES') &&
146146
typeof cacheValue.pageData === 'object'
147147
) {
148148
// pages router doesn't have cache tags headers in PAGE cache value
149149
// so we need to generate appropriate cache tags for it
150-
const cacheTags = [`_N_T_${key === '/index' ? '/' : key}`]
150+
// encode here to deal with non ASCII characters in the key
151+
152+
const cacheTags = [`_N_T_${key === '/index' ? '/' : encodeURI(key)}`]
151153
requestContext.responseCacheTags = cacheTags
152154
}
153155
}
@@ -341,10 +343,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
341343
if (data?.kind === 'PAGE' || data?.kind === 'PAGES') {
342344
const requestContext = getRequestContext()
343345
if (requestContext?.didPagesRouterOnDemandRevalidate) {
344-
const tag = `_N_T_${key === '/index' ? '/' : key}`
346+
// encode here to deal with non ASCII characters in the key
347+
const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
345348
getLogger().debug(`Purging CDN cache for: [${tag}]`)
346349
requestContext.trackBackgroundWork(
347-
purgeCache({ tags: [tag] }).catch((error) => {
350+
purgeCache({ tags: tag.split(/,|%2c/gi) }).catch((error) => {
348351
// TODO: add reporting here
349352
getLogger()
350353
.withError(error)
@@ -372,7 +375,9 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
372375
private async doRevalidateTag(tagOrTags: string | string[], ...args: any) {
373376
getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag')
374377

375-
const tags = Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]
378+
const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]).flatMap((tag) =>
379+
tag.split(/,|%2c/gi),
380+
)
376381

377382
const data: TagManifest = {
378383
revalidatedAt: Date.now(),
@@ -419,7 +424,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
419424
cacheEntry.value?.kind === 'ROUTE' ||
420425
cacheEntry.value?.kind === 'APP_ROUTE'
421426
) {
422-
cacheTags = (cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(',') || []
427+
cacheTags =
428+
(cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || []
423429
} else {
424430
return false
425431
}

‎tests/e2e/on-demand-app.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ test.describe('app router on-demand revalidation', () => {
4545
revalidateApiPath: '/api/on-demand-revalidate/tag?tag=show-4',
4646
expectedH1Content: 'Hello, Statically fetched show 4',
4747
},
48+
{
49+
label: 'revalidatePath (prerendered page with dynamic path) - non-ASCII variant',
50+
prerendered: true,
51+
pagePath: '/product/事前レンダリング,test',
52+
revalidateApiPath: `/api/on-demand-revalidate/path?path=/product/事前レンダリング,test`,
53+
expectedH1Content: 'Product 事前レンダリング,test',
54+
},
55+
{
56+
label: 'revalidatePath (not prerendered page with dynamic path) - non-ASCII variant',
57+
prerendered: false,
58+
pagePath: '/product/事前レンダリングされていない,test',
59+
revalidateApiPath: `/api/on-demand-revalidate/path?path=/product/事前レンダリングされていない,test`,
60+
expectedH1Content: 'Product 事前レンダリングされていない,test',
61+
},
4862
]) {
4963
test(label, async ({ page, pollUntilHeadersMatch, serverComponents }) => {
5064
// in case there is retry or some other test did hit that path before

‎tests/e2e/page-router.test.ts

+52-10
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,28 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
8080
revalidateApiBasePath: '/api/revalidate-no-await',
8181
expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
8282
},
83+
{
84+
label:
85+
'prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant',
86+
prerendered: true,
87+
pagePath: '/products/事前レンダリング,test',
88+
revalidateApiBasePath: '/api/revalidate',
89+
expectedH1Content: 'Product 事前レンダリング,test',
90+
},
91+
{
92+
label:
93+
'not prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant',
94+
prerendered: false,
95+
pagePath: '/products/事前レンダリングされていない,test',
96+
revalidateApiBasePath: '/api/revalidate',
97+
expectedH1Content: 'Product 事前レンダリングされていない,test',
98+
},
8399
]) {
84100
test(label, async ({ page, pollUntilHeadersMatch, pageRouter }) => {
85101
// in case there is retry or some other test did hit that path before
86102
// we want to make sure that cdn cache is not warmed up
87103
const purgeCdnCache = await page.goto(
88-
new URL(`/api/purge-cdn?path=${pagePath}`, pageRouter.url).href,
104+
new URL(`/api/purge-cdn?path=${encodeURI(pagePath)}`, pageRouter.url).href,
89105
)
90106
expect(purgeCdnCache?.status()).toBe(200)
91107

@@ -110,7 +126,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
110126
const headers1 = response1?.headers() || {}
111127
expect(response1?.status()).toBe(200)
112128
expect(headers1['x-nextjs-cache']).toBeUndefined()
113-
expect(headers1['netlify-cache-tag']).toBe(`_n_t_${pagePath}`)
129+
expect(headers1['netlify-cache-tag']).toBe(`_n_t_${encodeURI(pagePath).toLowerCase()}`)
114130
expect(headers1['netlify-cdn-cache-control']).toBe(
115131
's-maxage=31536000, stale-while-revalidate=31536000, durable',
116132
)
@@ -138,7 +154,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
138154
const headers1Json = response1Json?.headers() || {}
139155
expect(response1Json?.status()).toBe(200)
140156
expect(headers1Json['x-nextjs-cache']).toBeUndefined()
141-
expect(headers1Json['netlify-cache-tag']).toBe(`_n_t_${pagePath}`)
157+
expect(headers1Json['netlify-cache-tag']).toBe(`_n_t_${encodeURI(pagePath).toLowerCase()}`)
142158
expect(headers1Json['netlify-cdn-cache-control']).toBe(
143159
's-maxage=31536000, stale-while-revalidate=31536000, durable',
144160
)
@@ -459,14 +475,32 @@ test.describe('Page Router with basePath and i18n', () => {
459475
revalidateApiBasePath: '/api/revalidate-no-await',
460476
expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
461477
},
478+
{
479+
label:
480+
'prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant',
481+
prerendered: true,
482+
pagePath: '/products/事前レンダリング,test',
483+
revalidateApiBasePath: '/api/revalidate',
484+
expectedH1Content: 'Product 事前レンダリング,test',
485+
},
486+
{
487+
label:
488+
'not prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant',
489+
prerendered: false,
490+
pagePath: '/products/事前レンダリングされていない,test',
491+
revalidateApiBasePath: '/api/revalidate',
492+
expectedH1Content: 'Product 事前レンダリングされていない,test',
493+
},
462494
]) {
463495
test.describe(label, () => {
464496
test(`default locale`, async ({ page, pollUntilHeadersMatch, pageRouterBasePathI18n }) => {
465497
// in case there is retry or some other test did hit that path before
466498
// we want to make sure that cdn cache is not warmed up
467499
const purgeCdnCache = await page.goto(
468-
new URL(`/base/path/api/purge-cdn?path=/en${pagePath}`, pageRouterBasePathI18n.url)
469-
.href,
500+
new URL(
501+
`/base/path/api/purge-cdn?path=/en${encodeURI(pagePath)}`,
502+
pageRouterBasePathI18n.url,
503+
).href,
470504
)
471505
expect(purgeCdnCache?.status()).toBe(200)
472506

@@ -494,7 +528,9 @@ test.describe('Page Router with basePath and i18n', () => {
494528
const headers1ImplicitLocale = response1ImplicitLocale?.headers() || {}
495529
expect(response1ImplicitLocale?.status()).toBe(200)
496530
expect(headers1ImplicitLocale['x-nextjs-cache']).toBeUndefined()
497-
expect(headers1ImplicitLocale['netlify-cache-tag']).toBe(`_n_t_/en${pagePath}`)
531+
expect(headers1ImplicitLocale['netlify-cache-tag']).toBe(
532+
`_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
533+
)
498534
expect(headers1ImplicitLocale['netlify-cdn-cache-control']).toBe(
499535
's-maxage=31536000, stale-while-revalidate=31536000, durable',
500536
)
@@ -520,7 +556,9 @@ test.describe('Page Router with basePath and i18n', () => {
520556
const headers1ExplicitLocale = response1ExplicitLocale?.headers() || {}
521557
expect(response1ExplicitLocale?.status()).toBe(200)
522558
expect(headers1ExplicitLocale['x-nextjs-cache']).toBeUndefined()
523-
expect(headers1ExplicitLocale['netlify-cache-tag']).toBe(`_n_t_/en${pagePath}`)
559+
expect(headers1ExplicitLocale['netlify-cache-tag']).toBe(
560+
`_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
561+
)
524562
expect(headers1ExplicitLocale['netlify-cdn-cache-control']).toBe(
525563
's-maxage=31536000, stale-while-revalidate=31536000, durable',
526564
)
@@ -552,7 +590,9 @@ test.describe('Page Router with basePath and i18n', () => {
552590
const headers1Json = response1Json?.headers() || {}
553591
expect(response1Json?.status()).toBe(200)
554592
expect(headers1Json['x-nextjs-cache']).toBeUndefined()
555-
expect(headers1Json['netlify-cache-tag']).toBe(`_n_t_/en${pagePath}`)
593+
expect(headers1Json['netlify-cache-tag']).toBe(
594+
`_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
595+
)
556596
expect(headers1Json['netlify-cdn-cache-control']).toBe(
557597
's-maxage=31536000, stale-while-revalidate=31536000, durable',
558598
)
@@ -870,7 +910,7 @@ test.describe('Page Router with basePath and i18n', () => {
870910
const headers1 = response1?.headers() || {}
871911
expect(response1?.status()).toBe(200)
872912
expect(headers1['x-nextjs-cache']).toBeUndefined()
873-
expect(headers1['netlify-cache-tag']).toBe(`_n_t_/de${pagePath}`)
913+
expect(headers1['netlify-cache-tag']).toBe(`_n_t_/de${encodeURI(pagePath).toLowerCase()}`)
874914
expect(headers1['netlify-cdn-cache-control']).toBe(
875915
's-maxage=31536000, stale-while-revalidate=31536000, durable',
876916
)
@@ -899,7 +939,9 @@ test.describe('Page Router with basePath and i18n', () => {
899939
const headers1Json = response1Json?.headers() || {}
900940
expect(response1Json?.status()).toBe(200)
901941
expect(headers1Json['x-nextjs-cache']).toBeUndefined()
902-
expect(headers1Json['netlify-cache-tag']).toBe(`_n_t_/de${pagePath}`)
942+
expect(headers1Json['netlify-cache-tag']).toBe(
943+
`_n_t_/de${encodeURI(pagePath).toLowerCase()}`,
944+
)
903945
expect(headers1Json['netlify-cdn-cache-control']).toBe(
904946
's-maxage=31536000, stale-while-revalidate=31536000, durable',
905947
)

‎tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js

+5-5
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,22 @@ export async function getStaticProps({ params }) {
1717
}
1818
}
1919

20-
export const getStaticPaths = () => {
20+
/** @type {import('next').GetStaticPaths} */
21+
export const getStaticPaths = ({ locales }) => {
2122
return {
2223
paths: [
2324
{
2425
params: {
2526
slug: 'prerendered',
2627
},
27-
locale: 'en',
2828
},
2929
{
3030
params: {
31-
slug: 'prerendered',
31+
// Japanese prerendered (non-ascii) and comma
32+
slug: '事前レンダリング,test',
3233
},
33-
locale: 'de',
3434
},
35-
],
35+
].flatMap((pathDescription) => locales.map((locale) => ({ ...pathDescription, locale }))),
3636
fallback: 'blocking', // false or "blocking"
3737
}
3838
}

‎tests/fixtures/page-router/pages/products/[slug].js

+6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export const getStaticPaths = () => {
3030
slug: 'prerendered',
3131
},
3232
},
33+
{
34+
params: {
35+
// Japanese prerendered (non-ascii) and comma
36+
slug: '事前レンダリング,test',
37+
},
38+
},
3339
],
3440
fallback: 'blocking', // false or "blocking"
3541
}

‎tests/fixtures/server-components/app/api/purge-cdn/route.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
1515
)
1616
}
1717
try {
18-
await purgeCache({ tags: [`_N_T_${pathToPurge}`] })
18+
await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] })
1919
return NextResponse.json(
2020
{
2121
status: 'ok',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const Product = ({ params }) => (
2+
<div>
3+
<h1>Product {decodeURIComponent(params.slug)}</h1>
4+
<p>
5+
This page uses generateStaticParams() to prerender a Product
6+
<span data-testid="date-now">{new Date().toISOString()}</span>
7+
</p>
8+
</div>
9+
)
10+
11+
export async function generateStaticParams() {
12+
return [
13+
{
14+
// Japanese prerendered (non-ascii) and comma
15+
slug: '事前レンダリング,test',
16+
},
17+
]
18+
}
19+
20+
export default Product

‎tests/integration/cache-handler.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ describe('page router', () => {
4646
// the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier
4747
'/products/an-incredibly-long-product-',
4848
'/products/prerendered',
49+
'/products/事前レンダリング,te',
4950
'/static/revalidate-automatic',
5051
'/static/revalidate-manual',
5152
'/static/revalidate-slow',
@@ -359,6 +360,7 @@ describe('plugin', () => {
359360
'/api/static/first',
360361
'/api/static/second',
361362
'/index',
363+
'/product/事前レンダリング,test',
362364
'/revalidate-fetch',
363365
'/static-fetch-1',
364366
'/static-fetch-2',

‎tests/integration/static.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ test<FixtureTestContext>('requesting a non existing page route that needs to be
3737
expect(entries.map(({ key }) => decodeBlobKey(key.substring(0, 50))).sort()).toEqual([
3838
'/products/an-incredibly-long-product-',
3939
'/products/prerendered',
40+
'/products/事前レンダリング,te',
4041
'/static/revalidate-automatic',
4142
'/static/revalidate-manual',
4243
'/static/revalidate-slow',

0 commit comments

Comments
 (0)
Please sign in to comment.