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',
+ },
+ },
])
})