diff --git a/package-lock.json b/package-lock.json index f67608a99d..da4b92e2cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,10 +22,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", @@ -5812,6 +5814,16 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -6605,6 +6617,16 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -36562,6 +36584,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -37144,6 +37175,12 @@ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true }, + "adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true + }, "agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", diff --git a/package.json b/package.json index 92d30153bc..738b320e0d 100644 --- a/package.json +++ b/package.json @@ -65,10 +65,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 63b09901ef..1fe088777b 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -207,6 +207,11 @@ export class PluginContext { return join(this.edgeFunctionsDir, EDGE_HANDLER_NAME) } + /** Absolute path to the skew protection config */ + get skewProtectionConfigPath(): string { + return this.resolveFromPackagePath('.netlify/v1/skew-protection.json') + } + constructor(options: NetlifyPluginOptions) { this.constants = options.constants this.featureFlags = options.featureFlags diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts new file mode 100644 index 0000000000..0fbc2d206a --- /dev/null +++ b/src/build/skew-protection.test.ts @@ -0,0 +1,334 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' +import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest' + +import type { PluginContext } from './plugin-context.js' +import { + EnabledOrDisabledReason, + setSkewProtection, + shouldEnableSkewProtection, + skewProtectionConfig, +} from './skew-protection.js' + +// Mock fs promises +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock path +vi.mock('node:path', () => ({ + dirname: vi.fn(), +})) + +describe('shouldEnableSkewProtection', () => { + let mockCtx: PluginContext + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + } as PluginContext + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + }) + + describe('default behavior', () => { + it('should return disabled by default', () => { + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + }) + }) + }) + + describe('environment variable handling', () => { + describe('opt-in', () => { + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + }) + + describe('opt-out', () => { + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + }) + }) + + describe('feature flag opt-in', () => { + it('should enable when feature flag is set', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_FF, + }) + }) + + it('should not enable when feature flag is false', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + }) + }) + }) + + describe('DEPLOY_ID validation', () => { + it('should disable when DEPLOY_ID is missing and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + delete process.env.DEPLOY_ID + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + }) + }) + + it('should disable when DEPLOY_ID is "0" and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + process.env.DEPLOY_ID = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + }) + }) + + it('should show specific reason when env var is set but DEPLOY_ID is invalid in local context', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, + }) + }) + }) + + describe('precedence', () => { + it('should prioritize env var opt-out over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) + }) + + it('should prioritize env var opt-in over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) + }) + }) +}) + +describe('setSkewProtection', () => { + let mockCtx: PluginContext + let mockSpan: Span + let originalEnv: NodeJS.ProcessEnv + let consoleSpy: { + log: MockInstance + warn: MockInstance + } + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + delete process.env.NEXT_DEPLOYMENT_ID + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + skewProtectionConfigPath: '/test/path/skew-protection.json', + } as PluginContext + + mockSpan = { + setAttribute: vi.fn(), + } as unknown as Span + + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => { + /* no op */ + }), + warn: vi.spyOn(console, 'warn').mockImplementation(() => { + /* no op */ + }), + } + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + consoleSpy.log.mockRestore() + consoleSpy.warn.mockRestore() + }) + + it('should set span attribute and return early when disabled', async () => { + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_OUT_DEFAULT, + ) + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + expect(consoleSpy.log).not.toHaveBeenCalled() + expect(consoleSpy.warn).not.toHaveBeenCalled() + }) + + it('should show warning when env var is set but no valid DEPLOY_ID', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, + ) + expect(consoleSpy.warn).toHaveBeenCalledWith( + 'NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to true, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.', + ) + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + }) + + it('should set up skew protection when enabled via env var', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_ENV_VAR, + ) + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=true environment variable.', + ) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith( + '/test/path/skew-protection.json', + JSON.stringify(skewProtectionConfig), + ) + }) + + it('should set up skew protection when enabled via feature flag', async () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_FF, + ) + expect(consoleSpy.log).toHaveBeenCalledWith('Setting up Next.js Skew Protection.') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith('/test/path/skew-protection.json', expect.any(String)) + }) + + it('should handle different env var values correctly', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + await setSkewProtection(mockCtx, mockSpan) + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=1 environment variable.', + ) + }) +}) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts new file mode 100644 index 0000000000..150ad15f39 --- /dev/null +++ b/src/build/skew-protection.ts @@ -0,0 +1,118 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' + +import type { PluginContext } from './plugin-context.js' + +// eslint-disable-next-line no-shadow +export const enum EnabledOrDisabledReason { + OPT_OUT_DEFAULT = 'off-default', + OPT_OUT_NO_VALID_DEPLOY_ID = 'off-no-valid-deploy-id', + OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR = 'off-no-valid-deploy-id-env-var', + OPT_IN_FF = 'on-ff', + OPT_IN_ENV_VAR = 'on-env-var', + OPT_OUT_ENV_VAR = 'off-env-var', +} + +const optInOptions = new Set([ + EnabledOrDisabledReason.OPT_IN_FF, + EnabledOrDisabledReason.OPT_IN_ENV_VAR, +]) + +export const skewProtectionConfig = { + patterns: ['.*'], + sources: [ + { + type: 'cookie', + name: '__vdpl', + }, + { + type: 'header', + name: 'X-Deployment-Id', + }, + { + type: 'query', + name: 'dpl', + }, + ], +} + +export function shouldEnableSkewProtection(ctx: PluginContext) { + let enabledOrDisabledReason: EnabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_DEFAULT + + if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'true' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '1' + ) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_ENV_VAR + } else if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'false' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '0' + ) { + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + } + } else if (ctx.featureFlags?.['next-runtime-skew-protection']) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_FF + } else { + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + } + } + + if ( + (!process.env.DEPLOY_ID || process.env.DEPLOY_ID === '0') && + optInOptions.has(enabledOrDisabledReason) + ) { + // We can't proceed without a valid DEPLOY_ID, because Next.js does inline deploy ID at build time + // This should only be the case for CLI deploys + return { + enabled: false, + enabledOrDisabledReason: + enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR && ctx.constants.IS_LOCAL + ? // this case is singled out to provide visible feedback to users that env var has no effect + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR + : // this is silent disablement to avoid spam logs for users opted in via feature flag + // that don't explicitly opt in via env var + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + } + } + + return { + enabled: optInOptions.has(enabledOrDisabledReason), + enabledOrDisabledReason, + } +} + +export const setSkewProtection = async (ctx: PluginContext, span: Span) => { + const { enabled, enabledOrDisabledReason } = shouldEnableSkewProtection(ctx) + + span.setAttribute('skewProtection', enabledOrDisabledReason) + + if (!enabled) { + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR) { + console.warn( + `NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to ${process.env.NETLIFY_NEXT_SKEW_PROTECTION}, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.`, + ) + } + return + } + + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR) { + console.log( + `Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=${process.env.NETLIFY_NEXT_SKEW_PROTECTION} environment variable.`, + ) + } else { + console.log('Setting up Next.js Skew Protection.') + } + + process.env.NEXT_DEPLOYMENT_ID = process.env.DEPLOY_ID + + await mkdir(dirname(ctx.skewProtectionConfigPath), { + recursive: true, + }) + await writeFile(ctx.skewProtectionConfigPath, JSON.stringify(skewProtectionConfig)) +} diff --git a/src/index.ts b/src/index.ts index 27d9c1ff7b..296da96949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/ed import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' import { setImageConfig } from './build/image-cdn.js' import { PluginContext } from './build/plugin-context.js' +import { setSkewProtection } from './build/skew-protection.js' import { verifyAdvancedAPIRoutes, verifyNetlifyFormsWorkaround, @@ -49,7 +50,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { return } - await tracer.withActiveSpan('onPreBuild', async () => { + await tracer.withActiveSpan('onPreBuild', async (span) => { // Enable Next.js standalone mode at build time process.env.NEXT_PRIVATE_STANDALONE = 'true' const ctx = new PluginContext(options) @@ -62,6 +63,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { } else { await restoreBuildCache(ctx) } + await setSkewProtection(ctx, span) }) } diff --git a/tests/e2e/skew-protection.test.ts b/tests/e2e/skew-protection.test.ts new file mode 100644 index 0000000000..dfec460313 --- /dev/null +++ b/tests/e2e/skew-protection.test.ts @@ -0,0 +1,574 @@ +import { expect } from '@playwright/test' +import { execaCommand } from 'execa' +import { + createE2EFixture, + createSite, + deleteSite, + getBuildFixtureVariantCommand, + publishDeploy, +} from '../utils/create-e2e-fixture.js' +import { test as baseTest } from '../utils/playwright-helpers.js' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' + +type ExtendedFixtures = { + skewProtection: { + siteId: string + url: string + deployA: Awaited> + deployB: Awaited> + } +} + +const test = baseTest.extend< + { prepareSkewProtectionScenario: (callback: () => T) => Promise }, + ExtendedFixtures +>({ + prepareSkewProtectionScenario: async ({ skewProtection }, use) => { + const fixture = async (callback: () => T) => { + // first we will publish deployA + // then we call arbitrary callback to allow tests to load page using deployA + // and after that we will publish deployB so page loaded in browser is not using + // currently published deploy anymore, but still get results from initially published deploy + + const pollURL = `${skewProtection.url}/variant.txt` + + await publishDeploy(skewProtection.siteId, skewProtection.deployA.deployID) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('A')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + const result = await callback() + + await publishDeploy(skewProtection.siteId, skewProtection.deployB.deployID) + + // https://netlify.slack.com/archives/C098NQ4DEF6/p1758207235732189 + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('B')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + return result + } + + await use(fixture) + }, + skewProtection: [ + async ({}, use) => { + const { siteId, url } = await createSite({ + name: `next-skew-tests-${Date.now()}`, + }) + + let onBuildStart: () => void = () => {} + const waitForBuildStart = new Promise((resolve) => { + onBuildStart = () => { + resolve() + } + }) + + const deployAPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + onBuildStart, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + }) + + // we don't have to wait for deployA to finish completely before starting deployB, but we do have to wait a little bit + // to at least when build starts building, as otherwise whole deploy might be skipped and only second deploy happens + await waitForBuildStart + + const deployBPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + onPreDeploy: async (fixtureRoot) => { + await execaCommand( + `${getBuildFixtureVariantCommand('variant-b')} --apply-file-changes-only`, + { + cwd: fixtureRoot, + }, + ) + }, + }) + + const [deployA, deployB] = await Promise.all([deployAPromise, deployBPromise]) + + const fixture = { + url, + siteId, + deployA, + deployB, + + cleanup: async () => { + if (process.env.E2E_PERSIST) { + console.log( + `💾 Fixture and deployed site have been persisted. To clean up automatically, run tests without the 'E2E_PERSIST' environment variable.`, + ) + + return + } + + await deployA.cleanup() + await deployB.cleanup() + await deleteSite(siteId) + }, + } + + // for local iteration - this will print out snippet to allow to reuse previously deployed setup + // paste this at the top of `skewProtection` fixture function and this will avoid having to wait for redeploys + // keep in mind that if fixture itself require changes, you will have to redeploy + // uncomment console.log if you want to use same site/fixture and just iterate on test themselves + // and run a test with E2E_PERSIST=1 to keep site around for future runs + if (process.env.E2E_PERSIST) { + console.log( + 'You can reuse persisted site by pasting below snippet at the top of `skewProtection` fixture logic', + ) + console.log(`await use(${JSON.stringify(fixture, null, 2)})\n\nreturn`) + } + await use(fixture) + + await fixture.cleanup() + }, + { + scope: 'worker', + }, + ], +}) + +test.describe('Skew Protection', () => { + test.describe('App Router', () => { + test('should scope next/link navigation to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + + // this tests that both RSC and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-linked-page"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-server-component-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-page-client-component-current-variant')).toHaveText( + '"A"', + ) + }) + + test('should scope server actions to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('server-action-button').click() + + const element = await page.waitForSelector('[data-testid="server-action-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be either "B" (currently published deploy) + // or error about not finding server action - example of such error: + // "Error: Server Action "00a130b1673301d79679b22abb06a62c3125376d79" was not found on the server. + // Read more: https://nextjs.org/docs/messages/failed-to-find-server-action" + expect(content).toBe(`"A"`) + }) + + test('should scope route handler to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('scoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-route-handler-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope route handler to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('unscoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-route-handler-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Pages Router', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('when linked page is fully static', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-fully-static"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-static-current-variant')).toHaveText('"A"') + }) + + test('when linked page is getStaticProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getStaticProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getStaticProps-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-getStaticProps-props-variant')).toHaveText('"A"') + }) + + test('when linked page is getServerSideProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getServerSideProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getServerSideProps-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-getServerSideProps-props-variant')).toHaveText('"A"') + }) + }) + + test('should scope api route to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('scoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-api-route-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope api route to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('unscoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-api-route-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Middleware', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('NextResponse.next()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-next"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('next') + }) + + test('NextResponse.redirect()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('NextResponse.rewrite()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test('should scope middleware endpoint to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('scoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="scoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope middleware endpoint to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('unscoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="unscoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Next.js config rewrite and redirects', () => { + test('should scope next/link navigation to initial deploy when link target is Next.js config redirect', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('should scope next/link navigation to initial deploy when link target is Next.js config rewrite', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test.describe('Dynamic import', () => { + test('should scope dynamic import to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/dynamic-import`) + }) + + page.getByTestId('dynamic-import-button').click() + + const element = await page.waitForSelector('[data-testid="dynamic-import-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + }) +}) diff --git a/tests/fixtures/skew-protection/app/app-router/actions.js b/tests/fixtures/skew-protection/app/app-router/actions.js new file mode 100644 index 0000000000..b7f7deb422 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function testAction() { + return process.env.SKEW_VARIANT +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/client-component.js b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js new file mode 100644 index 0000000000..2006d4f1ab --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js @@ -0,0 +1,12 @@ +'use client' + +export function ClientComponent() { + return ( +

+ Client Component - variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/page.js b/tests/fixtures/skew-protection/app/app-router/linked/page.js new file mode 100644 index 0000000000..8d8d10c8d5 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/page.js @@ -0,0 +1,16 @@ +import { ClientComponent } from './client-component' + +export default function Page() { + return ( + <> +

Skew Protection Testing - App Router - next/link navigation test

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ + + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/page.js b/tests/fixtures/skew-protection/app/app-router/page.js new file mode 100644 index 0000000000..b091fb2be2 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/page.js @@ -0,0 +1,132 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +import { testAction } from './actions' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [actionResult, setActionResult] = useState(null) + const [scopedRouteHandlerResult, setScopedRouteHandlerResult] = useState(null) + const [unscopedRouteHandlerResult, setUnscopedRouteHandlerResult] = useState(null) + + return ( + <> +

Skew Protection Testing - App Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+

Server Action

+
+ + {actionResult && ( +

+ Action result: {actionResult} ( + {actionResult === process.env.SKEW_VARIANT ? 'match' : 'mismatch'}) +

+ )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of route handler as one that served initial html to the browser + } +

Fetching route-handler (scoped)

+
+ + {scopedRouteHandlerResult && ( +

+ Scoped route handler result: + {scopedRouteHandlerResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of route handler + } +

Fetching route-handler (unscoped)

+
+ + {unscopedRouteHandlerResult && ( +

+ Unscoped route handler result: + {unscopedRouteHandlerResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/route-handler/route.js b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js new file mode 100644 index 0000000000..2a526c18a9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js @@ -0,0 +1,3 @@ +export const GET = async (req) => { + return new Response(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js new file mode 100644 index 0000000000..967763eda9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js @@ -0,0 +1 @@ +export const variant = process.env.SKEW_VARIANT diff --git a/tests/fixtures/skew-protection/app/dynamic-import/page.js b/tests/fixtures/skew-protection/app/dynamic-import/page.js new file mode 100644 index 0000000000..c1f9b68044 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/page.js @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [dynamicallyImportedValue, setDynamicallyImportedValue] = useState(null) + + return ( + <> +

Skew Protection Testing - Dynamic import

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

Dynamic import

+
+ + {dynamicallyImportedValue && ( +

+ Dynamic import result: + {dynamicallyImportedValue} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/layout.js b/tests/fixtures/skew-protection/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/skew-protection/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/[slug]/page.js b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js new file mode 100644 index 0000000000..9c5f4971ec --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js @@ -0,0 +1,16 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

Skew Protection Testing - Middleware - link target page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/page.js b/tests/fixtures/skew-protection/app/middleware/page.js new file mode 100644 index 0000000000..f8f57a53d7 --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/page.js @@ -0,0 +1,125 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [scopedMiddlewareEndpointResult, setScopedMiddlewareEndpointResult] = useState(null) + const [unscopedMiddlewareEndpointResult, setUnscopedMiddlewareEndpointResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Middleware

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of middleware endpoint as one that served initial html to the browser + } +

Fetching middleware endpoint (scoped)

+
+ + {scopedMiddlewareEndpointResult && ( +

+ Scoped middleware endpoint result: + + {scopedMiddlewareEndpointResult} + +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of middleware endpoint + } +

Fetching middleware endpoint (unscoped)

+
+ + {unscopedMiddlewareEndpointResult && ( +

+ Unscoped middleware endpoint result: + + {unscopedMiddlewareEndpointResult} + +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/[slug]/page.js b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js new file mode 100644 index 0000000000..2bf7c3ac85 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js @@ -0,0 +1,18 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

+ Skew Protection Testing - next.config.js - link target page +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/page.js b/tests/fixtures/skew-protection/app/next-config/page.js new file mode 100644 index 0000000000..883e8a1155 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/page.js @@ -0,0 +1,56 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + + return ( + <> +

+ Skew Protection Testing - next.config.js +

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/page.js b/tests/fixtures/skew-protection/app/page.js new file mode 100644 index 0000000000..831070adbd --- /dev/null +++ b/tests/fixtures/skew-protection/app/page.js @@ -0,0 +1,30 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing

+ + + ) +} diff --git a/tests/fixtures/skew-protection/middleware.js b/tests/fixtures/skew-protection/middleware.js new file mode 100644 index 0000000000..0b354a6d55 --- /dev/null +++ b/tests/fixtures/skew-protection/middleware.js @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export function middleware(request) { + const parsedVariant = JSON.parse(process.env.SKEW_VARIANT) + + if (request.nextUrl.pathname === '/middleware/next') { + return NextResponse.next() + } + + if (request.nextUrl.pathname === '/middleware/redirect') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/redirect-${parsedVariant.toLowerCase()}` + return NextResponse.redirect(url) + } + + if (request.nextUrl.pathname === '/middleware/rewrite') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/rewrite-${parsedVariant.toLowerCase()}` + return NextResponse.rewrite(url) + } + + if (request.nextUrl.pathname === '/middleware/json') { + return NextResponse.json(parsedVariant) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/tests/fixtures/skew-protection/next.config.mjs b/tests/fixtures/skew-protection/next.config.mjs new file mode 100644 index 0000000000..511d6fd2e5 --- /dev/null +++ b/tests/fixtures/skew-protection/next.config.mjs @@ -0,0 +1,57 @@ +import { remoteImage, variant } from './variant-config.mjs' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + // for next@<14.0.0 + serverActions: true, + // for next@<14.1.4 + useDeploymentId: true, + // Optionally, use with Server Actions + useDeploymentIdServerActions: true, + }, + outputFileTracingRoot: import.meta.dirname, + + // for next@<15.1.0 + webpack(config, { webpack }) { + config.plugins.push( + new webpack.DefinePlugin({ + // double JSON.stringify is intentional here - this is to keep results same as when using `compile.define` + 'process.env.SKEW_VARIANT': JSON.stringify(JSON.stringify(variant)), + }), + ) + return config + }, + + compiler: { + // this is same as above webpack config, but this will apply to turbopack builds as well + // so just future proofing it here + define: { + 'process.env.SKEW_VARIANT': JSON.stringify(variant), + }, + }, + + redirects() { + return [ + { + source: '/next-config/redirect', + destination: `/next-config/redirect-${variant.toLowerCase()}`, + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/next-config/rewrite', + destination: `/next-config/rewrite-${variant.toLowerCase()}`, + }, + ] + }, +} + +export default nextConfig diff --git a/tests/fixtures/skew-protection/package.json b/tests/fixtures/skew-protection/package.json new file mode 100644 index 0000000000..299855e023 --- /dev/null +++ b/tests/fixtures/skew-protection/package.json @@ -0,0 +1,15 @@ +{ + "name": "skew-protection", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "npm run build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/skew-protection/pages/api/api-route.js b/tests/fixtures/skew-protection/pages/api/api-route.js new file mode 100644 index 0000000000..7dc0f0c926 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/api/api-route.js @@ -0,0 +1,3 @@ +export default function handler(_req, res) { + res.send(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/index.js b/tests/fixtures/skew-protection/pages/pages-router/index.js new file mode 100644 index 0000000000..364da0cccb --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/index.js @@ -0,0 +1,119 @@ +import { useState } from 'react' +import Link from 'next/link' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [unscopedApiRouteResult, setUnscopedApiRouteResult] = useState(null) + const [scopedApiRouteResult, setScopedApiRouteResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Pages Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of api route as one that served initial html to the browser + } +

Fetching API route (scoped)

+
+ + {scopedApiRouteResult && ( +

+ Scoped API route result: + {scopedApiRouteResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of api route + } +

Fetching API route (unscoped)

+
+ + {unscopedApiRouteResult && ( +

+ Unscoped API route result: + {unscopedApiRouteResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js new file mode 100644 index 0000000000..26cc4cd94d --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js @@ -0,0 +1,27 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getServerSideProps +

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+

+ Variant from props:{' '} + {variant} +

+ + ) +} + +export async function getServerSideProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js new file mode 100644 index 0000000000..1e9e6dd46e --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js @@ -0,0 +1,24 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getStaticProps +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Variant from props: {variant} +

+ + ) +} + +export async function getStaticProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-static.js b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js new file mode 100644 index 0000000000..7eaaf5eff2 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js @@ -0,0 +1,11 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing - Pages Router - fully static page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/public/local-image-b.png b/tests/fixtures/skew-protection/public/local-image-b.png new file mode 100644 index 0000000000..e0e353318f Binary files /dev/null and b/tests/fixtures/skew-protection/public/local-image-b.png differ diff --git a/tests/fixtures/skew-protection/public/local-image.png b/tests/fixtures/skew-protection/public/local-image.png new file mode 100644 index 0000000000..a282ce91bb Binary files /dev/null and b/tests/fixtures/skew-protection/public/local-image.png differ diff --git a/tests/fixtures/skew-protection/public/variant-b.txt b/tests/fixtures/skew-protection/public/variant-b.txt new file mode 100644 index 0000000000..223b7836fb --- /dev/null +++ b/tests/fixtures/skew-protection/public/variant-b.txt @@ -0,0 +1 @@ +B diff --git a/tests/fixtures/skew-protection/public/variant.txt b/tests/fixtures/skew-protection/public/variant.txt new file mode 100644 index 0000000000..f70f10e4db --- /dev/null +++ b/tests/fixtures/skew-protection/public/variant.txt @@ -0,0 +1 @@ +A diff --git a/tests/fixtures/skew-protection/test-variants.json b/tests/fixtures/skew-protection/test-variants.json new file mode 100644 index 0000000000..448f5d8cd1 --- /dev/null +++ b/tests/fixtures/skew-protection/test-variants.json @@ -0,0 +1,9 @@ +{ + "variant-b": { + "files": { + "variant-config.mjs": "variant-config-b.mjs", + "public/local-image.png": "public/local-image-b.png", + "public/variant.txt": "public/variant-b.txt" + } + } +} diff --git a/tests/fixtures/skew-protection/variant-config-b.mjs b/tests/fixtures/skew-protection/variant-config-b.mjs new file mode 100644 index 0000000000..ad5de288c7 --- /dev/null +++ b/tests/fixtures/skew-protection/variant-config-b.mjs @@ -0,0 +1,2 @@ +export const variant = 'B' +export const remoteImage = 'pixabay' diff --git a/tests/fixtures/skew-protection/variant-config.mjs b/tests/fixtures/skew-protection/variant-config.mjs new file mode 100644 index 0000000000..2eec99991f --- /dev/null +++ b/tests/fixtures/skew-protection/variant-config.mjs @@ -0,0 +1,2 @@ +export const variant = 'A' +export const remoteImage = 'unsplash' diff --git a/tests/prepare.mjs b/tests/prepare.mjs index 164f702b17..e0e98ca425 100644 --- a/tests/prepare.mjs +++ b/tests/prepare.mjs @@ -32,6 +32,7 @@ const e2eOnlyFixtures = new Set([ 'middleware-og', 'middleware-single-matcher', 'nx-integrated', + 'skew-protection', 'turborepo', 'turborepo-npm', 'unstable-cache', diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs index 0fc6f33c06..513e6a11cd 100644 --- a/tests/utils/build-variants.mjs +++ b/tests/utils/build-variants.mjs @@ -52,7 +52,10 @@ const variants = { } // build variants declared by args or build everything if not args provided -const variantsToBuild = argv.length > 2 ? argv.slice(2) : Object.keys(variants) +const variantsToBuild = + argv.length > 2 ? argv.slice(2).filter((arg) => !arg.startsWith('--')) : Object.keys(variants) + +const flags = argv.slice(2).filter((arg) => arg.startsWith('--')) /** @type {string[]} */ const notExistingVariants = [] @@ -118,12 +121,6 @@ for (const variantToBuild of variantsToBuild) { } } - const buildCommand = variant.buildCommand ?? 'next build' - const distDir = variant.distDir ?? '.next' - console.warn( - `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, - ) - for (const [target, source] of Object.entries(variant.files ?? {})) { const targetBackup = `${target}.bak` // create backup @@ -139,6 +136,17 @@ for (const variantToBuild of variantsToBuild) { }) } + if (flags.includes('--apply-file-changes-only')) { + console.warn(`[build-variants] Applied file changes for ${variantToBuild} variant`) + continue + } + + const buildCommand = variant.buildCommand ?? 'next build' + const distDir = variant.distDir ?? '.next' + console.warn( + `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, + ) + const result = await execaCommand(buildCommand, { env: { ...process.env, diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index fe2546da2e..3eebaad704 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -1,3 +1,4 @@ +import AdmZip from 'adm-zip' import { execaCommand } from 'execa' import fg from 'fast-glob' import { exec } from 'node:child_process' @@ -45,6 +46,25 @@ interface E2EConfig { * Site ID to deploy to. Defaults to the `NETLIFY_SITE_ID` environment variable or a default site. */ siteId?: string + /** + * If set to true, instead of using CLI to deploy, we will zip the source files and trigger build from zip. + */ + useBuildbot?: boolean + /** + * Runs before deploying the site if defined. + */ + onPreDeploy?: (isolatedFixtureRoot: string) => Promise + /** + * Buildbot mode specific callback that will be called once the build starts. + * Useful for scenario of triggering multiple consecutive builds, to be able to schedule builds + * before previous one finish completely. If multiple builds are scheduled at the same time, some + * of them might be skipped and this callback allows to avoid this scenario. + */ + onBuildStart?: () => Promise | void + /** + * Environment variables that will be added to `netlify.toml` if set. + */ + env?: Record } /** @@ -80,6 +100,9 @@ export const createE2EFixture = async (fixture: string, config: E2EConfig = {}) await setNextVersionInFixture(isolatedFixtureRoot, NEXT_VERSION) await installRuntime(packageName, isolatedFixtureRoot, config) await verifyFixture(isolatedFixtureRoot, config) + await config.onPreDeploy?.(isolatedFixtureRoot) + + const deploySite = config.useBuildbot ? deploySiteWithBuildbot : deploySiteWithCLI const result = await deploySite(isolatedFixtureRoot, config) @@ -157,6 +180,13 @@ async function buildAndPackRuntime( `[build] command = "${buildCommand}" publish = "${publishDirectory ?? join(siteRelDir, '.next')}" +${ + config.env + ? `[build.environment]\n${Object.entries(config.env) + .map(([key, value]) => `${key} = "${value}"`) + .join('\n')}` + : '' +} [[plugins]] package = "${name}" @@ -260,7 +290,7 @@ async function verifyFixture(isolatedFixtureRoot: string, { expectedCliVersion } } } -async function deploySite( +export async function deploySiteWithCLI( isolatedFixtureRoot: string, { packagePath, cwd = '', siteId = SITE_ID }: E2EConfig, ): Promise { @@ -293,6 +323,91 @@ async function deploySite( } } +export async function deploySiteWithBuildbot( + isolatedFixtureRoot: string, + { packagePath, siteId = SITE_ID, publishDirectory = '.next', onBuildStart }: E2EConfig, +): Promise { + if (packagePath) { + // It's likely possible to support this, just skipping implementing it until there's a need + // throwing just to be explicit that this was not done to avoid potential confusion if things + // don't work + throw new Error('packagePath is not currently supported when deploying with buildbot') + } + + if (!process.env.NETLIFY_AUTH_TOKEN) { + // we use CLI (ntl api) for most of operations, but build zip upload seems impossible with CLI + // and we do need to use API directly and we do need token for that + throw new Error('NETLIFY_AUTH_TOKEN is required for buildbot deploy, but it was not set') + } + + console.log(`🚀 Packing source files and triggering deploy`) + + const newZip = new AdmZip() + newZip.addLocalFolder(isolatedFixtureRoot, '', (entry) => { + if ( + // don't include node_modules / .git / publish dir in zip + entry.startsWith('node_modules') || + entry.startsWith('.git') || + entry.startsWith(publishDirectory) + ) { + return false + } + return true + }) + + const result = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`, + }, + // @ts-expect-error sigh, it works + body: newZip.toBuffer(), + }) + const { deploy_id } = await result.json() + + let didRunOnBuildStartCallback = false + const runOnBuildStartCallbackOnce = onBuildStart + ? () => { + if (!didRunOnBuildStartCallback) { + didRunOnBuildStartCallback = true + return onBuildStart() + } + } + : () => {} + + // poll for status + while (true) { + const { stdout } = await execaCommand( + `npx netlify api getDeploy --data=${JSON.stringify({ deploy_id })}`, + ) + const { state } = JSON.parse(stdout) + + if (state === 'error' || state === 'rejected') { + await runOnBuildStartCallbackOnce() + throw new Error( + `The deploy failed https://app.netlify.com/projects/${siteId}/deploys/${deploy_id}`, + ) + } + if (state === 'ready') { + await runOnBuildStartCallbackOnce() + break + } + + if (state === 'building') { + await runOnBuildStartCallbackOnce() + } + + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + + return { + deployID: deploy_id, + url: `https://${deploy_id}--${siteId}.netlify.app`, // this is not nice, but it does work + logs: '', + } +} + export async function deleteDeploy(deployID?: string): Promise { if (!deployID) { return @@ -312,10 +427,38 @@ async function cleanup(dest: string, deployId?: string): Promise { await Promise.allSettled([deleteDeploy(deployId), rm(dest, { recursive: true, force: true })]) } -function getBuildFixtureVariantCommand(variantName: string) { +export function getBuildFixtureVariantCommand(variantName: string) { return `node ${fileURLToPath(new URL(`./build-variants.mjs`, import.meta.url))} ${variantName}` } +export async function createSite(siteConfig?: { name: string }) { + const cmd = `npx netlify api createSiteInTeam --data=${JSON.stringify({ + account_slug: 'netlify-integration-testing', + body: siteConfig ?? {}, + })}` + + const { stdout } = await execaCommand(cmd) + const { site_id, ssl_url, admin_url } = JSON.parse(stdout) + + console.log(`🚀 Created site ${ssl_url} / ${admin_url}`) + + return { + siteId: site_id as string, + url: ssl_url as string, + adminUrl: admin_url as string, + } +} + +export async function deleteSite(siteId: string) { + const cmd = `npx netlify api deleteSite --data=${JSON.stringify({ site_id: siteId })}` + await execaCommand(cmd) +} + +export async function publishDeploy(siteId: string, deployID: string) { + const cmd = `npx netlify api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}` + await execaCommand(cmd) +} + export const fixtureFactories = { simple: () => createE2EFixture('simple'), helloWorldTurbopack: () =>