Skip to content

Commit

Permalink
feat: add deploymentId config (#63198)
Browse files Browse the repository at this point in the history
This PR stabilizes an experimental feature that was added in a previous
PR #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
  • Loading branch information
styfle committed Mar 18, 2024
1 parent f904dcd commit a3707f5
Show file tree
Hide file tree
Showing 12 changed files with 83 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<PagesOnly>

Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 1 addition & 4 deletions packages/next/src/build/webpack/plugins/define-env-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,13 +501,12 @@ export default abstract class Server<ServerOptions extends Options = Options> {
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 || '',
Expand Down
4 changes: 1 addition & 3 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
z.literal('use-credentials'),
])
.optional(),
deploymentId: z.string().optional(),
devIndicators: z
.object({
buildActivity: z.boolean().optional(),
Expand Down Expand Up @@ -247,9 +248,6 @@ export const configSchema: zod.ZodType<NextConfig> = 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(),
Expand Down
11 changes: 5 additions & 6 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,6 @@ export interface NextJsWebpackConfig {

export interface ExperimentalConfig {
caseSensitiveRoutes?: boolean
useDeploymentId?: boolean
useDeploymentIdServerActions?: boolean
deploymentId?: string
appDocumentPreloading?: boolean
strictNextHead?: boolean
clientRouterFilter?: boolean
Expand Down Expand Up @@ -591,6 +588,11 @@ export interface NextConfig extends Record<string, any> {
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
*
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 2 additions & 10 deletions packages/next/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions packages/next/src/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions test/production/deployment-id-handling/app/next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
useDeploymentId: !!process.env.USE_DEPLOYMENT_ID,
},
deploymentId: process.env.CUSTOM_DEPLOYMENT_ID,
}
142 changes: 62 additions & 80 deletions test/production/deployment-id-handling/deployment-id-handling.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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)
}
)
}
)
}
)
})

0 comments on commit a3707f5

Please sign in to comment.