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 b4b92aa56c..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", @@ -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..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", @@ -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..c00a4d5e14 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[] @@ -345,9 +348,15 @@ 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) }).catch((error) => { + purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { // TODO: add reporting here getLogger() .withError(error) @@ -375,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(), @@ -393,7 +406,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) 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 = { 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')) }) 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)