diff --git a/package-lock.json b/package-lock.json index 23307eebb0..e59baf5fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fastly/http-compute-js": "1.1.5", "@netlify/blobs": "^8.2.0", "@netlify/build": "^32.2.0", + "@netlify/config": "^23.0.1", "@netlify/edge-bundler": "^13.0.3", "@netlify/edge-functions": "^2.12.0", "@netlify/eslint-config-node": "^7.0.1", @@ -2858,7 +2859,8 @@ "version": "2.2.5", "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", @@ -3687,10 +3689,11 @@ } }, "node_modules/@netlify/api": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/@netlify/api/-/api-13.4.0.tgz", - "integrity": "sha512-Y/RDvIhMrxWoyhD3DV+um2sv1HFFxoG4LnaB8RqQu7Ei3zEiA7GwqLQm28YZfUR8uEerOPnWiuluKGmqKScX2Q==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@netlify/api/-/api-14.0.1.tgz", + "integrity": "sha512-yzBDOpVQJBW95qCLLXrM9sdJ9dlYu9StSfYhmCwA+RvWoPmZ5oqIiNbiKQE4Qe0qLEss2SQyhub+b64NY1orOA==", "dev": true, + "license": "MIT", "dependencies": { "@netlify/open-api": "^2.37.0", "lodash-es": "^4.17.21", @@ -3700,7 +3703,7 @@ "qs": "^6.9.6" }, "engines": { - "node": "^14.16.0 || >=16.0.0" + "node": ">=18.14.0" } }, "node_modules/@netlify/binary-info": { @@ -3799,6 +3802,81 @@ } } }, + "node_modules/@netlify/build/node_modules/@netlify/api": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@netlify/api/-/api-13.4.0.tgz", + "integrity": "sha512-Y/RDvIhMrxWoyhD3DV+um2sv1HFFxoG4LnaB8RqQu7Ei3zEiA7GwqLQm28YZfUR8uEerOPnWiuluKGmqKScX2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@netlify/open-api": "^2.37.0", + "lodash-es": "^4.17.21", + "micro-api-client": "^3.3.0", + "node-fetch": "^3.0.0", + "p-wait-for": "^5.0.0", + "qs": "^6.9.6" + }, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/@netlify/build/node_modules/@netlify/config": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@netlify/config/-/config-22.2.0.tgz", + "integrity": "sha512-33SwZJrLXqNCZJiKCyPXaxLVHGRcQhEV6+RwjKt6IVDvShZ2l1pLQnAS0Z/2xKsZUhQvKhrjXBAts/5eXt9WTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "@netlify/api": "^13.4.0", + "@netlify/headers-parser": "^8.0.0", + "@netlify/redirect-parser": "^14.5.1", + "chalk": "^5.0.0", + "cron-parser": "^4.1.0", + "deepmerge": "^4.2.2", + "dot-prop": "^7.0.0", + "execa": "^7.0.0", + "fast-safe-stringify": "^2.0.7", + "figures": "^5.0.0", + "filter-obj": "^5.0.0", + "find-up": "^6.0.0", + "indent-string": "^5.0.0", + "is-plain-obj": "^4.0.0", + "js-yaml": "^4.0.0", + "map-obj": "^5.0.0", + "node-fetch": "^3.3.1", + "omit.js": "^2.0.2", + "p-locate": "^6.0.0", + "path-type": "^5.0.0", + "tomlify-j0.4": "^3.0.0", + "validate-npm-package-name": "^4.0.0", + "yargs": "^17.6.0" + }, + "bin": { + "netlify-config": "bin.js" + }, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/@netlify/build/node_modules/@netlify/headers-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-8.0.0.tgz", + "integrity": "sha512-TAxRPOpPDphDttDukWj1mTJtjxA81FhxV9EBOwP3DipqKMNs1mXlucMu/3kvIKG1o2XMrQbvSttHK8URdVROrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "escape-string-regexp": "^5.0.0", + "fast-safe-stringify": "^2.0.7", + "is-plain-obj": "^4.0.0", + "map-obj": "^5.0.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, "node_modules/@netlify/build/node_modules/@netlify/opentelemetry-utils": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@netlify/opentelemetry-utils/-/opentelemetry-utils-1.3.1.tgz", @@ -3811,6 +3889,23 @@ "@opentelemetry/api": "~1.8.0" } }, + "node_modules/@netlify/build/node_modules/@netlify/redirect-parser": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-14.5.1.tgz", + "integrity": "sha512-pg5Oa/da6P0djfLOaBj/5IiB4tXNzGlvl2IK6MzxM4W0zkwdLprw3NjduBeaSmWe7h+9WZKKVTh2IVNEXqs3iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "fast-safe-stringify": "^2.1.1", + "filter-obj": "^5.0.0", + "is-plain-obj": "^4.0.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, "node_modules/@netlify/build/node_modules/clean-stack": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", @@ -3951,15 +4046,16 @@ } }, "node_modules/@netlify/config": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@netlify/config/-/config-22.2.0.tgz", - "integrity": "sha512-33SwZJrLXqNCZJiKCyPXaxLVHGRcQhEV6+RwjKt6IVDvShZ2l1pLQnAS0Z/2xKsZUhQvKhrjXBAts/5eXt9WTA==", + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@netlify/config/-/config-23.0.1.tgz", + "integrity": "sha512-KoI5HMLAmkrIn7icNlHAeCA4FwvnptShgJUPvjKm63ygYgGarM+MjtrKA10SDyBbONWLldmcuhh4gGGy8EszzQ==", "dev": true, + "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", - "@netlify/api": "^13.4.0", - "@netlify/headers-parser": "^8.0.0", - "@netlify/redirect-parser": "^14.5.1", + "@netlify/api": "^14.0.1", + "@netlify/headers-parser": "^9.0.0", + "@netlify/redirect-parser": "^15.0.0", "chalk": "^5.0.0", "cron-parser": "^4.1.0", "deepmerge": "^4.2.2", @@ -3985,7 +4081,7 @@ "netlify-config": "bin.js" }, "engines": { - "node": "^14.16.0 || >=16.0.0" + "node": ">=18.14.0" } }, "node_modules/@netlify/config/node_modules/execa": { @@ -4467,10 +4563,11 @@ "dev": true }, "node_modules/@netlify/headers-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-8.0.0.tgz", - "integrity": "sha512-TAxRPOpPDphDttDukWj1mTJtjxA81FhxV9EBOwP3DipqKMNs1mXlucMu/3kvIKG1o2XMrQbvSttHK8URdVROrw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-9.0.0.tgz", + "integrity": "sha512-l1p7qMOr8AF1K1NMFYAjXSKv6cC0rDLc9q2YwFusHcdeLY6Xok3cECHFxgaS0AIkRGKWUqaPd/w/vQRDFoVOQw==", "dev": true, + "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", "escape-string-regexp": "^5.0.0", @@ -4480,7 +4577,7 @@ "path-exists": "^5.0.0" }, "engines": { - "node": "^14.16.0 || >=16.0.0" + "node": ">=18.14.0" } }, "node_modules/@netlify/open-api": { @@ -4581,10 +4678,11 @@ } }, "node_modules/@netlify/redirect-parser": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-14.5.1.tgz", - "integrity": "sha512-pg5Oa/da6P0djfLOaBj/5IiB4tXNzGlvl2IK6MzxM4W0zkwdLprw3NjduBeaSmWe7h+9WZKKVTh2IVNEXqs3iQ==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-15.0.0.tgz", + "integrity": "sha512-k85Gj//UbYj8JhJAjPV6div8rZRqPz3Pp++egIl4NqUH6r26q798CnjLZ6XPmtXfYDa8Uyg9TwJjZLPk/EnqPQ==", "dev": true, + "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", "fast-safe-stringify": "^2.1.1", @@ -4593,7 +4691,7 @@ "path-exists": "^5.0.0" }, "engines": { - "node": "^14.16.0 || >=16.0.0" + "node": ">=18.14.0" } }, "node_modules/@netlify/run-utils": { @@ -10743,7 +10841,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fastq": { "version": "1.15.0", @@ -36540,9 +36639,9 @@ } }, "@netlify/api": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/@netlify/api/-/api-13.4.0.tgz", - "integrity": "sha512-Y/RDvIhMrxWoyhD3DV+um2sv1HFFxoG4LnaB8RqQu7Ei3zEiA7GwqLQm28YZfUR8uEerOPnWiuluKGmqKScX2Q==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@netlify/api/-/api-14.0.1.tgz", + "integrity": "sha512-yzBDOpVQJBW95qCLLXrM9sdJ9dlYu9StSfYhmCwA+RvWoPmZ5oqIiNbiKQE4Qe0qLEss2SQyhub+b64NY1orOA==", "dev": true, "requires": { "@netlify/open-api": "^2.37.0", @@ -36631,6 +36730,66 @@ "yargs": "^17.6.0" }, "dependencies": { + "@netlify/api": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@netlify/api/-/api-13.4.0.tgz", + "integrity": "sha512-Y/RDvIhMrxWoyhD3DV+um2sv1HFFxoG4LnaB8RqQu7Ei3zEiA7GwqLQm28YZfUR8uEerOPnWiuluKGmqKScX2Q==", + "dev": true, + "requires": { + "@netlify/open-api": "^2.37.0", + "lodash-es": "^4.17.21", + "micro-api-client": "^3.3.0", + "node-fetch": "^3.0.0", + "p-wait-for": "^5.0.0", + "qs": "^6.9.6" + } + }, + "@netlify/config": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@netlify/config/-/config-22.2.0.tgz", + "integrity": "sha512-33SwZJrLXqNCZJiKCyPXaxLVHGRcQhEV6+RwjKt6IVDvShZ2l1pLQnAS0Z/2xKsZUhQvKhrjXBAts/5eXt9WTA==", + "dev": true, + "requires": { + "@iarna/toml": "^2.2.5", + "@netlify/api": "^13.4.0", + "@netlify/headers-parser": "^8.0.0", + "@netlify/redirect-parser": "^14.5.1", + "chalk": "^5.0.0", + "cron-parser": "^4.1.0", + "deepmerge": "^4.2.2", + "dot-prop": "^7.0.0", + "execa": "^7.0.0", + "fast-safe-stringify": "^2.0.7", + "figures": "^5.0.0", + "filter-obj": "^5.0.0", + "find-up": "^6.0.0", + "indent-string": "^5.0.0", + "is-plain-obj": "^4.0.0", + "js-yaml": "^4.0.0", + "map-obj": "^5.0.0", + "node-fetch": "^3.3.1", + "omit.js": "^2.0.2", + "p-locate": "^6.0.0", + "path-type": "^5.0.0", + "tomlify-j0.4": "^3.0.0", + "validate-npm-package-name": "^4.0.0", + "yargs": "^17.6.0" + } + }, + "@netlify/headers-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-8.0.0.tgz", + "integrity": "sha512-TAxRPOpPDphDttDukWj1mTJtjxA81FhxV9EBOwP3DipqKMNs1mXlucMu/3kvIKG1o2XMrQbvSttHK8URdVROrw==", + "dev": true, + "requires": { + "@iarna/toml": "^2.2.5", + "escape-string-regexp": "^5.0.0", + "fast-safe-stringify": "^2.0.7", + "is-plain-obj": "^4.0.0", + "map-obj": "^5.0.0", + "path-exists": "^5.0.0" + } + }, "@netlify/opentelemetry-utils": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@netlify/opentelemetry-utils/-/opentelemetry-utils-1.3.1.tgz", @@ -36638,6 +36797,19 @@ "dev": true, "requires": {} }, + "@netlify/redirect-parser": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-14.5.1.tgz", + "integrity": "sha512-pg5Oa/da6P0djfLOaBj/5IiB4tXNzGlvl2IK6MzxM4W0zkwdLprw3NjduBeaSmWe7h+9WZKKVTh2IVNEXqs3iQ==", + "dev": true, + "requires": { + "@iarna/toml": "^2.2.5", + "fast-safe-stringify": "^2.1.1", + "filter-obj": "^5.0.0", + "is-plain-obj": "^4.0.0", + "path-exists": "^5.0.0" + } + }, "clean-stack": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", @@ -36731,15 +36903,15 @@ } }, "@netlify/config": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@netlify/config/-/config-22.2.0.tgz", - "integrity": "sha512-33SwZJrLXqNCZJiKCyPXaxLVHGRcQhEV6+RwjKt6IVDvShZ2l1pLQnAS0Z/2xKsZUhQvKhrjXBAts/5eXt9WTA==", + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@netlify/config/-/config-23.0.1.tgz", + "integrity": "sha512-KoI5HMLAmkrIn7icNlHAeCA4FwvnptShgJUPvjKm63ygYgGarM+MjtrKA10SDyBbONWLldmcuhh4gGGy8EszzQ==", "dev": true, "requires": { "@iarna/toml": "^2.2.5", - "@netlify/api": "^13.4.0", - "@netlify/headers-parser": "^8.0.0", - "@netlify/redirect-parser": "^14.5.1", + "@netlify/api": "^14.0.1", + "@netlify/headers-parser": "^9.0.0", + "@netlify/redirect-parser": "^15.0.0", "chalk": "^5.0.0", "cron-parser": "^4.1.0", "deepmerge": "^4.2.2", @@ -37129,9 +37301,9 @@ } }, "@netlify/headers-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-8.0.0.tgz", - "integrity": "sha512-TAxRPOpPDphDttDukWj1mTJtjxA81FhxV9EBOwP3DipqKMNs1mXlucMu/3kvIKG1o2XMrQbvSttHK8URdVROrw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-9.0.0.tgz", + "integrity": "sha512-l1p7qMOr8AF1K1NMFYAjXSKv6cC0rDLc9q2YwFusHcdeLY6Xok3cECHFxgaS0AIkRGKWUqaPd/w/vQRDFoVOQw==", "dev": true, "requires": { "@iarna/toml": "^2.2.5", @@ -37210,9 +37382,9 @@ "dev": true }, "@netlify/redirect-parser": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-14.5.1.tgz", - "integrity": "sha512-pg5Oa/da6P0djfLOaBj/5IiB4tXNzGlvl2IK6MzxM4W0zkwdLprw3NjduBeaSmWe7h+9WZKKVTh2IVNEXqs3iQ==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@netlify/redirect-parser/-/redirect-parser-15.0.0.tgz", + "integrity": "sha512-k85Gj//UbYj8JhJAjPV6div8rZRqPz3Pp++egIl4NqUH6r26q798CnjLZ6XPmtXfYDa8Uyg9TwJjZLPk/EnqPQ==", "dev": true, "requires": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 6c9e177c86..bb94776b27 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@fastly/http-compute-js": "1.1.5", "@netlify/blobs": "^8.2.0", "@netlify/build": "^32.2.0", + "@netlify/config": "^23.0.1", "@netlify/edge-bundler": "^13.0.3", "@netlify/edge-functions": "^2.12.0", "@netlify/eslint-config-node": "^7.0.1", diff --git a/src/build/content/static.test.ts b/src/build/content/static.test.ts index ed1927b298..6d1b811472 100644 --- a/src/build/content/static.test.ts +++ b/src/build/content/static.test.ts @@ -10,6 +10,7 @@ import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' import { decodeBlobKey, encodeBlobKey, mockFileSystem } from '../../../tests/index.js' import { type FixtureTestContext } from '../../../tests/utils/contexts.js' import { createFsFixture } from '../../../tests/utils/fixture.js' +import { HtmlBlob } from '../../shared/blob-types.cjs' import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js' import { copyStaticAssets, copyStaticContent } from './static.js' @@ -22,18 +23,19 @@ type Context = FixtureTestContext & { const createFsFixtureWithBasePath = ( fixture: Record, ctx: Omit, - { basePath = '', // eslint-disable-next-line unicorn/no-useless-undefined i18n = undefined, dynamicRoutes = {}, + pagesManifest = {}, }: { basePath?: string i18n?: Pick, 'locales'> dynamicRoutes?: { [route: string]: Pick } + pagesManifest?: Record } = {}, ) => { return createFsFixture( @@ -49,6 +51,7 @@ const createFsFixtureWithBasePath = ( }, } as Pick), [join(ctx.publishDir, 'prerender-manifest.json')]: JSON.stringify({ dynamicRoutes }), + [join(ctx.publishDir, 'server', 'pages-manifest.json')]: JSON.stringify(pagesManifest), }, ctx, ) @@ -62,10 +65,7 @@ async function readDirRecursive(dir: string) { return paths } -let failBuildMock: Mock< - Parameters, - ReturnType -> +let failBuildMock: Mock const dontFailTest: PluginContext['utils']['build']['failBuild'] = () => { return undefined as never @@ -197,12 +197,13 @@ describe('Regular Repository layout', () => { ) }) - describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fallback', () => { + describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => { test('no i18n', async ({ pluginContext, ...ctx }) => { await createFsFixtureWithBasePath( { '.next/server/pages/test.html': '', '.next/server/pages/test2.html': '', + '.next/server/pages/test3.html': '', '.next/server/pages/test3.json': '', '.next/server/pages/blog/[slug].html': '', }, @@ -213,27 +214,36 @@ describe('Regular Repository layout', () => { fallback: '/blog/[slug].html', }, }, + pagesManifest: { + '/blog/[slug]': 'pages/blog/[slug].js', + '/test': 'pages/test.html', + '/test2': 'pages/test2.html', + '/test3': 'pages/test3.js', + }, }, ) await copyStaticContent(pluginContext) const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) - const expectedStaticPages = ['blog/[slug].html', 'test.html', 'test2.html'] - const expectedFallbacks = new Set(['blog/[slug].html']) + const expectedHtmlBlobs = ['blog/[slug].html', 'test.html', 'test2.html'] + const expectedFullyStaticPages = new Set(['test.html', 'test2.html']) - expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs) - for (const page of expectedStaticPages) { - const expectedIsFallback = expectedFallbacks.has(page) + for (const page of expectedHtmlBlobs) { + const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page) const blob = JSON.parse( await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), - ) + ) as HtmlBlob - expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + expect( + blob, + `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`, + ).toEqual({ html: '', - isFallback: expectedIsFallback, + isFullyStaticPage: expectedIsFullyStaticPage, }) } }) @@ -243,10 +253,12 @@ describe('Regular Repository layout', () => { { '.next/server/pages/de/test.html': '', '.next/server/pages/de/test2.html': '', + '.next/server/pages/de/test3.html': '', '.next/server/pages/de/test3.json': '', '.next/server/pages/de/blog/[slug].html': '', '.next/server/pages/en/test.html': '', '.next/server/pages/en/test2.html': '', + '.next/server/pages/en/test3.html': '', '.next/server/pages/en/test3.json': '', '.next/server/pages/en/blog/[slug].html': '', }, @@ -260,13 +272,21 @@ describe('Regular Repository layout', () => { i18n: { locales: ['en', 'de'], }, + pagesManifest: { + '/blog/[slug]': 'pages/blog/[slug].js', + '/en/test': 'pages/en/test.html', + '/de/test': 'pages/de/test.html', + '/en/test2': 'pages/en/test2.html', + '/de/test2': 'pages/de/test2.html', + '/test3': 'pages/test3.js', + }, }, ) await copyStaticContent(pluginContext) const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) - const expectedStaticPages = [ + const expectedHtmlBlobs = [ 'de/blog/[slug].html', 'de/test.html', 'de/test2.html', @@ -274,20 +294,28 @@ describe('Regular Repository layout', () => { 'en/test.html', 'en/test2.html', ] - const expectedFallbacks = new Set(['en/blog/[slug].html', 'de/blog/[slug].html']) + const expectedFullyStaticPages = new Set([ + 'en/test.html', + 'de/test.html', + 'en/test2.html', + 'de/test2.html', + ]) - expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs) - for (const page of expectedStaticPages) { - const expectedIsFallback = expectedFallbacks.has(page) + for (const page of expectedHtmlBlobs) { + const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page) const blob = JSON.parse( await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), - ) + ) as HtmlBlob - expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + expect( + blob, + `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`, + ).toEqual({ html: '', - isFallback: expectedIsFallback, + isFullyStaticPage: expectedIsFullyStaticPage, }) } }) @@ -419,12 +447,13 @@ describe('Mono Repository', () => { ) }) - describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fallback', () => { + describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => { test('no i18n', async ({ pluginContext, ...ctx }) => { await createFsFixtureWithBasePath( { 'apps/app-1/.next/server/pages/test.html': '', 'apps/app-1/.next/server/pages/test2.html': '', + 'apps/app-1/.next/server/pages/test3.html': '', 'apps/app-1/.next/server/pages/test3.json': '', 'apps/app-1/.next/server/pages/blog/[slug].html': '', }, @@ -435,27 +464,36 @@ describe('Mono Repository', () => { fallback: '/blog/[slug].html', }, }, + pagesManifest: { + '/blog/[slug]': 'pages/blog/[slug].js', + '/test': 'pages/test.html', + '/test2': 'pages/test2.html', + '/test3': 'pages/test3.js', + }, }, ) await copyStaticContent(pluginContext) const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) - const expectedStaticPages = ['blog/[slug].html', 'test.html', 'test2.html'] - const expectedFallbacks = new Set(['blog/[slug].html']) + const expectedHtmlBlobs = ['blog/[slug].html', 'test.html', 'test2.html'] + const expectedFullyStaticPages = new Set(['test.html', 'test2.html']) - expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs) - for (const page of expectedStaticPages) { - const expectedIsFallback = expectedFallbacks.has(page) + for (const page of expectedHtmlBlobs) { + const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page) const blob = JSON.parse( await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), - ) + ) as HtmlBlob - expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + expect( + blob, + `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`, + ).toEqual({ html: '', - isFallback: expectedIsFallback, + isFullyStaticPage: expectedIsFullyStaticPage, }) } }) @@ -465,10 +503,12 @@ describe('Mono Repository', () => { { 'apps/app-1/.next/server/pages/de/test.html': '', 'apps/app-1/.next/server/pages/de/test2.html': '', + 'apps/app-1/.next/server/pages/de/test3.html': '', 'apps/app-1/.next/server/pages/de/test3.json': '', 'apps/app-1/.next/server/pages/de/blog/[slug].html': '', 'apps/app-1/.next/server/pages/en/test.html': '', 'apps/app-1/.next/server/pages/en/test2.html': '', + 'apps/app-1/.next/server/pages/en/test3.html': '', 'apps/app-1/.next/server/pages/en/test3.json': '', 'apps/app-1/.next/server/pages/en/blog/[slug].html': '', }, @@ -482,13 +522,21 @@ describe('Mono Repository', () => { i18n: { locales: ['en', 'de'], }, + pagesManifest: { + '/blog/[slug]': 'pages/blog/[slug].js', + '/en/test': 'pages/en/test.html', + '/de/test': 'pages/de/test.html', + '/en/test2': 'pages/en/test2.html', + '/de/test2': 'pages/de/test2.html', + '/test3': 'pages/test3.js', + }, }, ) await copyStaticContent(pluginContext) const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) - const expectedStaticPages = [ + const expectedHtmlBlobs = [ 'de/blog/[slug].html', 'de/test.html', 'de/test2.html', @@ -496,20 +544,28 @@ describe('Mono Repository', () => { 'en/test.html', 'en/test2.html', ] - const expectedFallbacks = new Set(['en/blog/[slug].html', 'de/blog/[slug].html']) + const expectedFullyStaticPages = new Set([ + 'en/test.html', + 'de/test.html', + 'en/test2.html', + 'de/test2.html', + ]) - expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs) - for (const page of expectedStaticPages) { - const expectedIsFallback = expectedFallbacks.has(page) + for (const page of expectedHtmlBlobs) { + const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page) const blob = JSON.parse( await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), - ) + ) as HtmlBlob - expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + expect( + blob, + `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`, + ).toEqual({ html: '', - isFallback: expectedIsFallback, + isFullyStaticPage: expectedIsFullyStaticPage, }) } }) diff --git a/src/build/content/static.ts b/src/build/content/static.ts index d251a17d97..47dded47bb 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -27,21 +27,23 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { }) const fallbacks = ctx.getFallbacks(await ctx.getPrerenderManifest()) + const fullyStaticPages = await ctx.getFullyStaticHtmlPages() try { await mkdir(destDir, { recursive: true }) await Promise.all( paths - .filter((path) => !paths.includes(`${path.slice(0, -5)}.json`)) + .filter((path) => !path.endsWith('.json') && !paths.includes(`${path.slice(0, -5)}.json`)) .map(async (path): Promise => { const html = await readFile(join(srcDir, path), 'utf-8') verifyNetlifyForms(ctx, html) const isFallback = fallbacks.includes(path.slice(0, -5)) + const isFullyStaticPage = !isFallback && fullyStaticPages.includes(path) await writeFile( join(destDir, await encodeBlobKey(path)), - JSON.stringify({ html, isFallback } satisfies HtmlBlob), + JSON.stringify({ html, isFullyStaticPage } satisfies HtmlBlob), 'utf-8', ) }), diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 74c0ffc329..9148d0dd56 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { join, relative, resolve } from 'node:path' -import { join as posixJoin } from 'node:path/posix' +import { join as posixJoin, relative as posixRelative } from 'node:path/posix' import { fileURLToPath } from 'node:url' import type { @@ -12,6 +12,7 @@ import type { } from '@netlify/build' import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js' import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js' +import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js' import type { NextConfigComplete } from 'next/dist/server/config-shared.js' import { satisfies } from 'semver' @@ -370,6 +371,35 @@ export class PluginContext { return this.#fallbacks } + #fullyStaticHtmlPages: string[] | null = null + /** + * Get an array of fully static pages router pages (no `getServerSideProps` or `getStaticProps`). + * Those are being served as-is without involving CacheHandler, so we need to keep track of them + * to make sure we apply permanent caching headers for responses that use them. + */ + async getFullyStaticHtmlPages(): Promise { + if (!this.#fullyStaticHtmlPages) { + const pagesManifest = JSON.parse( + await readFile(join(this.publishDir, 'server/pages-manifest.json'), 'utf-8'), + ) as PagesManifest + + this.#fullyStaticHtmlPages = Object.values(pagesManifest) + .filter( + (filePath) => + // Limit handling to pages router files (App Router pages should not be included in pages-manifest.json + // as they have their own app-paths-manifest.json) + filePath.startsWith('pages/') && + // Fully static pages will have entries in the pages-manifest.json pointing to .html files. + // Pages with data fetching exports will point to .js files. + filePath.endsWith('.html'), + ) + // values will be prefixed with `pages/`, so removing it here for consistency with other methods + // like `getFallbacks` that return the route without the prefix + .map((filePath) => posixRelative('pages', filePath)) + } + return this.#fullyStaticHtmlPages + } + /** Fails a build with a message and an optional error */ failBuild(message: string, error?: unknown): never { return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined) diff --git a/src/run/next.cts b/src/run/next.cts index fc6b3fcbef..085cf057de 100644 --- a/src/run/next.cts +++ b/src/run/next.cts @@ -1,5 +1,6 @@ -import fs from 'fs/promises' -import { relative, resolve } from 'path' +import { AsyncLocalStorage } from 'node:async_hooks' +import fs from 'node:fs/promises' +import { relative, resolve } from 'node:path' // @ts-expect-error no types installed import { patchFs } from 'fs-monkey' @@ -79,6 +80,13 @@ ResponseCache.prototype.get = function get(...getArgs: unknown[]) { type FS = typeof import('fs') export async function getMockedRequestHandler(...args: Parameters) { + const initContext = { initializingServer: true } + /** + * Using async local storage to identify operations happening as part of server initialization + * and not part of handling of current request. + */ + const initAsyncLocalStorage = new AsyncLocalStorage() + const tracer = getTracer() return tracer.withActiveSpan('mocked request handler', async () => { const ofs = { ...fs } @@ -96,9 +104,16 @@ export async function getMockedRequestHandler(...args: Parameters(relPath, 'staticHtml.get') if (file !== null) { - if (!file.isFallback) { + if (file.isFullyStaticPage) { const requestContext = getRequestContext() - if (requestContext) { + // On server initialization Next.js attempt to preload all pages + // which might result in reading .html files from the file system + // for fully static pages. We don't want to capture those cases. + // Note that Next.js does NOT cache read html files so on actual requests + // that those will be served, it will read those AGAIN and then we do + // want to capture fact of reading them. + const { initializingServer } = initAsyncLocalStorage.getStore() ?? {} + if (!initializingServer && requestContext) { requestContext.usedFsReadForNonFallback = true } } @@ -120,7 +135,12 @@ export async function getMockedRequestHandler(...args: Parameters { + // we need to await getRequestHandlers(...) promise in this callback to ensure that initAsyncLocalStorage + // is available in async / background work + return await getRequestHandlers(...args) + }) + // depending on Next.js version requestHandlers might be an array of object // see https://github.com/vercel/next.js/commit/08e7410f15706379994b54c3195d674909a8d533#diff-37243d614f1f5d3f7ea50bbf2af263f6b1a9a4f70e84427977781e07b02f57f1R742 return Array.isArray(requestHandlers) ? requestHandlers[0] : requestHandlers.requestHandler diff --git a/src/shared/blob-types.cts b/src/shared/blob-types.cts index 976ffdb79c..c7b113a262 100644 --- a/src/shared/blob-types.cts +++ b/src/shared/blob-types.cts @@ -4,7 +4,7 @@ export type TagManifest = { revalidatedAt: number } export type HtmlBlob = { html: string - isFallback: boolean + isFullyStaticPage: boolean } export type BlobType = NetlifyCacheHandlerValue | TagManifest | HtmlBlob @@ -24,9 +24,9 @@ export const isHtmlBlob = (value: BlobType): value is HtmlBlob => { typeof value === 'object' && value !== null && 'html' in value && - 'isFallback' in value && + 'isFullyStaticPage' in value && typeof value.html === 'string' && - typeof value.isFallback === 'boolean' && + typeof value.isFullyStaticPage === 'boolean' && Object.keys(value).length === 2 ) } diff --git a/src/shared/blob-types.test.ts b/src/shared/blob-types.test.ts index e41a992045..16c0a5c5f9 100644 --- a/src/shared/blob-types.test.ts +++ b/src/shared/blob-types.test.ts @@ -9,14 +9,14 @@ describe('isTagManifest', () => { }) it(`returns false for non-TagManifest instance`, () => { - const value: BlobType = { html: '', isFallback: false } + const value: BlobType = { html: '', isFullyStaticPage: false } expect(isTagManifest(value)).toBe(false) }) }) describe('isHtmlBlob', () => { it(`returns true for HtmlBlob instance`, () => { - const value: HtmlBlob = { html: '', isFallback: false } + const value: HtmlBlob = { html: '', isFullyStaticPage: false } expect(isHtmlBlob(value)).toBe(true) }) diff --git a/tests/fixtures/simple/app/api/slow-not-cacheable-with-html-read/route.js b/tests/fixtures/simple/app/api/slow-not-cacheable-with-html-read/route.js new file mode 100644 index 0000000000..bb85ff0604 --- /dev/null +++ b/tests/fixtures/simple/app/api/slow-not-cacheable-with-html-read/route.js @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import { readFile } from 'node:fs/promises' + +export async function GET() { + // This adds intentional delay here to make it more likely to hit some next-server + // initialization side-effects such as preloading page entries + // and trying to assert that side-effects do NOT impact the response. + // There is no way to force problematic side-effect scenario to happen without + // modifying the next internals. + // See https://github.com/vercel/next.js/blob/592401bb7fec83079716b2c9b090db580a63483f/packages/next/src/server/next-server.ts#L321-L327 + // which starts NOT awaited async work + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + // This route handler variant also reads static html file to test an edge case + // for our handler that tracks static html file reads to set CDN cache control + const staticHTML = await readFile('static/prebuilt.html', 'utf-8') + + return NextResponse.json({ + message: `Not cacheable route handler using force-dynamic dynamic strategy that reads ${staticHTML.length} character long .html file`, + }) +} +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/simple/app/api/slow-not-cacheable/route.js b/tests/fixtures/simple/app/api/slow-not-cacheable/route.js new file mode 100644 index 0000000000..73d6391500 --- /dev/null +++ b/tests/fixtures/simple/app/api/slow-not-cacheable/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + // This adds intentional delay here to make it more likely to hit some next-server + // initialization side-effects such as preloading page entries + // and trying to assert that side-effects do NOT impact the response. + // There is no way to force problematic side-effect scenario to happen without + // modifying the next internals. + // See https://github.com/vercel/next.js/blob/592401bb7fec83079716b2c9b090db580a63483f/packages/next/src/server/next-server.ts#L321-L327 + // which starts NOT awaited async work + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + return NextResponse.json({ + message: 'Not cacheable route handler using force-dynamic dynamic strategy', + }) +} +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/simple/pages/fully-static.js b/tests/fixtures/simple/pages/fully-static.js new file mode 100644 index 0000000000..06edf008f7 --- /dev/null +++ b/tests/fixtures/simple/pages/fully-static.js @@ -0,0 +1,9 @@ +// This is forcing this fixture to produce static html pages router +// to not rely just on Next.js currently always handling default pages router 404.html page +const FullyStatic = () => ( +
+

This page is not using getStaticProps()

+
+) + +export default FullyStatic diff --git a/tests/fixtures/simple/static/prebuilt.html b/tests/fixtures/simple/static/prebuilt.html new file mode 100644 index 0000000000..7e02a43a80 --- /dev/null +++ b/tests/fixtures/simple/static/prebuilt.html @@ -0,0 +1,5 @@ + + + hello static html NOT produced by Next.js + + diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 62b1ddd09a..6fc02f387c 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -25,6 +25,7 @@ import { createFixture, getFixtureSourceDirectory, invokeFunction, + loadSandboxedFunction, runPlugin, } from '../utils/fixture.js' import { @@ -35,10 +36,7 @@ import { } from '../utils/helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -const mockedCp = cp as Mock< - Parameters<(typeof import('node:fs/promises'))['cp']>, - ReturnType<(typeof import('node:fs/promises'))['cp']> -> +const mockedCp = cp as Mock<(typeof import('node:fs/promises'))['cp']> vi.mock('node:fs/promises', async (importOriginal) => { const fsPromisesModule = (await importOriginal()) as typeof import('node:fs/promises') @@ -115,6 +113,7 @@ test('Test that the simple next app is working', async (ctx) '/route-resolves-to-not-found', '404.html', '500.html', + 'fully-static.html', ]) // test the function call @@ -245,6 +244,62 @@ test('handlers can add cookies in route handlers with the co expect(setCookieHeader).toContain('test2=value2; Path=/handler; HttpOnly') }) +test("slow NOT cacheable route handler is NOT cached on cdn (dynamic='force-dynamic')", async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + // there is a side effect of initializing next-server that might impact "random" request/response which can't + // be forced, so this test attempt its best to trigger a lot of requests in hope it will hit the side effect + + const staggeredInvocationPromises = [] as Promise>[] + for (let delay = 0; delay < 5_000; delay += 100) { + staggeredInvocationPromises.push( + new Promise((res) => { + setTimeout(() => { + res(invokeFunction({ url: '/api/slow-not-cacheable' })) + }, delay) + }), + ) + } + + const staggeredInvocations = await Promise.all(staggeredInvocationPromises) + + for (const invocation of staggeredInvocations) { + expect(invocation.statusCode).toBe(200) + expect(invocation.headers['netlify-cdn-cache-control']).toBeUndefined() + } +}) + +test("slow NOT cacheable route handler reading html files is NOT cached on cdn (dynamic='force-dynamic')", async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + // there is a side effect of initializing next-server that might impact "random" request/response which can't + // be forced, so this test attempt its best to trigger a lot of requests in hope it will hit the side effect + + const staggeredInvocationPromises = [] as Promise>[] + for (let delay = 0; delay < 5_000; delay += 100) { + staggeredInvocationPromises.push( + new Promise((res) => { + setTimeout(() => { + res(invokeFunction({ url: '/api/slow-not-cacheable-with-html-read' })) + }, delay) + }), + ) + } + + const staggeredInvocations = await Promise.all(staggeredInvocationPromises) + + for (const invocation of staggeredInvocations) { + expect(invocation.statusCode).toBe(200) + expect(invocation.headers['netlify-cdn-cache-control']).toBeUndefined() + } +}) + test('cacheable route handler is cached on cdn (revalidate=false / permanent caching)', async (ctx) => { await createFixture('simple', ctx) await runPlugin(ctx) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 443450a15d..5f42864932 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -1,6 +1,7 @@ import { assert, vi } from 'vitest' import { type NetlifyPluginConstants, type NetlifyPluginOptions } from '@netlify/build' +import { resolveConfig as resolveNetlifyConfig } from '@netlify/config' import { bundle, serve } from '@netlify/edge-bundler' import { zipFunctions } from '@netlify/zip-it-and-ship-it' import { execaCommand } from 'execa' @@ -191,6 +192,20 @@ export async function runPluginStep( constants: Partial = {}, ) { const stepFunction = (await import('../../src/index.js'))[step] + + let netlifyConfig = { + headers: [], + redirects: [], + } + + // load netlify.toml if it exists + if (existsSync(join(ctx.cwd, 'netlify.toml'))) { + const resolvedNetlifyConfig = await resolveNetlifyConfig({ cwd: ctx.cwd }) + if (resolvedNetlifyConfig.config) { + netlifyConfig = resolvedNetlifyConfig.config + } + } + const options = { constants: { SITE_ID: ctx.siteID, @@ -208,10 +223,7 @@ export async function runPluginStep( // INTERNAL_FUNCTIONS_SRC: '.netlify/functions-internal', // INTERNAL_EDGE_FUNCTIONS_SRC: '.netlify/edge-functions', }, - netlifyConfig: { - headers: [], - redirects: [], - }, + netlifyConfig, utils: { build: { failBuild: (message, options: { error?: Error } = {}) => { diff --git a/tests/utils/lambda-helpers.mjs b/tests/utils/lambda-helpers.mjs index 7252427b3d..4a874caa8d 100644 --- a/tests/utils/lambda-helpers.mjs +++ b/tests/utils/lambda-helpers.mjs @@ -143,7 +143,7 @@ export async function loadFunction(ctx, { env } = {}) { flags: flags ?? DEFAULT_FLAGS, }, lambdaFunc: { handler }, - timeoutMs: 4_000, + timeoutMs: 10_000, onInvocationEnd: (error) => { // lambda-local resolve promise return from execute when response is closed // but we should wait for tracked background work to finish