From d7b3458a2ffd62f460e57c7529ad6debc3fa7521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 18 Dec 2024 13:19:05 +0000 Subject: [PATCH 1/5] fix: make `revalidateTags` no-op when list of tags is empty --- src/run/handlers/cache.cts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 69dcd4271c..8805ad60ba 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -345,6 +345,12 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { if (requestContext?.didPagesRouterOnDemandRevalidate) { // encode here to deal with non ASCII characters in the key const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}` + const tags = tag.split(/,|%2c/gi) + + if (tags.length === 0) { + return + } + getLogger().debug(`Purging CDN cache for: [${tag}]`) requestContext.trackBackgroundWork( purgeCache({ tags: tag.split(/,|%2c/gi) }).catch((error) => { @@ -379,6 +385,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { tag.split(/,|%2c/gi), ) + if (tags.length === 0) { + return + } + const data: TagManifest = { revalidatedAt: Date.now(), } From 04e54514fd9afd5a61dc4ad824aa37277efd50f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 18 Dec 2024 13:20:42 +0000 Subject: [PATCH 2/5] fix: oops --- src/run/handlers/cache.cts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 8805ad60ba..951f4c2e44 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -353,7 +353,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { getLogger().debug(`Purging CDN cache for: [${tag}]`) requestContext.trackBackgroundWork( - purgeCache({ tags: tag.split(/,|%2c/gi) }).catch((error) => { + purgeCache({ tags }).catch((error) => { // TODO: add reporting here getLogger() .withError(error) From 28339a30cd796f81b3c889fee72cab9ee4f4df67 Mon Sep 17 00:00:00 2001 From: pieh Date: Thu, 19 Dec 2024 11:46:28 +0100 Subject: [PATCH 3/5] test: add integration test for site-wide purge calls --- .../simple/app/unstable_cache/page.js | 21 ++++++ tests/integration/simple-app.test.ts | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/fixtures/simple/app/unstable_cache/page.js diff --git a/tests/fixtures/simple/app/unstable_cache/page.js b/tests/fixtures/simple/app/unstable_cache/page.js new file mode 100644 index 0000000000..6dc19a2df5 --- /dev/null +++ b/tests/fixtures/simple/app/unstable_cache/page.js @@ -0,0 +1,21 @@ +import { unstable_cache } from 'next/cache' + +export const dynamic = 'force-dynamic' + +const getData = unstable_cache( + async () => { + return { + timestamp: Date.now(), + } + }, + [], + { + revalidate: 1, + }, +) + +export default async function Page() { + const data = await getData() + + return
{JSON.stringify(data, null, 2)}
+} diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index c082a8c480..35dc7e282d 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -4,6 +4,8 @@ import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' +import { HttpResponse, http, passthrough } from 'msw' +import { setupServer } from 'msw/node' import { gt, prerelease } from 'semver' import { v4 } from 'uuid' import { Mock, afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' @@ -22,6 +24,8 @@ import { startMockBlobStore, } from '../utils/helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' +import { purgeCache } from '@netlify/functions' +import { afterEach } from 'node:test' const mockedCp = cp as Mock< Parameters<(typeof import('node:fs/promises'))['cp']>, @@ -36,9 +40,32 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) +let server: ReturnType + // Disable the verbose logging of the lambda-local runtime getLogger().level = 'alert' +const purgeAPI = vi.fn() + +beforeAll(() => { + server = setupServer( + http.post('https://api.netlify.com/api/v1/purge', async ({ request }) => { + purgeAPI(await request.json()) + + return HttpResponse.json({ + ok: true, + }) + }), + http.all(/.*/, () => passthrough()), + ) + server.listen() +}) + +afterAll(() => { + // Disable API mocking after the tests are done. + server.close() +}) + beforeEach(async (ctx) => { // set for each test a new deployID and siteID ctx.deployID = generateRandomObjectID() @@ -48,9 +75,15 @@ beforeEach(async (ctx) => { // hide debug logs in tests vi.spyOn(console, 'debug').mockImplementation(() => {}) + purgeAPI.mockClear() + await startMockBlobStore(ctx) }) +afterEach(() => { + vi.unstubAllEnvs() +}) + test('Test that the simple next app is working', async (ctx) => { await createFixture('simple', ctx) await runPlugin(ctx) @@ -210,6 +243,39 @@ test('cacheable route handler is cached on cdn (revalidate=f ) }) +test('purge API is not used when unstable_cache cache entry gets stale', async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + // set the NETLIFY_PURGE_API_TOKEN to get pass token check and allow fetch call to be made + vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'mock') + + const page1 = await invokeFunction(ctx, { + url: '/unstable_cache', + }) + const data1 = load(page1.body)('pre').text() + + // allow for cache entry to get stale + await new Promise((res) => setTimeout(res, 2000)) + + const page2 = await invokeFunction(ctx, { + url: '/unstable_cache', + }) + const data2 = load(page2.body)('pre').text() + + const page3 = await invokeFunction(ctx, { + url: '/unstable_cache', + }) + const data3 = load(page3.body)('pre').text() + + expect(purgeAPI, 'Purge API should not be hit').toHaveBeenCalledTimes(0) + expect( + data2, + 'Should use stale cache entry for current request and invalidate it in background', + ).toBe(data1) + expect(data3, 'Should use updated cache entry').not.toBe(data2) +}) + test('cacheable route handler is cached on cdn (revalidate=15)', async (ctx) => { await createFixture('simple', ctx) await runPlugin(ctx) From f112f291b20e9472542660937cc14bd459e8ef1d Mon Sep 17 00:00:00 2001 From: pieh Date: Thu, 19 Dec 2024 17:36:24 +0100 Subject: [PATCH 4/5] fix: filter out empty tags --- src/run/handlers/cache.cts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 4bee5bb30a..c00a4d5e14 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -348,7 +348,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { if (requestContext?.didPagesRouterOnDemandRevalidate) { // encode here to deal with non ASCII characters in the key const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}` - const tags = tag.split(/,|%2c/gi) + const tags = tag.split(/,|%2c/gi).filter(Boolean) if (tags.length === 0) { return @@ -384,9 +384,9 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { private async doRevalidateTag(tagOrTags: string | string[], ...args: any) { getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag') - const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]).flatMap((tag) => - tag.split(/,|%2c/gi), - ) + const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]) + .flatMap((tag) => tag.split(/,|%2c/gi)) + .filter(Boolean) if (tags.length === 0) { return From 4a02e345b378a22fec355ced93a1e9ffa9886a87 Mon Sep 17 00:00:00 2001 From: pieh Date: Fri, 20 Dec 2024 11:26:43 +0100 Subject: [PATCH 5/5] test: correct afterEach import --- tests/integration/simple-app.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 35dc7e282d..790836fdf8 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -8,7 +8,17 @@ import { HttpResponse, http, passthrough } from 'msw' import { setupServer } from 'msw/node' import { gt, prerelease } from 'semver' import { v4 } from 'uuid' -import { Mock, afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' +import { + Mock, + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' import { getPatchesToApply } from '../../src/build/content/server.js' import { type FixtureTestContext } from '../utils/contexts.js' import { @@ -24,8 +34,6 @@ import { startMockBlobStore, } from '../utils/helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -import { purgeCache } from '@netlify/functions' -import { afterEach } from 'node:test' const mockedCp = cp as Mock< Parameters<(typeof import('node:fs/promises'))['cp']>,