From f00b542100a39182ac2e32521705999267bcd55d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 19 Dec 2024 13:22:19 +0100 Subject: [PATCH 1/5] test: update assertion to allow image cdn responses be webp (#2729) --- tests/e2e/export.test.ts | 6 ++++-- tests/e2e/simple-app.test.ts | 22 ++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/e2e/export.test.ts b/tests/e2e/export.test.ts index 715ec18f1d..916f668f97 100644 --- a/tests/e2e/export.test.ts +++ b/tests/e2e/export.test.ts @@ -64,8 +64,10 @@ test.describe('next/image is using Netlify Image CDN', () => { expect(nextImageResponse.status()).toBe(200) // ensure next/image is using Image CDN - // source image is jpg, but when requesting it through Image CDN avif will be returned - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + // source image is jpg, but when requesting it through Image CDN avif or webp will be returned + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) diff --git a/tests/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts index 15f1f585fe..980ad84dd0 100644 --- a/tests/e2e/simple-app.test.ts +++ b/tests/e2e/simple-app.test.ts @@ -119,8 +119,10 @@ test.describe('next/image is using Netlify Image CDN', () => { expect(nextImageResponse.status()).toBe(200) // ensure next/image is using Image CDN - // source image is jpg, but when requesting it through Image CDN avif will be returned - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + // source image is jpg, but when requesting it through Image CDN avif or webp will be returned + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) @@ -142,7 +144,9 @@ test.describe('next/image is using Netlify Image CDN', () => { ) expect(nextImageResponse.status()).toBe(200) - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) @@ -164,7 +168,9 @@ test.describe('next/image is using Netlify Image CDN', () => { ) expect(nextImageResponse.status()).toBe(200) - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) @@ -183,7 +189,9 @@ test.describe('next/image is using Netlify Image CDN', () => { ) expect(nextImageResponse?.status()).toBe(200) - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) @@ -203,7 +211,9 @@ test.describe('next/image is using Netlify Image CDN', () => { ) expect(nextImageResponse.status()).toEqual(200) - expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif') + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) await expectImageWasLoaded(page.locator('img')) }) From 871f7b9d232015d1332756ad949bdd66d95f9084 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 19 Dec 2024 15:05:34 +0100 Subject: [PATCH 2/5] fix: set user agent for purge requests (#2730) --- package-lock.json | 45 +++++++++----------------------------- package.json | 2 +- src/run/handlers/cache.cts | 19 ++++++++++------ 3 files changed, 23 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4b92aa56c..d6e4396a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@netlify/edge-bundler": "^12.2.3", "@netlify/edge-functions": "^2.11.0", "@netlify/eslint-config-node": "^7.0.1", - "@netlify/functions": "^2.8.2", + "@netlify/functions": "^3.0.0", "@netlify/serverless-functions-api": "^1.30.1", "@netlify/zip-it-and-ship-it": "^9.41.0", "@opentelemetry/api": "^1.8.0", @@ -4780,15 +4780,15 @@ } }, "node_modules/@netlify/functions": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.8.2.tgz", - "integrity": "sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-3.0.0.tgz", + "integrity": "sha512-XXf9mNw4+fkxUzukDpJtzc32bl1+YlXZwEhc5ZgMcTbJPLpgRLDs5WWSPJ4eY/Mv1ZFvtxmMwmfgoQYVt68Qog==", "dev": true, "dependencies": { - "@netlify/serverless-functions-api": "1.26.1" + "@netlify/serverless-functions-api": "1.30.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" } }, "node_modules/@netlify/functions-utils": { @@ -4805,19 +4805,6 @@ "node": "^14.16.0 || >=16.0.0" } }, - "node_modules/@netlify/functions/node_modules/@netlify/serverless-functions-api": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.26.1.tgz", - "integrity": "sha512-q3L9i3HoNfz0SGpTIS4zTcKBbRkxzCRpd169eyiTuk3IwcPC3/85mzLHranlKo2b+HYT0gu37YxGB45aD8A3Tw==", - "dev": true, - "dependencies": { - "@netlify/node-cookies": "^0.1.0", - "urlpattern-polyfill": "8.0.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@netlify/git-utils": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@netlify/git-utils/-/git-utils-5.1.1.tgz", @@ -39465,24 +39452,12 @@ } }, "@netlify/functions": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.8.2.tgz", - "integrity": "sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-3.0.0.tgz", + "integrity": "sha512-XXf9mNw4+fkxUzukDpJtzc32bl1+YlXZwEhc5ZgMcTbJPLpgRLDs5WWSPJ4eY/Mv1ZFvtxmMwmfgoQYVt68Qog==", "dev": true, "requires": { - "@netlify/serverless-functions-api": "1.26.1" - }, - "dependencies": { - "@netlify/serverless-functions-api": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.26.1.tgz", - "integrity": "sha512-q3L9i3HoNfz0SGpTIS4zTcKBbRkxzCRpd169eyiTuk3IwcPC3/85mzLHranlKo2b+HYT0gu37YxGB45aD8A3Tw==", - "dev": true, - "requires": { - "@netlify/node-cookies": "^0.1.0", - "urlpattern-polyfill": "8.0.2" - } - } + "@netlify/serverless-functions-api": "1.30.1" } }, "@netlify/functions-utils": { diff --git a/package.json b/package.json index caff8bf295..5c78f70bd2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@netlify/edge-bundler": "^12.2.3", "@netlify/edge-functions": "^2.11.0", "@netlify/eslint-config-node": "^7.0.1", - "@netlify/functions": "^2.8.2", + "@netlify/functions": "^3.0.0", "@netlify/serverless-functions-api": "^1.30.1", "@netlify/zip-it-and-ship-it": "^9.41.0", "@opentelemetry/api": "^1.8.0", diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 69dcd4271c..2ec5cb4bb3 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -11,6 +11,7 @@ import { type Span } from '@opentelemetry/api' import type { PrerenderManifest } from 'next/dist/build/index.js' import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js' +import { name as nextRuntimePkgName, version as nextRuntimePkgVersion } from '../../../package.json' import { type CacheHandlerContext, type CacheHandlerForMultipleVersions, @@ -30,6 +31,8 @@ type TagManifest = { revalidatedAt: number } type TagManifestBlobCache = Record> +const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}` + export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { options: CacheHandlerContext revalidatedTags: string[] @@ -347,12 +350,14 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}` getLogger().debug(`Purging CDN cache for: [${tag}]`) requestContext.trackBackgroundWork( - purgeCache({ tags: tag.split(/,|%2c/gi) }).catch((error) => { - // TODO: add reporting here - getLogger() - .withError(error) - .error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`) - }), + purgeCache({ tags: tag.split(/,|%2c/gi), userAgent: purgeCacheUserAgent }).catch( + (error) => { + // TODO: add reporting here + getLogger() + .withError(error) + .error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`) + }, + ), ) } } @@ -393,7 +398,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { }), ) - await purgeCache({ tags }).catch((error) => { + await purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { // TODO: add reporting here getLogger() .withError(error) From 38e58b3f46b78b307bcf7576a00849c41f495b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 20 Dec 2024 10:42:50 +0000 Subject: [PATCH 3/5] fix: make `revalidateTags` no-op when list of tags is empty (#2727) * fix: make `revalidateTags` no-op when list of tags is empty * fix: oops * test: add integration test for site-wide purge calls * fix: filter out empty tags * test: correct afterEach import --------- Co-authored-by: pieh --- src/run/handlers/cache.cts | 30 +++++--- .../simple/app/unstable_cache/page.js | 21 +++++ tests/integration/simple-app.test.ts | 76 ++++++++++++++++++- 3 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/simple/app/unstable_cache/page.js diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 2ec5cb4bb3..c00a4d5e14 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -348,16 +348,20 @@ 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).filter(Boolean) + + if (tags.length === 0) { + return + } + getLogger().debug(`Purging CDN cache for: [${tag}]`) requestContext.trackBackgroundWork( - purgeCache({ tags: tag.split(/,|%2c/gi), userAgent: purgeCacheUserAgent }).catch( - (error) => { - // TODO: add reporting here - getLogger() - .withError(error) - .error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`) - }, - ), + purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { + // TODO: add reporting here + getLogger() + .withError(error) + .error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`) + }), ) } } @@ -380,9 +384,13 @@ 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 + } const data: TagManifest = { revalidatedAt: Date.now(), 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..790836fdf8 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -4,9 +4,21 @@ 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' +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 { @@ -36,9 +48,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 +83,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 +251,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 4df130d9249ab2d33942bf3fa9f48ef5619b9ed2 Mon Sep 17 00:00:00 2001 From: Ivan Zarea Date: Fri, 20 Dec 2024 12:06:17 +0100 Subject: [PATCH 4/5] Removing a useless log (#2704) --- src/run/next.cts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/run/next.cts b/src/run/next.cts index 1712e991e2..17859c4835 100644 --- a/src/run/next.cts +++ b/src/run/next.cts @@ -17,8 +17,6 @@ import { getRegionalBlobStore } from './regional-blob-store.cjs' // @ts-ignore ignoring readonly NODE_ENV process.env.NODE_ENV = 'production' -console.time('import next server') - // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequestHandlers } = require('next/dist/server/lib/start-server.js') // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -76,8 +74,6 @@ ResponseCache.prototype.get = function get(...getArgs: unknown[]) { return originalGet.apply(this, getArgs) } -console.timeEnd('import next server') - type FS = typeof import('fs') export type HtmlBlob = { From c3e328c67aee611f83567ebf95c86abcfdd52d93 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 20 Dec 2024 16:44:19 +0100 Subject: [PATCH 5/5] chore(main): release 5.9.2 (#2732) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 08a8c70cf8..0eebf4fc35 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.9.1" + ".": "5.9.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a71177a991..9ab7cdf164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [5.9.2](https://github.com/opennextjs/opennextjs-netlify/compare/v5.9.1...v5.9.2) (2024-12-20) + + +### Bug Fixes + +* make `revalidateTags` no-op when list of tags is empty ([#2727](https://github.com/opennextjs/opennextjs-netlify/issues/2727)) ([38e58b3](https://github.com/opennextjs/opennextjs-netlify/commit/38e58b3f46b78b307bcf7576a00849c41f495b52)) +* set user agent for purge requests ([#2730](https://github.com/opennextjs/opennextjs-netlify/issues/2730)) ([871f7b9](https://github.com/opennextjs/opennextjs-netlify/commit/871f7b9d232015d1332756ad949bdd66d95f9084)) + ## [5.9.1](https://github.com/opennextjs/opennextjs-netlify/compare/v5.9.0...v5.9.1) (2024-12-18) diff --git a/package-lock.json b/package-lock.json index d6e4396a86..f3a23902ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.9.1", + "version": "5.9.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@netlify/plugin-nextjs", - "version": "5.9.1", + "version": "5.9.2", "license": "MIT", "devDependencies": { "@fastly/http-compute-js": "1.1.4", diff --git a/package.json b/package.json index 5c78f70bd2..18bf026a07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.9.1", + "version": "5.9.2", "description": "Run Next.js seamlessly on Netlify", "main": "./dist/index.js", "type": "module",