Skip to content

Commit 467b3a3

Browse files
authored
Merge branch 'main' into minivan/free-github-squares
2 parents d36d9bf + 38e58b3 commit 467b3a3

File tree

7 files changed

+145
-50
lines changed

7 files changed

+145
-50
lines changed

package-lock.json

+10-35
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@netlify/edge-bundler": "^12.2.3",
5555
"@netlify/edge-functions": "^2.11.0",
5656
"@netlify/eslint-config-node": "^7.0.1",
57-
"@netlify/functions": "^2.8.2",
57+
"@netlify/functions": "^3.0.0",
5858
"@netlify/serverless-functions-api": "^1.30.1",
5959
"@netlify/zip-it-and-ship-it": "^9.41.0",
6060
"@opentelemetry/api": "^1.8.0",

src/run/handlers/cache.cts

+18-5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { type Span } from '@opentelemetry/api'
1111
import type { PrerenderManifest } from 'next/dist/build/index.js'
1212
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1313

14+
import { name as nextRuntimePkgName, version as nextRuntimePkgVersion } from '../../../package.json'
1415
import {
1516
type CacheHandlerContext,
1617
type CacheHandlerForMultipleVersions,
@@ -30,6 +31,8 @@ type TagManifest = { revalidatedAt: number }
3031

3132
type TagManifestBlobCache = Record<string, Promise<TagManifest>>
3233

34+
const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}`
35+
3336
export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
3437
options: CacheHandlerContext
3538
revalidatedTags: string[]
@@ -345,9 +348,15 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
345348
if (requestContext?.didPagesRouterOnDemandRevalidate) {
346349
// encode here to deal with non ASCII characters in the key
347350
const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
351+
const tags = tag.split(/,|%2c/gi).filter(Boolean)
352+
353+
if (tags.length === 0) {
354+
return
355+
}
356+
348357
getLogger().debug(`Purging CDN cache for: [${tag}]`)
349358
requestContext.trackBackgroundWork(
350-
purgeCache({ tags: tag.split(/,|%2c/gi) }).catch((error) => {
359+
purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
351360
// TODO: add reporting here
352361
getLogger()
353362
.withError(error)
@@ -375,9 +384,13 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
375384
private async doRevalidateTag(tagOrTags: string | string[], ...args: any) {
376385
getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag')
377386

378-
const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]).flatMap((tag) =>
379-
tag.split(/,|%2c/gi),
380-
)
387+
const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags])
388+
.flatMap((tag) => tag.split(/,|%2c/gi))
389+
.filter(Boolean)
390+
391+
if (tags.length === 0) {
392+
return
393+
}
381394

382395
const data: TagManifest = {
383396
revalidatedAt: Date.now(),
@@ -393,7 +406,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
393406
}),
394407
)
395408

396-
await purgeCache({ tags }).catch((error) => {
409+
await purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
397410
// TODO: add reporting here
398411
getLogger()
399412
.withError(error)

tests/e2e/export.test.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ test.describe('next/image is using Netlify Image CDN', () => {
6464

6565
expect(nextImageResponse.status()).toBe(200)
6666
// ensure next/image is using Image CDN
67-
// source image is jpg, but when requesting it through Image CDN avif will be returned
68-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
67+
// source image is jpg, but when requesting it through Image CDN avif or webp will be returned
68+
expect(['image/avif', 'image/webp']).toContain(
69+
await nextImageResponse.headerValue('content-type'),
70+
)
6971

7072
await expectImageWasLoaded(page.locator('img'))
7173
})

tests/e2e/simple-app.test.ts

+16-6
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ test.describe('next/image is using Netlify Image CDN', () => {
119119

120120
expect(nextImageResponse.status()).toBe(200)
121121
// ensure next/image is using Image CDN
122-
// source image is jpg, but when requesting it through Image CDN avif will be returned
123-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
122+
// source image is jpg, but when requesting it through Image CDN avif or webp will be returned
123+
expect(['image/avif', 'image/webp']).toContain(
124+
await nextImageResponse.headerValue('content-type'),
125+
)
124126

125127
await expectImageWasLoaded(page.locator('img'))
126128
})
@@ -142,7 +144,9 @@ test.describe('next/image is using Netlify Image CDN', () => {
142144
)
143145

144146
expect(nextImageResponse.status()).toBe(200)
145-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
147+
expect(['image/avif', 'image/webp']).toContain(
148+
await nextImageResponse.headerValue('content-type'),
149+
)
146150

147151
await expectImageWasLoaded(page.locator('img'))
148152
})
@@ -164,7 +168,9 @@ test.describe('next/image is using Netlify Image CDN', () => {
164168
)
165169

166170
expect(nextImageResponse.status()).toBe(200)
167-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
171+
expect(['image/avif', 'image/webp']).toContain(
172+
await nextImageResponse.headerValue('content-type'),
173+
)
168174

169175
await expectImageWasLoaded(page.locator('img'))
170176
})
@@ -183,7 +189,9 @@ test.describe('next/image is using Netlify Image CDN', () => {
183189
)
184190

185191
expect(nextImageResponse?.status()).toBe(200)
186-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
192+
expect(['image/avif', 'image/webp']).toContain(
193+
await nextImageResponse.headerValue('content-type'),
194+
)
187195

188196
await expectImageWasLoaded(page.locator('img'))
189197
})
@@ -203,7 +211,9 @@ test.describe('next/image is using Netlify Image CDN', () => {
203211
)
204212

205213
expect(nextImageResponse.status()).toEqual(200)
206-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
214+
expect(['image/avif', 'image/webp']).toContain(
215+
await nextImageResponse.headerValue('content-type'),
216+
)
207217

208218
await expectImageWasLoaded(page.locator('img'))
209219
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { unstable_cache } from 'next/cache'
2+
3+
export const dynamic = 'force-dynamic'
4+
5+
const getData = unstable_cache(
6+
async () => {
7+
return {
8+
timestamp: Date.now(),
9+
}
10+
},
11+
[],
12+
{
13+
revalidate: 1,
14+
},
15+
)
16+
17+
export default async function Page() {
18+
const data = await getData()
19+
20+
return <pre>{JSON.stringify(data, null, 2)}</pre>
21+
}

tests/integration/simple-app.test.ts

+75-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,21 @@ import { cp } from 'node:fs/promises'
44
import { createRequire } from 'node:module'
55
import { join } from 'node:path'
66
import { gunzipSync } from 'node:zlib'
7+
import { HttpResponse, http, passthrough } from 'msw'
8+
import { setupServer } from 'msw/node'
79
import { gt, prerelease } from 'semver'
810
import { v4 } from 'uuid'
9-
import { Mock, afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
11+
import {
12+
Mock,
13+
afterAll,
14+
afterEach,
15+
beforeAll,
16+
beforeEach,
17+
describe,
18+
expect,
19+
test,
20+
vi,
21+
} from 'vitest'
1022
import { getPatchesToApply } from '../../src/build/content/server.js'
1123
import { type FixtureTestContext } from '../utils/contexts.js'
1224
import {
@@ -36,9 +48,32 @@ vi.mock('node:fs/promises', async (importOriginal) => {
3648
}
3749
})
3850

51+
let server: ReturnType<typeof setupServer>
52+
3953
// Disable the verbose logging of the lambda-local runtime
4054
getLogger().level = 'alert'
4155

56+
const purgeAPI = vi.fn()
57+
58+
beforeAll(() => {
59+
server = setupServer(
60+
http.post('https://api.netlify.com/api/v1/purge', async ({ request }) => {
61+
purgeAPI(await request.json())
62+
63+
return HttpResponse.json({
64+
ok: true,
65+
})
66+
}),
67+
http.all(/.*/, () => passthrough()),
68+
)
69+
server.listen()
70+
})
71+
72+
afterAll(() => {
73+
// Disable API mocking after the tests are done.
74+
server.close()
75+
})
76+
4277
beforeEach<FixtureTestContext>(async (ctx) => {
4378
// set for each test a new deployID and siteID
4479
ctx.deployID = generateRandomObjectID()
@@ -48,9 +83,15 @@ beforeEach<FixtureTestContext>(async (ctx) => {
4883
// hide debug logs in tests
4984
vi.spyOn(console, 'debug').mockImplementation(() => {})
5085

86+
purgeAPI.mockClear()
87+
5188
await startMockBlobStore(ctx)
5289
})
5390

91+
afterEach(() => {
92+
vi.unstubAllEnvs()
93+
})
94+
5495
test<FixtureTestContext>('Test that the simple next app is working', async (ctx) => {
5596
await createFixture('simple', ctx)
5697
await runPlugin(ctx)
@@ -210,6 +251,39 @@ test<FixtureTestContext>('cacheable route handler is cached on cdn (revalidate=f
210251
)
211252
})
212253

254+
test<FixtureTestContext>('purge API is not used when unstable_cache cache entry gets stale', async (ctx) => {
255+
await createFixture('simple', ctx)
256+
await runPlugin(ctx)
257+
258+
// set the NETLIFY_PURGE_API_TOKEN to get pass token check and allow fetch call to be made
259+
vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'mock')
260+
261+
const page1 = await invokeFunction(ctx, {
262+
url: '/unstable_cache',
263+
})
264+
const data1 = load(page1.body)('pre').text()
265+
266+
// allow for cache entry to get stale
267+
await new Promise((res) => setTimeout(res, 2000))
268+
269+
const page2 = await invokeFunction(ctx, {
270+
url: '/unstable_cache',
271+
})
272+
const data2 = load(page2.body)('pre').text()
273+
274+
const page3 = await invokeFunction(ctx, {
275+
url: '/unstable_cache',
276+
})
277+
const data3 = load(page3.body)('pre').text()
278+
279+
expect(purgeAPI, 'Purge API should not be hit').toHaveBeenCalledTimes(0)
280+
expect(
281+
data2,
282+
'Should use stale cache entry for current request and invalidate it in background',
283+
).toBe(data1)
284+
expect(data3, 'Should use updated cache entry').not.toBe(data2)
285+
})
286+
213287
test<FixtureTestContext>('cacheable route handler is cached on cdn (revalidate=15)', async (ctx) => {
214288
await createFixture('simple', ctx)
215289
await runPlugin(ctx)

0 commit comments

Comments
 (0)