From a3707f5a331f6021f5264aa669c00a0e051ca11c Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 13 Mar 2024 16:46:50 -0400 Subject: [PATCH] feat: add `deploymentId` config (#63198) This PR stabilizes an experimental feature that was added in a previous PR https://github.com/vercel/next.js/pull/50470 It allows the user to set `deploymentId` in `next.config.js`, which is a unique identifier for a deployment that will be included in each request's query string or header. This PR is easier to review with whitespace hidden: https://github.com/vercel/next.js/pull/63198/files?w=1 Closes NEXT-2789 --- .../10-deploying/index.mdx | 6 +- packages/next/src/build/index.ts | 2 +- .../webpack/plugins/define-env-plugin.ts | 5 +- .../reducers/server-action-reducer.ts | 3 +- packages/next/src/export/index.ts | 2 +- packages/next/src/server/base-server.ts | 5 +- packages/next/src/server/config-schema.ts | 4 +- packages/next/src/server/config-shared.ts | 11 +- packages/next/src/server/config.ts | 12 +- packages/next/src/server/next-server.ts | 3 +- .../deployment-id-handling/app/next.config.js | 5 +- .../deployment-id-handling.test.ts | 142 ++++++++---------- 12 files changed, 83 insertions(+), 117 deletions(-) diff --git a/docs/02-app/01-building-your-application/10-deploying/index.mdx b/docs/02-app/01-building-your-application/10-deploying/index.mdx index c54bb181bad90..be7b456a7e732 100644 --- a/docs/02-app/01-building-your-application/10-deploying/index.mdx +++ b/docs/02-app/01-building-your-application/10-deploying/index.mdx @@ -210,11 +210,13 @@ module.exports = { ### Version Skew -Next.js will automatically mitigate most instances of [version skew](https://www.industrialempathy.com/posts/version-skew/) and automatically reload the application to retrieve new assets when detected. For example, if there is a mismatch in the build ID, transitions between pages will perform a hard navigation versus using a prefetched value. +Next.js will automatically mitigate most instances of [version skew](https://www.industrialempathy.com/posts/version-skew/) and automatically reload the application to retrieve new assets when detected. For example, if there is a mismatch in the `deploymentId`, transitions between pages will perform a hard navigation versus using a prefetched value. When the application is reloaded, there may be a loss of application state if it's not designed to persist between page navigations. For example, using URL state or local storage would persist state after a page refresh. However, component state like `useState` would be lost in such navigations. -Vercel provides additional [skew protection](https://vercel.com/docs/deployments/skew-protection?utm_source=next-site&utm_medium=docs&utm_campaign=next-website) for Next.js applications to ensure assets and functions from the previous build are still available while the new build is being deployed. +Vercel provides additional [skew protection](https://vercel.com/docs/deployments/skew-protection?utm_source=next-site&utm_medium=docs&utm_campaign=next-website) for Next.js applications to ensure assets and functions from the previous version are still available to older clients, even after the new version is deployed. + +You can manually configure the `deploymentId` property in your `next.config.js` file to ensure each request uses either `?dpl` query string or `x-deployment-id` header. diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 2638d05d6da16..3427a7ccc7869 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -730,7 +730,7 @@ export default async function build( }) ) - process.env.NEXT_DEPLOYMENT_ID = config.experimental.deploymentId || '' + process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || '' NextBuildContext.config = config let configOutDir = 'out' diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index ef6aa0631e246..59fd4c57415c4 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -96,11 +96,8 @@ export function getDefineEnv({ ), 'process.env.NEXT_MINIMAL': JSON.stringify(''), 'process.env.__NEXT_PPR': JSON.stringify(config.experimental.ppr === true), - 'process.env.__NEXT_ACTIONS_DEPLOYMENT_ID': JSON.stringify( - config.experimental.useDeploymentIdServerActions - ), 'process.env.NEXT_DEPLOYMENT_ID': JSON.stringify( - config.experimental.deploymentId || false + config.deploymentId || false ), 'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': JSON.stringify(fetchCacheKeyPrefix), diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index 7e410e0b4c221..02a2281ae17b9 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -70,8 +70,7 @@ async function fetchServerAction( Accept: RSC_CONTENT_TYPE_HEADER, [ACTION]: actionId, [NEXT_ROUTER_STATE_TREE]: encodeURIComponent(JSON.stringify(state.tree)), - ...(process.env.__NEXT_ACTIONS_DEPLOYMENT_ID && - process.env.NEXT_DEPLOYMENT_ID + ...(process.env.NEXT_DEPLOYMENT_ID ? { 'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID, } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 177a73de6d60b..e865f59fdaac1 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -505,7 +505,7 @@ export async function exportAppImpl( } : {}), strictNextHead: !!nextConfig.experimental.strictNextHead, - deploymentId: nextConfig.experimental.deploymentId, + deploymentId: nextConfig.deploymentId, experimental: { ppr: nextConfig.experimental.ppr === true, missingSuspenseWithCSRBailout: diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 28681c7686184..e85093b5208c2 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -501,13 +501,12 @@ export default abstract class Server { this.nextFontManifest = this.getNextFontManifest() if (process.env.NEXT_RUNTIME !== 'edge') { - process.env.NEXT_DEPLOYMENT_ID = - this.nextConfig.experimental.deploymentId || '' + process.env.NEXT_DEPLOYMENT_ID = this.nextConfig.deploymentId || '' } this.renderOpts = { trailingSlash: this.nextConfig.trailingSlash, - deploymentId: this.nextConfig.experimental.deploymentId, + deploymentId: this.nextConfig.deploymentId, strictNextHead: !!this.nextConfig.experimental.strictNextHead, poweredByHeader: this.nextConfig.poweredByHeader, canonicalBase: this.nextConfig.amp.canonicalBase || '', diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 4f44f041c4c54..9460a201b88e1 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -204,6 +204,7 @@ export const configSchema: zod.ZodType = z.lazy(() => z.literal('use-credentials'), ]) .optional(), + deploymentId: z.string().optional(), devIndicators: z .object({ buildActivity: z.boolean().optional(), @@ -247,9 +248,6 @@ export const configSchema: zod.ZodType = z.lazy(() => memoryBasedWorkersCount: z.boolean().optional(), craCompat: z.boolean().optional(), caseSensitiveRoutes: z.boolean().optional(), - useDeploymentId: z.boolean().optional(), - useDeploymentIdServerActions: z.boolean().optional(), - deploymentId: z.string().optional(), disableOptimizedLoading: z.boolean().optional(), disablePostcssPresetEnv: z.boolean().optional(), esmExternals: z.union([z.boolean(), z.literal('loose')]).optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 7378caefcf338..1bf622b7bb049 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -160,9 +160,6 @@ export interface NextJsWebpackConfig { export interface ExperimentalConfig { caseSensitiveRoutes?: boolean - useDeploymentId?: boolean - useDeploymentIdServerActions?: boolean - deploymentId?: string appDocumentPreloading?: boolean strictNextHead?: boolean clientRouterFilter?: boolean @@ -591,6 +588,11 @@ export interface NextConfig extends Record { canonicalBase?: string } + /** + * A unique identifier for a deployment that will be included in each request's query string or header. + */ + deploymentId?: string + /** * Deploy a Next.js application under a sub-path of a domain * @@ -812,9 +814,6 @@ export const defaultConfig: NextConfig = { serverMinification: true, serverSourceMaps: false, caseSensitiveRoutes: false, - useDeploymentId: false, - deploymentId: undefined, - useDeploymentIdServerActions: false, appDocumentPreloading: undefined, clientRouterFilter: true, clientRouterFilterRedirects: false, diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index f4c897083f5cd..3d1f633ee8d59 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -601,16 +601,8 @@ function assignDefaults( } // only leverage deploymentId - if (result.experimental?.useDeploymentId && process.env.NEXT_DEPLOYMENT_ID) { - if (!result.experimental) { - result.experimental = {} - } - result.experimental.deploymentId = process.env.NEXT_DEPLOYMENT_ID - } - - // can't use this one without the other - if (result.experimental?.useDeploymentIdServerActions) { - result.experimental.useDeploymentId = true + if (process.env.NEXT_DEPLOYMENT_ID) { + result.deploymentId = process.env.NEXT_DEPLOYMENT_ID } // use the closest lockfile as tracing root diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 891ae15b9cc3e..11f25b254f5fe 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -194,8 +194,7 @@ export default class NextNodeServer extends BaseServer { if (this.renderOpts.nextScriptWorkers) { process.env.__NEXT_SCRIPT_WORKERS = JSON.stringify(true) } - process.env.NEXT_DEPLOYMENT_ID = - this.nextConfig.experimental.deploymentId || '' + process.env.NEXT_DEPLOYMENT_ID = this.nextConfig.deploymentId || '' if (!this.minimalMode) { this.imageResponseCache = new ResponseCache(this.minimalMode) diff --git a/test/production/deployment-id-handling/app/next.config.js b/test/production/deployment-id-handling/app/next.config.js index 0beb0297db7d3..3a1cbedd56f30 100644 --- a/test/production/deployment-id-handling/app/next.config.js +++ b/test/production/deployment-id-handling/app/next.config.js @@ -1,5 +1,4 @@ +/** @type {import('next').NextConfig} */ module.exports = { - experimental: { - useDeploymentId: !!process.env.USE_DEPLOYMENT_ID, - }, + deploymentId: process.env.CUSTOM_DEPLOYMENT_ID, } diff --git a/test/production/deployment-id-handling/deployment-id-handling.test.ts b/test/production/deployment-id-handling/deployment-id-handling.test.ts index 0aa43c51de068..895b0ce992d29 100644 --- a/test/production/deployment-id-handling/deployment-id-handling.test.ts +++ b/test/production/deployment-id-handling/deployment-id-handling.test.ts @@ -1,19 +1,18 @@ -import { createNextDescribe } from 'e2e-utils' +import { nextTestSetup } from 'e2e-utils' import { check } from 'next-test-utils' import { join } from 'node:path' -const deploymentId = Date.now() + '' +describe.each(['NEXT_DEPLOYMENT_ID', 'CUSTOM_DEPLOYMENT_ID'])( + 'deployment-id-handling enabled with %s', + (envKey) => { + const deploymentId = Date.now() + '' + const { next } = nextTestSetup({ + files: join(__dirname, 'app'), + env: { + [envKey]: deploymentId, + }, + }) -createNextDescribe( - 'deployment-id-handling enabled', - { - files: join(__dirname, 'app'), - env: { - NEXT_DEPLOYMENT_ID: deploymentId, - USE_DEPLOYMENT_ID: '1', - }, - }, - ({ next }) => { it.each([ { urlPath: '/' }, { urlPath: '/pages-edge' }, @@ -84,83 +83,66 @@ createNextDescribe( ) } ) - -createNextDescribe( - 'deployment-id-handling disabled', - { +describe('deployment-id-handling disabled', () => { + const deploymentId = Date.now() + '' + const { next } = nextTestSetup({ files: join(__dirname, 'app'), - env: { - NEXT_DEPLOYMENT_ID: deploymentId, - }, - }, - ({ next }) => { - it.each([ - { urlPath: '/' }, - { urlPath: '/pages-edge' }, - { urlPath: '/from-app' }, - { urlPath: '/from-app/edge' }, - ])( - 'should not append dpl query to all assets for $urlPath', - async ({ urlPath }) => { - const $ = await next.render$(urlPath) - - expect($('#deploymentId').text()).not.toBe(deploymentId) - - const scripts = Array.from($('script')) - expect(scripts.length).toBeGreaterThan(0) - - for (const script of scripts) { - if (script.attribs.src) { - expect(script.attribs.src).not.toContain('dpl=' + deploymentId) - } + }) + it.each([ + { urlPath: '/' }, + { urlPath: '/pages-edge' }, + { urlPath: '/from-app' }, + { urlPath: '/from-app/edge' }, + ])( + 'should not append dpl query to all assets for $urlPath', + async ({ urlPath }) => { + const $ = await next.render$(urlPath) + + expect($('#deploymentId').text()).not.toBe(deploymentId) + + const scripts = Array.from($('script')) + expect(scripts.length).toBeGreaterThan(0) + + for (const script of scripts) { + if (script.attribs.src) { + expect(script.attribs.src).not.toContain('dpl=' + deploymentId) } + } - const links = Array.from($('link')) - expect(links.length).toBeGreaterThan(0) + const links = Array.from($('link')) + expect(links.length).toBeGreaterThan(0) - for (const link of links) { - if (link.attribs.href) { - if (link.attribs.as === 'font') { - expect(link.attribs.href).not.toContain('dpl=' + deploymentId) - } else { - expect(link.attribs.href).not.toContain('dpl=' + deploymentId) - } + for (const link of links) { + if (link.attribs.href) { + if (link.attribs.as === 'font') { + expect(link.attribs.href).not.toContain('dpl=' + deploymentId) + } else { + expect(link.attribs.href).not.toContain('dpl=' + deploymentId) } } + } - const browser = await next.browser(urlPath) - const requests = [] - - browser.on('request', (req) => { - requests.push(req.url()) - }) - - await browser.elementByCss('#dynamic-import').click() + const browser = await next.browser(urlPath) + const requests = [] - await check( - () => (requests.length > 0 ? 'success' : JSON.stringify(requests)), - 'success' - ) + browser.on('request', (req) => { + requests.push(req.url()) + }) - try { - expect( - requests.every((item) => !item.includes('dpl=' + deploymentId)) - ).toBe(true) - } finally { - require('console').error('requests', requests) - } - } - ) + await browser.elementByCss('#dynamic-import').click() - it.each([{ pathname: '/api/hello' }, { pathname: '/api/hello-app' }])( - 'should not have deployment id env available', - async ({ pathname }) => { - const res = await next.fetch(pathname) + await check( + () => (requests.length > 0 ? 'success' : JSON.stringify(requests)), + 'success' + ) - expect(await res.json()).not.toEqual({ - deploymentId, - }) + try { + expect( + requests.every((item) => !item.includes('dpl=' + deploymentId)) + ).toBe(true) + } finally { + require('console').error('requests', requests) } - ) - } -) + } + ) +})