Skip to content

Commit 40a3f39

Browse files
biilmannclaude
andcommitted
feat: implement Next.js skew protection for Netlify
This implements Next.js skew protection that automatically routes requests to the appropriate deployment based on deployment IDs, preventing version skew issues during deployments. **Features:** - Automatically enabled when VERCEL_SKEW_PROTECTION_ENABLED=1 - Maps NETLIFY_DEPLOY_ID to VERCEL_DEPLOYMENT_ID for Next.js compatibility - Creates edge function to handle deployment ID routing - Supports all three deployment ID carriers (?dpl, X-Deployment-Id header, __vdpl cookie) - Routes static assets and API routes to old deployments when needed - Allows HTML pages to use current deployment for proper skew protection **Implementation:** - Added skew protection edge function template - Integrated edge function creation into plugin lifecycle - Updated onPreBuild to set deployment ID environment variables - Added comprehensive test coverage - Updated documentation in CLAUDE.md 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent ae2e201 commit 40a3f39

File tree

11 files changed

+392
-5
lines changed

11 files changed

+392
-5
lines changed

CLAUDE.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is the Next.js Runtime for Netlify (@netlify/plugin-nextjs) - a Netlify build plugin that handles the build process and creates the runtime environment for Next.js sites on Netlify. The plugin is automatically used during builds of Next.js sites on Netlify and supports Next.js 13.5+ with Node.js 18+.
8+
9+
## Development Commands
10+
11+
### Build
12+
- `npm run build` - Build the plugin using custom build script
13+
- `npm run build:watch` - Build in watch mode
14+
15+
### Testing
16+
- `npm test` - Run all tests using Vitest
17+
- `npm run test:unit` - Run unit tests only
18+
- `npm run test:integration` - Run integration tests only
19+
- `npm run test:smoke` - Run smoke tests only
20+
- `npm run test:e2e` - Run E2E tests using Playwright
21+
- `npm run test:ci:unit-and-integration` - CI command for unit + integration tests
22+
- `npm run test:ci:smoke` - CI command for smoke tests
23+
- `npm run test:ci:e2e` - CI command for E2E tests
24+
25+
### Code Quality
26+
- `npm run lint` - Lint TypeScript/JavaScript files with ESLint
27+
- `npm run typecheck` - Type check with TypeScript compiler
28+
- `npm run format:check` - Check code formatting with Prettier
29+
- `npm run format:fix` - Fix code formatting with Prettier
30+
31+
### Test Preparation
32+
- `npm run pretest:integration` - Builds and prepares test fixtures (runs automatically before tests)
33+
34+
## Architecture
35+
36+
The plugin follows Netlify's build plugin lifecycle with these main entry points in `src/index.ts`:
37+
38+
- **onPreDev** - Cleans up blob files before local development
39+
- **onPreBuild** - Prepares build environment, enables Next.js standalone mode
40+
- **onBuild** - Main build logic, handles static exports vs full builds
41+
- **onPostBuild** - Publishes static assets to CDN
42+
- **onSuccess** - Prewarms deployment URLs
43+
- **onEnd** - Cleanup after build completion
44+
45+
### Key Directories
46+
47+
- **src/build/** - Build-time logic for processing Next.js applications
48+
- `content/` - Static asset handling, prerendered content processing
49+
- `functions/` - Edge and server function generation
50+
- `templates/` - Function handler templates
51+
- **src/run/** - Runtime logic for handling requests
52+
- `handlers/` - Cache, request context, server request handlers
53+
- `storage/` - Blob storage and in-memory cache implementations
54+
- **src/shared/** - Shared types and utilities
55+
- **edge-runtime/** - Edge function runtime environment
56+
- **tests/fixtures/** - Test fixtures for various Next.js configurations
57+
58+
### Plugin Context
59+
60+
The `PluginContext` class (`src/build/plugin-context.ts`) centralizes build configuration and provides access to:
61+
- Build output paths and directories
62+
- Next.js build configuration
63+
- Netlify deployment context
64+
- Feature flags and environment variables
65+
66+
### Build Process
67+
68+
1. **Static Export**: For `output: 'export'` - copies static files and sets up image handler
69+
2. **Full Build**: Creates server/edge handlers, processes static/prerendered content, configures headers and image CDN
70+
71+
### Skew Protection
72+
73+
When `VERCEL_SKEW_PROTECTION_ENABLED=1` is set, the plugin automatically:
74+
75+
1. **Sets deployment ID**: Maps `NETLIFY_DEPLOY_ID` to `VERCEL_DEPLOYMENT_ID` for Next.js compatibility
76+
2. **Creates edge function**: Generates a skew protection edge function at `___netlify-skew-protection`
77+
3. **Handles routing**: Routes requests with deployment IDs (`?dpl=<id>`, `X-Deployment-Id` header, or `__vdpl` cookie) to appropriate deployments
78+
4. **Asset routing**: Static assets and API routes are routed to old deployments, while HTML pages use current deployment
79+
80+
The edge function is automatically added to the edge functions manifest with highest priority (pattern: `^.*$`).
81+
82+
## Testing
83+
84+
### Test Organization
85+
- **Unit tests**: Individual module testing
86+
- **Integration tests**: End-to-end plugin functionality with real Next.js projects
87+
- **Smoke tests**: Compatibility testing across Next.js versions
88+
- **E2E tests**: Full deployment scenarios using Playwright
89+
90+
### Important Test Configuration
91+
- Some integration tests run in isolation due to side effects (configured in `vitest.config.ts`)
92+
- Test fixtures in `tests/fixtures/` cover various Next.js configurations
93+
- Custom sequencer handles test sharding for CI
94+
95+
### Test Fixtures
96+
Extensive test fixtures cover scenarios like:
97+
- Middleware configurations
98+
- API routes and edge functions
99+
- Static exports and ISR
100+
- Monorepo setups (Nx, Turborepo)
101+
- Various Next.js features (PPR, image optimization, etc.)
102+
103+
## Environment Variables
104+
105+
- `NETLIFY_NEXT_PLUGIN_SKIP` - Skip plugin execution entirely
106+
- `NEXT_PRIVATE_STANDALONE` - Enabled automatically for builds
107+
- `IS_LOCAL` - Indicates local development vs deployment
108+
- `VERCEL_SKEW_PROTECTION_ENABLED` - Enable Next.js skew protection (set to '1')
109+
- `VERCEL_DEPLOYMENT_ID` - Set automatically from `NETLIFY_DEPLOY_ID` when skew protection is enabled
110+
111+
## Build Tools
112+
113+
- Custom build script at `tools/build.js` handles compilation
114+
- Uses esbuild for fast builds
115+
- Supports watch mode for development

package-lock.json

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/build/functions/edge.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { pathToRegexp } from 'path-to-regexp'
88

99
import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js'
1010

11+
const SKEW_PROTECTION_HANDLER_NAME = '___netlify-skew-protection'
12+
1113
const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => {
1214
await mkdir(ctx.edgeFunctionsDir, { recursive: true })
1315
await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
@@ -189,16 +191,49 @@ const buildHandlerDefinition = (
189191
}))
190192
}
191193

194+
const createSkewProtectionHandler = async (ctx: PluginContext): Promise<void> => {
195+
const handlerDirectory = join(ctx.edgeFunctionsDir, SKEW_PROTECTION_HANDLER_NAME)
196+
const handlerFile = join(handlerDirectory, `${SKEW_PROTECTION_HANDLER_NAME}.js`)
197+
198+
// Read the skew protection template
199+
const templatePath = join(ctx.pluginDir, 'src/build/templates/skew-protection.tmpl.js')
200+
const template = await readFile(templatePath, 'utf8')
201+
202+
await mkdir(handlerDirectory, { recursive: true })
203+
await writeFile(handlerFile, template)
204+
}
205+
192206
export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
193207
await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
194208
}
195209

196210
export const createEdgeHandlers = async (ctx: PluginContext) => {
197211
const nextManifest = await ctx.getMiddlewareManifest()
198212
const nextDefinitions = [...Object.values(nextManifest.middleware)]
199-
await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))
213+
214+
const edgeHandlerPromises = nextDefinitions.map((def) => createEdgeHandler(ctx, def))
215+
216+
// Create skew protection handler if enabled
217+
if (process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1') {
218+
edgeHandlerPromises.push(createSkewProtectionHandler(ctx))
219+
}
220+
221+
await Promise.all(edgeHandlerPromises)
200222

201223
const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
224+
225+
// Add skew protection handler to manifest if enabled
226+
if (process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1') {
227+
const skewProtectionDefinition: ManifestFunction = {
228+
function: SKEW_PROTECTION_HANDLER_NAME,
229+
name: 'Next.js Skew Protection Handler',
230+
pattern: '^.*$', // Match all paths
231+
cache: 'manual',
232+
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
233+
}
234+
netlifyDefinitions.unshift(skewProtectionDefinition) // Add at beginning for higher priority
235+
}
236+
202237
const netlifyManifest: Manifest = {
203238
version: 1,
204239
functions: netlifyDefinitions,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Skew Protection Edge Function for Next.js on Netlify
3+
*
4+
* This function implements Next.js skew protection by:
5+
* 1. Checking for deployment ID in query param (?dpl=<id>), header (X-Deployment-Id), or cookie (__vdpl)
6+
* 2. Routing requests to the appropriate deployment
7+
*
8+
* Note: Next.js automatically sets the __vdpl cookie when VERCEL_SKEW_PROTECTION_ENABLED=1,
9+
* so this edge function only needs to handle the routing logic.
10+
*/
11+
12+
const SKEW_PROTECTION_COOKIE = '__vdpl'
13+
const DEPLOYMENT_ID_HEADER = 'X-Deployment-Id'
14+
const DEPLOYMENT_ID_QUERY_PARAM = 'dpl'
15+
16+
export default async (request, context) => {
17+
const url = new URL(request.url)
18+
const currentDeployId = context.deploy?.id
19+
20+
// Skip in dev mode
21+
if (!currentDeployId) {
22+
return
23+
}
24+
25+
// Get deployment ID from request in priority order:
26+
// 1. Query parameter (?dpl=<id>)
27+
// 2. Header (X-Deployment-Id)
28+
// 3. Cookie (__vdpl)
29+
let requestedDeployId = url.searchParams.get(DEPLOYMENT_ID_QUERY_PARAM)
30+
31+
if (!requestedDeployId) {
32+
requestedDeployId = request.headers.get(DEPLOYMENT_ID_HEADER)
33+
}
34+
35+
if (!requestedDeployId) {
36+
const cookies = request.headers.get('cookie')
37+
if (cookies) {
38+
const cookieMatch = cookies.match(new RegExp(`${SKEW_PROTECTION_COOKIE}=([^;]+)`))
39+
requestedDeployId = cookieMatch?.[1]
40+
}
41+
}
42+
43+
// If no deployment ID is specified or it matches current deployment, continue normally
44+
if (!requestedDeployId || requestedDeployId === currentDeployId) {
45+
return
46+
}
47+
48+
// Route to the requested deployment
49+
try {
50+
const targetUrl = new URL(request.url)
51+
52+
// Check if this is a request that should be routed to old deployment
53+
if (shouldRouteToOldDeployment(url.pathname)) {
54+
// Route to the old deployment by changing the hostname
55+
targetUrl.hostname = `${requestedDeployId}--${context.site.name}.netlify.app`
56+
57+
// Remove the dpl query parameter to avoid infinite loops
58+
targetUrl.searchParams.delete(DEPLOYMENT_ID_QUERY_PARAM)
59+
60+
// Create new request with the updated URL, preserving all headers
61+
const newRequest = new Request(targetUrl.toString(), {
62+
method: request.method,
63+
headers: request.headers,
64+
body: request.body,
65+
})
66+
67+
// Remove the deployment ID header to avoid confusion
68+
newRequest.headers.delete(DEPLOYMENT_ID_HEADER)
69+
70+
console.log(`[Skew Protection] Routing ${url.pathname} to deployment ${requestedDeployId}`)
71+
return fetch(newRequest)
72+
}
73+
} catch (error) {
74+
console.error('[Skew Protection] Error routing to old deployment:', error)
75+
// Fall through to continue with current deployment
76+
}
77+
78+
// For other requests, continue with current deployment
79+
}
80+
81+
function shouldRouteToOldDeployment(pathname) {
82+
// Route static assets and API routes to old deployments
83+
// But not HTML pages (those should use current deployment for skew protection)
84+
85+
// Static assets (JS, CSS, images, etc.)
86+
if (/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico|webp|avif)$/.test(pathname)) {
87+
return true
88+
}
89+
90+
// Next.js static assets
91+
if (pathname.startsWith('/_next/static/')) {
92+
return true
93+
}
94+
95+
// API routes
96+
if (pathname.startsWith('/api/')) {
97+
return true
98+
}
99+
100+
// Server actions and chunks
101+
if (pathname.includes('/_next/static/chunks/')) {
102+
return true
103+
}
104+
105+
// Image optimization
106+
if (pathname.startsWith('/_next/image')) {
107+
return true
108+
}
109+
110+
// Don't route HTML pages - they should use current deployment
111+
return false
112+
}
113+
114+
export const config = {
115+
path: "/*"
116+
}

src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => {
5252
await tracer.withActiveSpan('onPreBuild', async () => {
5353
// Enable Next.js standalone mode at build time
5454
process.env.NEXT_PRIVATE_STANDALONE = 'true'
55+
56+
// Set up skew protection if enabled
57+
if (process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1') {
58+
// Use Netlify's deploy ID as the deployment ID for Next.js
59+
const deployId = process.env.NETLIFY_DEPLOY_ID
60+
if (deployId) {
61+
process.env.VERCEL_DEPLOYMENT_ID = deployId
62+
console.log('[Skew Protection] Enabled with deployment ID:', deployId)
63+
} else {
64+
console.warn('[Skew Protection] VERCEL_SKEW_PROTECTION_ENABLED is set but NETLIFY_DEPLOY_ID is not available')
65+
}
66+
}
67+
5568
const ctx = new PluginContext(options)
5669
if (options.constants.IS_LOCAL) {
5770
// Only clear directory if we are running locally as then we might have stale functions from previous
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export async function GET() {
2+
return Response.json({
3+
message: 'Hello from API route',
4+
deploymentId: process.env.VERCEL_DEPLOYMENT_ID || 'not-set'
5+
})
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function RootLayout({ children }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function Page() {
2+
return (
3+
<div>
4+
<h1>Skew Protection Test</h1>
5+
<p>This page tests Next.js skew protection on Netlify.</p>
6+
</div>
7+
)
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
experimental: {
3+
useDeploymentId: true,
4+
useDeploymentIdServerActions: true,
5+
},
6+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"private": true,
3+
"scripts": {
4+
"build": "next build",
5+
"dev": "next dev",
6+
"start": "next start"
7+
},
8+
"dependencies": {
9+
"next": "latest",
10+
"react": "latest",
11+
"react-dom": "latest"
12+
}
13+
}

0 commit comments

Comments
 (0)