diff --git a/.github/workflows/cypress-demo-all-flags.yml b/.github/workflows/cypress-demo-all-flags.yml new file mode 100644 index 0000000000..e90d2444d4 --- /dev/null +++ b/.github/workflows/cypress-demo-all-flags.yml @@ -0,0 +1,73 @@ +name: Run e2e (default demo with all feature flags enabled) +on: + pull_request: + types: [opened, synchronize] + push: + branches: + - main + paths: + - 'demos/default/**/*.{js,jsx,ts,tsx}' + - 'cypress/e2e/default/**/*.{ts,js}' + - 'packages/*/src/**/*.{ts,js}' +jobs: + cypress: + name: Cypress + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + containers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Generate Github token + uses: navikt/github-app-token-generator@v1 + id: get-token + with: + private-key: ${{ secrets.TOKENS_PRIVATE_KEY }} + app-id: ${{ secrets.TOKENS_APP_ID }} + + - name: Checkout @netlify/wait-for-deploy-action + uses: actions/checkout@v3 + with: + repository: netlify/wait-for-deploy-action + token: ${{ steps.get-token.outputs.token }} + path: ./.github/actions/wait-for-netlify-deploy + + - name: Wait for Netlify Deploy + id: deploy + uses: ./.github/actions/wait-for-netlify-deploy + with: + site-name: netlify-plugin-nextjs-demo-all-flags + timeout: 300 + + - name: Deploy successful + if: ${{ steps.deploy.outputs.origin-url }} + run: echo ${{ steps.deploy.outputs.origin-url }} + + - name: Node + uses: actions/setup-node@v3 + with: + node-version: '16' + + - run: npm install + + - name: Cypress run + if: ${{ steps.deploy.outputs.origin-url }} + id: cypress + uses: cypress-io/github-action@v5 + with: + browser: chrome + record: true + parallel: true + config-file: cypress/config/ci.config.ts + group: 'Next Runtime - Demo' + spec: cypress/e2e/default/* + env: + DEBUG: '@cypress/github-action' + CYPRESS_baseUrl: ${{ steps.deploy.outputs.origin-url }} + CYPRESS_NETLIFY_CONTEXT: ${{ steps.deploy.outputs.context }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CYPRESS_RECORD_KEY: ${{ secrets.DEFAULT_CYPRESS_RECORD_KEY }} + CYPRESS_PLUGIN_ALL_FEATURE_FLAGS: 'enabled' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afc54d6ac0..a5d580aa43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ concurrency: env: NEXT_SPLIT_API_ROUTES: true + NEXT_CDN_CACHE_CONTROL: true jobs: build: diff --git a/cypress/e2e/default/dynamic-routes.cy.ts b/cypress/e2e/default/dynamic-routes.cy.ts index 266f6faa43..c84509884a 100644 --- a/cypress/e2e/default/dynamic-routes.cy.ts +++ b/cypress/e2e/default/dynamic-routes.cy.ts @@ -1,3 +1,5 @@ +import { CDNCacheControlEnabled } from '../../utils/flags' + /* eslint-disable max-lines-per-function */ describe('Static Routing', () => { it('renders correct page via SSR on a static route', () => { @@ -17,7 +19,11 @@ describe('Static Routing', () => { it('renders correct page via ODB on a static route', () => { cy.request({ url: '/getStaticProps/with-revalidate/', headers: { 'x-nf-debug-logging': '1' } }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=1, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } expect(res.body).to.contain('Dancing with the Stars') }) }) @@ -64,8 +70,12 @@ describe('Dynamic Routing', () => { it('renders fallback page via ODB on a non-prerendered dynamic route with fallback: true', () => { cy.request({ url: '/getStaticProps/withFallback/3/', headers: { 'x-nf-debug-logging': '1' } }).then((res) => { expect(res.status).to.eq(200) - // expect 'odb' until https://github.com/netlify/pillar-runtime/issues/438 is fixed - expect(res.headers).to.have.property('x-nf-render-mode', 'odb') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=31536000, stale-while-revalidate') + } else { + // expect 'odb' until https://github.com/netlify/pillar-runtime/issues/438 is fixed + expect(res.headers).to.have.property('x-nf-render-mode', 'odb') + } // expect 'Bitten' until the above is fixed and we can test for fallback 'Loading...' message expect(res.body).to.contain('Bitten') }) @@ -83,7 +93,11 @@ describe('Dynamic Routing', () => { cy.request({ url: '/getStaticProps/withFallbackBlocking/3/', headers: { 'x-nf-debug-logging': '1' } }).then( (res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=31536000, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb') + } expect(res.body).to.contain('Bitten') }, ) @@ -91,7 +105,11 @@ describe('Dynamic Routing', () => { it('renders correct page via ODB on a prerendered dynamic route with revalidate and fallback: false', () => { cy.request({ url: '/getStaticProps/withRevalidate/1/', headers: { 'x-nf-debug-logging': '1' } }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=60, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } expect(res.body).to.contain('Under the Dome') }) }) @@ -110,7 +128,11 @@ describe('Dynamic Routing', () => { cy.request({ url: '/getStaticProps/withRevalidate/withFallback/1/', headers: { 'x-nf-debug-logging': '1' } }).then( (res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=60, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } expect(res.body).to.contain('Under the Dome') }, ) @@ -119,7 +141,11 @@ describe('Dynamic Routing', () => { cy.request({ url: '/getStaticProps/withRevalidate/withFallback/3/', headers: { 'x-nf-debug-logging': '1' } }).then( (res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=60, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } // expect 'Bitten' until https://github.com/netlify/pillar-runtime/issues/438 is fixed expect(res.body).to.contain('Bitten') }, @@ -131,7 +157,11 @@ describe('Dynamic Routing', () => { headers: { 'x-nf-debug-logging': '1' }, }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=60, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } expect(res.body).to.contain('Under the Dome') }) }) @@ -141,7 +171,11 @@ describe('Dynamic Routing', () => { headers: { 'x-nf-debug-logging': '1' }, }).then((res) => { expect(res.status).to.eq(200) - expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + if (CDNCacheControlEnabled) { + expect(res.headers).to.have.property('netlify-cdn-cache-control', 's-maxage=60, stale-while-revalidate') + } else { + expect(res.headers).to.have.property('x-nf-render-mode', 'odb ttl=60') + } expect(res.body).to.contain('Bitten') }) }) diff --git a/cypress/e2e/default/revalidate.cy.ts b/cypress/e2e/default/revalidate.cy.ts index 56b5c53211..d016a888db 100644 --- a/cypress/e2e/default/revalidate.cy.ts +++ b/cypress/e2e/default/revalidate.cy.ts @@ -1,4 +1,6 @@ -describe('On-demand revalidation', () => { +import { describeConditional, CDNCacheControlEnabled } from '../../utils/flags' + +describeConditional(!CDNCacheControlEnabled)('On-demand revalidation', () => { it('revalidates static ISR route with default locale', () => { cy.request({ url: '/api/revalidate/?select=0' }).then((res) => { expect(res.status).to.eq(200) @@ -33,7 +35,9 @@ describe('On-demand revalidation', () => { cy.request({ url: '/api/revalidate/?select=5', failOnStatusCode: false }).then((res) => { expect(res.status).to.eq(500) expect(res.body).to.have.property('message') - expect(res.body.message).to.include('could not refresh content for path /getStaticProps/withRevalidate/3/, path is not handled by an odb') + expect(res.body.message).to.include( + 'could not refresh content for path /getStaticProps/withRevalidate/3/, path is not handled by an odb', + ) }) }) it('revalidates dynamic non-prerendered ISR route with fallback blocking', () => { diff --git a/cypress/utils/flags.ts b/cypress/utils/flags.ts new file mode 100644 index 0000000000..136eb01fbb --- /dev/null +++ b/cypress/utils/flags.ts @@ -0,0 +1,30 @@ +export const allFlagsEnabled = Cypress.env('PLUGIN_ALL_FEATURE_FLAGS') === `enabled` + +// In current setup this is only enabled when all flags are enabled, but when doing conditional +// assertions for CDN cache control specific behaviour we want semantically check for just that feature +// and not everything else - so that's the reason for separate export +export const CDNCacheControlEnabled = allFlagsEnabled + +/** + * @param shouldExecute {boolean} - if truthy, `describe` will be executed, otherwise skipped + * @returns {function} - describe or describe.skip + * @example + * // this will execute the describe block only if CDNCacheControlEnabled variable is falsy + * describeConditional(!CDNCacheControlEnabled)('describe block name', () => { + * } + */ +export const describeConditional = function describeConditional(shouldExecute: boolean) { + return shouldExecute ? describe : describe.skip +} + +/** + * @param shouldExecute {boolean} - if truthy, `it` will be executed, otherwise skipped + * @returns {function} - it or it.skip + * @example + * // this will execute the test only if CDNCacheControlEnabled variable is falsy + * itConditional(!CDNCacheControlEnabled)('test name', () => { + * } + */ +export const itConditional = function itConditional(shouldExecute: boolean) { + return shouldExecute ? it : it.skip +} diff --git a/demos/default/netlify.toml b/demos/default/netlify.toml index 61c0e4b2a1..f7f32ea991 100644 --- a/demos/default/netlify.toml +++ b/demos/default/netlify.toml @@ -13,6 +13,9 @@ NODE_VERSION = "16.15.1" NEXT_SPLIT_API_ROUTES = "true" NEXT_BUNDLE_BASED_ON_NFT_FILES = "true" +# following flags are enabled on "All flags" variant of test site - uncomment those to easily run in that mode +# NEXT_CDN_CACHE_CONTROL = "true" + [[headers]] for = "/_next/image/*" diff --git a/demos/default/pages/index.js b/demos/default/pages/index.js index bbc32e45bf..06af9c8525 100644 --- a/demos/default/pages/index.js +++ b/demos/default/pages/index.js @@ -3,7 +3,7 @@ import dynamic from 'next/dynamic' const Header = dynamic(() => import(/* webpackChunkName: 'header' */ '../components/Header'), { ssr: true }) import { useRouter } from 'next/router' -const Index = ({ shows, nodeEnv }) => { +const Index = ({ shows, nodeEnv, time }) => { const { locale } = useRouter() return ( @@ -21,6 +21,8 @@ const Index = ({ shows, nodeEnv }) => {

Incremental Static Regeneration

+
{time}
+

This page is rendered by an On-Demand Builder (ODB) function. It fetches a random list of five TV shows from the TVmaze REST API. After 60 seconds, the ODB cache is invalidated and the page will be re-rendered on the @@ -201,6 +203,7 @@ export async function getStaticProps(context) { props: { shows: data.slice(0, 5), nodeEnv: process.env.NODE_ENV || null, + time: new Date().toString(), }, revalidate: 60, } diff --git a/packages/runtime/src/helpers/config.ts b/packages/runtime/src/helpers/config.ts index c6c5ae3886..73b1174fa3 100644 --- a/packages/runtime/src/helpers/config.ts +++ b/packages/runtime/src/helpers/config.ts @@ -109,6 +109,7 @@ export const configureHandlerFunctions = async ({ apiLambdas, ssrLambdas, splitApiRoutes, + useCDNCacheControl, }: { netlifyConfig: NetlifyConfig publish: string @@ -116,6 +117,7 @@ export const configureHandlerFunctions = async ({ apiLambdas: APILambda[] ssrLambdas: SSRLambda[] splitApiRoutes: boolean + useCDNCacheControl: boolean }) => { const config = await getRequiredServerFiles(publish) const files = config.files || [] @@ -182,7 +184,9 @@ export const configureHandlerFunctions = async ({ if (ssrLambdas.length === 0) { configureFunction(HANDLER_FUNCTION_NAME) - configureFunction(ODB_FUNCTION_NAME) + if (!useCDNCacheControl) { + configureFunction(ODB_FUNCTION_NAME) + } } else { ssrLambdas.forEach(configureLambda) } @@ -217,6 +221,19 @@ const buildHeader = (buildHeaderParams: BuildHeaderParams) => { // configuration does not support. const sanitizePath = (path: string) => path.replace(/:[^*/]+\*$/, '*') +/** + * Sets Content-type header for static RSC assets (text/x-component), so application/octet-stream is not used when serving them + * @param netlifyHeaders - Existing headers that are already configured in the Netlify configuration + */ +export const addContentTypeHeaderToStaticRSCAssets = (netlifyHeaders: NetlifyHeaders = []) => { + netlifyHeaders.push({ + for: `*.rsc`, + values: { + 'Content-Type': 'text/x-component', + }, + }) +} + /** * Persist Next.js custom headers to the Netlify configuration so the headers work with static files * See {@link https://nextjs.org/docs/api-reference/next.config.js/headers} for more information on custom diff --git a/packages/runtime/src/helpers/edge.ts b/packages/runtime/src/helpers/edge.ts index 98001f54b9..47e3efa89d 100644 --- a/packages/runtime/src/helpers/edge.ts +++ b/packages/runtime/src/helpers/edge.ts @@ -278,26 +278,35 @@ export const generateRscDataEdgeManifest = async ({ prerenderManifest, appPathRoutesManifest, packagePath = '', + useCDNCacheControl, }: { packagePath?: string prerenderManifest?: PrerenderManifest appPathRoutesManifest?: Record + useCDNCacheControl: boolean }): Promise => { const generator = await getPluginVersion() if (!prerenderManifest || !appPathRoutesManifest) { return [] } const staticAppdirRoutes: Array = [] + for (const [path, route] of Object.entries(prerenderManifest.routes)) { - if (isAppDirRoute(route.srcRoute, appPathRoutesManifest) && route.dataRoute) { - staticAppdirRoutes.push(path, route.dataRoute) + if ( + isAppDirRoute(route.srcRoute, appPathRoutesManifest) && + route.dataRoute && + (!useCDNCacheControl || route.initialRevalidateSeconds === false) + ) { + staticAppdirRoutes.push(path) } } const dynamicAppDirRoutes: Array = [] - for (const [path, route] of Object.entries(prerenderManifest.dynamicRoutes)) { - if (isAppDirRoute(path, appPathRoutesManifest) && route.dataRouteRegex) { - dynamicAppDirRoutes.push(route.routeRegex, route.dataRouteRegex) + if (!useCDNCacheControl) { + for (const [path, route] of Object.entries(prerenderManifest.dynamicRoutes)) { + if (isAppDirRoute(path, appPathRoutesManifest) && route.dataRouteRegex) { + dynamicAppDirRoutes.push(route.routeRegex) + } } } @@ -357,10 +366,12 @@ export const writeEdgeFunctions = async ({ netlifyConfig, routesManifest, constants: { PACKAGE_PATH = '' }, + useCDNCacheControl, }: { netlifyConfig: NetlifyConfig routesManifest: RoutesManifest constants: NetlifyPluginConstants + useCDNCacheControl: boolean }) => { const generator = await getPluginVersion() @@ -392,6 +403,7 @@ export const writeEdgeFunctions = async ({ packagePath: PACKAGE_PATH, prerenderManifest: await loadPrerenderManifest(netlifyConfig), appPathRoutesManifest: await loadAppPathRoutesManifest(netlifyConfig), + useCDNCacheControl, }) manifest.functions.push(...rscFunctions) diff --git a/packages/runtime/src/helpers/flags.ts b/packages/runtime/src/helpers/flags.ts index 368895f61f..c46c1bae01 100644 --- a/packages/runtime/src/helpers/flags.ts +++ b/packages/runtime/src/helpers/flags.ts @@ -36,3 +36,9 @@ export const bundleBasedOnNftFiles = (featureFlags: Record): bo return isEnabled } + +export const useCDNCacheControlEnabled = (featureFlags: Record): boolean => { + const isEnabled = destr(process.env.NEXT_CDN_CACHE_CONTROL) ?? featureFlags['next-use-cdn-cache-control'] ?? false + + return isEnabled +} diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index 0912982a1a..c964694c19 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -67,6 +67,8 @@ export const generateFunctions = async ( appDir: string, apiLambdas: APILambda[], ssrLambdas: SSRLambda[], + useCDNCacheControl: boolean, + // eslint-disable-next-line max-params ): Promise => { const publish = resolve(PUBLISH_DIR) const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC) @@ -133,10 +135,11 @@ export const generateFunctions = async ( const writeHandler = async (functionName: string, functionTitle: string, isODB: boolean) => { const handlerSource = getHandler({ - isODB, + isODB: useCDNCacheControl ? false : isODB, publishDir, appDir: relative(functionDir, appDir), nextServerModuleRelativeLocation, + useCDNCacheControl, }) await ensureDir(join(functionsDir, functionName)) @@ -167,24 +170,31 @@ export const generateFunctions = async ( } await writeHandler(HANDLER_FUNCTION_NAME, HANDLER_FUNCTION_TITLE, false) - await writeHandler(ODB_FUNCTION_NAME, ODB_FUNCTION_TITLE, true) + if (!useCDNCacheControl) { + await writeHandler(ODB_FUNCTION_NAME, ODB_FUNCTION_TITLE, true) + } } /** * Writes a file in each function directory that contains references to every page entrypoint. * This is just so that the nft bundler knows about them. We'll eventually do this better. */ -export const generatePagesResolver = async ({ - INTERNAL_FUNCTIONS_SRC, - PUBLISH_DIR, - PACKAGE_PATH = '', - FUNCTIONS_SRC = join(PACKAGE_PATH, DEFAULT_FUNCTIONS_SRC), -}: NetlifyPluginConstants): Promise => { +export const generatePagesResolver = async ( + { + INTERNAL_FUNCTIONS_SRC, + PUBLISH_DIR, + PACKAGE_PATH = '', + FUNCTIONS_SRC = join(PACKAGE_PATH, DEFAULT_FUNCTIONS_SRC), + }: NetlifyPluginConstants, + useCDNCacheControl: boolean, +): Promise => { const functionsPath = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC const jsSource = await getResolverForPages(PUBLISH_DIR, PACKAGE_PATH) - await writeFile(join(functionsPath, ODB_FUNCTION_NAME, 'pages.js'), jsSource) + if (!useCDNCacheControl) { + await writeFile(join(functionsPath, ODB_FUNCTION_NAME, 'pages.js'), jsSource) + } await writeFile(join(functionsPath, HANDLER_FUNCTION_NAME, 'pages.js'), jsSource) } @@ -391,7 +401,7 @@ const getSSRDependencies = async (publish: string): Promise => { ] } -export const getSSRLambdas = async (publish: string): Promise => { +export const getSSRLambdas = async (publish: string, useCDNCacheControl: boolean): Promise => { const commonDependencies = await getCommonDependencies(publish) const ssrRoutes = await getSSRRoutes(publish) @@ -412,13 +422,19 @@ export const getSSRLambdas = async (publish: string): Promise => { ], routes: nonOdbRoutes, }, - { - functionName: ODB_FUNCTION_NAME, - functionTitle: ODB_FUNCTION_TITLE, - includedFiles: [...commonDependencies, ...ssrDependencies, ...odbRoutes.flatMap((route) => route.includedFiles)], - routes: odbRoutes, - }, - ] + useCDNCacheControl + ? undefined + : { + functionName: ODB_FUNCTION_NAME, + functionTitle: ODB_FUNCTION_TITLE, + includedFiles: [ + ...commonDependencies, + ...ssrDependencies, + ...odbRoutes.flatMap((route) => route.includedFiles), + ], + routes: odbRoutes, + }, + ].filter(Boolean) } const getSSRRoutes = async (publish: string): Promise => { diff --git a/packages/runtime/src/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts index 8777951f4c..893c502381 100644 --- a/packages/runtime/src/helpers/redirects.ts +++ b/packages/runtime/src/helpers/redirects.ts @@ -113,6 +113,7 @@ const generateStaticIsrRewrites = ({ buildId, middleware, appPathRoutes, + useCDNCacheControl, }: { staticRouteEntries: Array<[string, SsgRoute]> basePath: string @@ -120,6 +121,7 @@ const generateStaticIsrRewrites = ({ buildId: string middleware: Array appPathRoutes: Record + useCDNCacheControl: boolean }): { staticRoutePaths: Set staticIsrRoutesThatMatchMiddleware: Array @@ -153,7 +155,7 @@ const generateStaticIsrRewrites = ({ route, dataRoute: isAppDir ? dataRoute : routeToDataRoute(route, buildId, i18n.defaultLocale), basePath, - to: ODB_FUNCTION_PATH, + to: useCDNCacheControl ? HANDLER_FUNCTION_PATH : ODB_FUNCTION_PATH, force: true, }), ) @@ -167,7 +169,7 @@ const generateStaticIsrRewrites = ({ ...redirectsForNextRoute({ route, basePath, - to: ODB_FUNCTION_PATH, + to: useCDNCacheControl ? HANDLER_FUNCTION_PATH : ODB_FUNCTION_PATH, force: true, buildId, dataRoute: isAppDir ? dataRoute : null, @@ -196,6 +198,7 @@ export const generateDynamicRewrites = ({ i18n, is404Isr, appPathRoutes, + useCDNCacheControl, }: { dynamicRoutes: RoutesManifest['dynamicRoutes'] prerenderedDynamicRoutes: PrerenderManifest['dynamicRoutes'] @@ -205,6 +208,7 @@ export const generateDynamicRewrites = ({ middleware: Array is404Isr: boolean appPathRoutes?: Record + useCDNCacheControl: boolean }): { dynamicRoutesThatMatchMiddleware: Array dynamicRewrites: NetlifyConfig['redirects'] @@ -225,7 +229,7 @@ export const generateDynamicRewrites = ({ route: route.page, buildId, basePath, - to: ODB_FUNCTION_PATH, + to: useCDNCacheControl ? HANDLER_FUNCTION_PATH : ODB_FUNCTION_PATH, i18n, dataRoute: prerenderedDynamicRoutes[route.page].dataRoute, withData: true, @@ -239,7 +243,13 @@ export const generateDynamicRewrites = ({ dynamicRewrites.push(...redirectsForNext404Route({ route: route.page, buildId, basePath, i18n })) } else { dynamicRewrites.push( - ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: ODB_FUNCTION_PATH, i18n }), + ...redirectsForNextRoute({ + route: route.page, + buildId, + basePath, + to: useCDNCacheControl ? HANDLER_FUNCTION_PATH : ODB_FUNCTION_PATH, + i18n, + }), ) } } else { @@ -260,11 +270,13 @@ export const generateRedirects = async ({ nextConfig: { i18n, basePath, trailingSlash, appDir }, buildId, apiLambdas, + useCDNCacheControl, }: { netlifyConfig: NetlifyConfig nextConfig: Pick buildId: string apiLambdas: APILambda[] + useCDNCacheControl: boolean }) => { const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest = await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json')) @@ -306,6 +318,7 @@ export const generateRedirects = async ({ buildId, middleware, appPathRoutes, + useCDNCacheControl, }) routesThatMatchMiddleware.push(...staticIsrRoutesThatMatchMiddleware) @@ -332,6 +345,7 @@ export const generateRedirects = async ({ i18n, is404Isr, appPathRoutes, + useCDNCacheControl, }) netlifyConfig.redirects.push(...dynamicRewrites) routesThatMatchMiddleware.push(...dynamicRoutesThatMatchMiddleware) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index cf7e03d26a..f458d8a9a5 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -14,11 +14,12 @@ import { updateRequiredServerFiles, configureHandlerFunctions, generateCustomHeaders, + addContentTypeHeaderToStaticRSCAssets, } from './helpers/config' import { onPreDev } from './helpers/dev' import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge' import { moveStaticPages, movePublicFiles, removeMetadataFiles } from './helpers/files' -import { bundleBasedOnNftFiles, splitApiRoutes } from './helpers/flags' +import { bundleBasedOnNftFiles, splitApiRoutes, useCDNCacheControlEnabled } from './helpers/flags' import { generateFunctions, setupImageFunction, @@ -82,6 +83,12 @@ const plugin: NetlifyPlugin = { if (shouldSkip()) { return } + + const useCDNCacheControl = useCDNCacheControlEnabled(featureFlags) + if (useCDNCacheControl) { + console.log(`Using CDN Cache Control headers`) + } + const { publish } = netlifyConfig.build checkNextSiteHasBuilt({ publish, failBuild }) @@ -172,10 +179,10 @@ const plugin: NetlifyPlugin = { extendedRoutes.map(packSingleFunction), ) - const ssrLambdas = bundleBasedOnNftFiles(featureFlags) ? await getSSRLambdas(publish) : [] + const ssrLambdas = bundleBasedOnNftFiles(featureFlags) ? await getSSRLambdas(publish, useCDNCacheControl) : [] - await generateFunctions(constants, appDir, apiLambdas, ssrLambdas) - await generatePagesResolver(constants) + await generateFunctions(constants, appDir, apiLambdas, ssrLambdas, useCDNCacheControl) + await generatePagesResolver(constants, useCDNCacheControl) await configureHandlerFunctions({ netlifyConfig, @@ -184,6 +191,7 @@ const plugin: NetlifyPlugin = { apiLambdas, ssrLambdas, splitApiRoutes: splitApiRoutes(featureFlags, publish), + useCDNCacheControl, }) await movePublicFiles({ appDir, outdir, publish, basePath }) @@ -211,9 +219,10 @@ const plugin: NetlifyPlugin = { nextConfig: { basePath, i18n, trailingSlash, appDir }, buildId, apiLambdas, + useCDNCacheControl, }) - await writeEdgeFunctions({ constants, netlifyConfig, routesManifest }) + await writeEdgeFunctions({ constants, netlifyConfig, routesManifest, useCDNCacheControl }) }, async onPostBuild({ @@ -250,6 +259,7 @@ const plugin: NetlifyPlugin = { const { basePath, appDir, experimental } = nextConfig + addContentTypeHeaderToStaticRSCAssets(headers) generateCustomHeaders(nextConfig, headers) warnForProblematicUserRewrites({ basePath, redirects }) diff --git a/packages/runtime/src/templates/edge-shared/rsc-data.ts b/packages/runtime/src/templates/edge-shared/rsc-data.ts index 1d2dcd89d6..68a8f3181a 100644 --- a/packages/runtime/src/templates/edge-shared/rsc-data.ts +++ b/packages/runtime/src/templates/edge-shared/rsc-data.ts @@ -28,10 +28,9 @@ export declare type PrerenderManifest = { const noop = () => {} -// Ensure that routes with and without a trailing slash map to different ODB paths const rscifyPath = (route: string) => { if (route.endsWith('/')) { - return route.slice(0, -1) + '.rsc/' + return route.slice(0, -1) + '.rsc' } return route + '.rsc' } diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index 8baadbb4fb..33cbdddc68 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -5,7 +5,7 @@ import { outdent as javascript } from 'outdent' import type { NextConfig } from '../helpers/config' -import type { NextServerType } from './handlerUtils' +import { type NextServerType } from './handlerUtils' import type { NetlifyNextServerType } from './server' /* eslint-disable @typescript-eslint/no-var-requires */ @@ -39,11 +39,26 @@ type MakeHandlerParams = { NextServer: NextServerType staticManifest: Array<[string, string]> mode: 'ssr' | 'odb' + useCDNCacheControl: boolean +} + +export interface NetlifyVaryHeaderBuilder { + headers?: string[] + languages?: string[] + cookies?: string[] } // We return a function and then call `toString()` on it to serialise it as the launcher function // eslint-disable-next-line max-lines-per-function -const makeHandler = ({ conf, app, pageRoot, NextServer, staticManifest = [], mode = 'ssr' }: MakeHandlerParams) => { +const makeHandler = ({ + conf, + app, + pageRoot, + NextServer, + staticManifest = [], + mode = 'ssr', + useCDNCacheControl, +}: MakeHandlerParams) => { // Change working directory into the site root, unless using Nx, which moves the // dist directory and handles this itself const dir = path.resolve(__dirname, app) @@ -117,6 +132,27 @@ const makeHandler = ({ conf, app, pageRoot, NextServer, staticManifest = [], mod return bridge } + const generateNetlifyVaryHeaderValue = ({ headers, languages, cookies }: NetlifyVaryHeaderBuilder = {}): string => { + let NetlifyVaryHeader = `` + if (headers && headers.length !== 0) { + NetlifyVaryHeader += `header=${headers.join(`|`)}` + } + if (languages && languages.length !== 0) { + if (NetlifyVaryHeader.length !== 0) { + NetlifyVaryHeader += `,` + } + NetlifyVaryHeader += `language=${languages.join(`|`)}` + } + if (cookies && cookies.length !== 0) { + if (NetlifyVaryHeader.length !== 0) { + NetlifyVaryHeader += `,` + } + NetlifyVaryHeader += `cookie=${cookies.join(`|`)}` + } + + return NetlifyVaryHeader + } + return async function handler(event: HandlerEvent, context: HandlerContext) { let requestMode: string = mode const prefetchResponse = getPrefetchResponse(event, mode) @@ -160,32 +196,67 @@ const makeHandler = ({ conf, app, pageRoot, NextServer, staticManifest = [], mod } // Sending SWR headers causes undefined behaviour with the Netlify CDN - const cacheHeader = multiValueHeaders['cache-control']?.[0] - - if (cacheHeader?.includes('stale-while-revalidate')) { - if (requestMode === 'odb') { - const ttl = getMaxAge(cacheHeader) - // Long-expiry TTL is basically no TTL, so we'll skip it - if (ttl > 0 && ttl < ONE_YEAR_IN_SECONDS) { - // ODBs currently have a minimum TTL of 60 seconds - result.ttl = Math.max(ttl, 60) + const cacheControlHeader = multiValueHeaders['cache-control']?.[0] + + if (useCDNCacheControl) { + if (cacheControlHeader?.includes('stale-while-revalidate')) { + multiValueHeaders[`netlify-cdn-cache-control`] = [cacheControlHeader] + multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] + + const netlifyVaryBuilder: NetlifyVaryHeaderBuilder = { + headers: [], + languages: [], + cookies: ['__prerender_bypass', '__next_preview_data'], } - const ephemeralCodes = [301, 302, 307, 308] - if (ttl === ONE_YEAR_IN_SECONDS && ephemeralCodes.includes(result.statusCode)) { - // Only cache for 60s if default TTL provided - result.ttl = 60 + + const varyHeaderFromNextJS = multiValueHeaders.vary?.[0] ?? `` + if (varyHeaderFromNextJS.length !== 0) { + netlifyVaryBuilder.headers.push( + ...varyHeaderFromNextJS.split(`,`).map((untrimmedHeaderName) => untrimmedHeaderName.trim()), + ) + } + + if (conf.i18n.localeDetection !== false && conf.i18n.locales.length > 1) { + const logicalPath = + conf.basePath && event.path.startsWith(conf.basePath) ? event.path.slice(conf.basePath.length) : event.path + + if (logicalPath === `/`) { + netlifyVaryBuilder.languages.push(...conf.i18n.locales) + netlifyVaryBuilder.cookies.push(`NEXT_LOCALE`) + } + } + + const NetlifyVaryHeader = generateNetlifyVaryHeaderValue(netlifyVaryBuilder) + if (NetlifyVaryHeader.length !== 0) { + multiValueHeaders[`netlify-vary`] = [NetlifyVaryHeader] } } - multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] - } + } else { + if (cacheControlHeader?.includes('stale-while-revalidate')) { + if (requestMode === 'odb') { + const ttl = getMaxAge(cacheControlHeader) + // Long-expiry TTL is basically no TTL, so we'll skip it + if (ttl > 0 && ttl < ONE_YEAR_IN_SECONDS) { + // ODBs currently have a minimum TTL of 60 seconds + result.ttl = Math.max(ttl, 60) + } + const ephemeralCodes = [301, 302, 307, 308] + if (ttl === ONE_YEAR_IN_SECONDS && ephemeralCodes.includes(result.statusCode)) { + // Only cache for 60s if default TTL provided + result.ttl = 60 + } + } + multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate'] + } - // ISR 404s are not served with SWR headers so we need to set the TTL here - if (requestMode === 'odb' && result.statusCode === 404) { - result.ttl = 60 - } + // ISR 404s are not served with SWR headers so we need to set the TTL here + if (requestMode === 'odb' && result.statusCode === 404) { + result.ttl = 60 + } - if (result.ttl > 0) { - requestMode = `odb ttl=${result.ttl}` + if (result.ttl > 0) { + requestMode = `odb ttl=${result.ttl}` + } } multiValueHeaders['x-nf-render-mode'] = [requestMode] @@ -205,6 +276,7 @@ export const getHandler = ({ publishDir = '../../../.next', appDir = '../../..', nextServerModuleRelativeLocation, + useCDNCacheControl, }): string => // This is a string, but if you have the right editor plugin it should format as js (e.g. bierner.comment-tagged-templates in VS Code) javascript/* javascript */ ` @@ -232,7 +304,11 @@ export const getHandler = ({ const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server")); exports.handler = ${ isODB - ? `builder((${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'odb' }));` - : `(${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'ssr' });` + ? `builder((${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'odb', useCDNCacheControl: ${ + useCDNCacheControl ? `true` : `false` + } }));` + : `(${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'ssr', useCDNCacheControl: ${ + useCDNCacheControl ? `true` : `false` + } });` } ` diff --git a/test/helpers/edge.spec.ts b/test/helpers/edge.spec.ts index 8ccfffabc8..2735d0fc67 100644 --- a/test/helpers/edge.spec.ts +++ b/test/helpers/edge.spec.ts @@ -46,12 +46,6 @@ describe('generateRscDataEdgeManifest', () => { name: 'RSC data routing', path: '/', }, - { - function: 'rsc-data', - generator: '@netlify/next-runtime@1.0.0', - name: 'RSC data routing', - path: '/index.rsc', - }, ]) }) @@ -99,12 +93,6 @@ describe('generateRscDataEdgeManifest', () => { name: 'RSC data routing', pattern: '^/blog/([^/]+?)(?:/)?$', }, - { - function: 'rsc-data', - generator: '@netlify/next-runtime@1.0.0', - name: 'RSC data routing', - pattern: '^/blog/([^/]+?)\\.rsc$', - }, ]) }) diff --git a/test/index.spec.ts b/test/index.spec.ts index eb76d0df67..a2f730ac7e 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -558,10 +558,10 @@ describe('onBuild()', () => { expect(existsSync(odbHandlerFile)).toBeTruthy() expect(readFileSync(handlerFile, 'utf8')).toMatch( - `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'ssr' })`, + `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'ssr', useCDNCacheControl: false })`, ) expect(readFileSync(odbHandlerFile, 'utf8')).toMatch( - `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'odb' })`, + `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'odb', useCDNCacheControl: false })`, ) expect(readFileSync(handlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) @@ -899,6 +899,12 @@ describe('onPostBuild', () => { }) expect(netlifyConfig.headers).toEqual([ + { + for: `*.rsc`, + values: { + 'Content-Type': 'text/x-component', + }, + }, { for: '/', values: { @@ -1001,6 +1007,12 @@ describe('onPostBuild', () => { 'x-existing-header-in-configuration': 'existing header in configuration value', }, }, + { + for: `*.rsc`, + values: { + 'Content-Type': 'text/x-component', + }, + }, { for: '/', values: { @@ -1108,6 +1120,12 @@ describe('onPostBuild', () => { 'x-existing-header-in-configuration': 'existing header in configuration value', }, }, + { + for: `*.rsc`, + values: { + 'Content-Type': 'text/x-component', + }, + }, ]) })