Skip to content

feat: implement Next.js skew protection for Netlify #3023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

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+.

## Development Commands

### Build
- `npm run build` - Build the plugin using custom build script
- `npm run build:watch` - Build in watch mode

### Testing
- `npm test` - Run all tests using Vitest
- `npm run test:unit` - Run unit tests only
- `npm run test:integration` - Run integration tests only
- `npm run test:smoke` - Run smoke tests only
- `npm run test:e2e` - Run E2E tests using Playwright
- `npm run test:ci:unit-and-integration` - CI command for unit + integration tests
- `npm run test:ci:smoke` - CI command for smoke tests
- `npm run test:ci:e2e` - CI command for E2E tests

### Code Quality
- `npm run lint` - Lint TypeScript/JavaScript files with ESLint
- `npm run typecheck` - Type check with TypeScript compiler
- `npm run format:check` - Check code formatting with Prettier
- `npm run format:fix` - Fix code formatting with Prettier

### Test Preparation
- `npm run pretest:integration` - Builds and prepares test fixtures (runs automatically before tests)

## Architecture

The plugin follows Netlify's build plugin lifecycle with these main entry points in `src/index.ts`:

- **onPreDev** - Cleans up blob files before local development
- **onPreBuild** - Prepares build environment, enables Next.js standalone mode
- **onBuild** - Main build logic, handles static exports vs full builds
- **onPostBuild** - Publishes static assets to CDN
- **onSuccess** - Prewarms deployment URLs
- **onEnd** - Cleanup after build completion

### Key Directories

- **src/build/** - Build-time logic for processing Next.js applications
- `content/` - Static asset handling, prerendered content processing
- `functions/` - Edge and server function generation
- `templates/` - Function handler templates
- **src/run/** - Runtime logic for handling requests
- `handlers/` - Cache, request context, server request handlers
- `storage/` - Blob storage and in-memory cache implementations
- **src/shared/** - Shared types and utilities
- **edge-runtime/** - Edge function runtime environment
- **tests/fixtures/** - Test fixtures for various Next.js configurations

### Plugin Context

The `PluginContext` class (`src/build/plugin-context.ts`) centralizes build configuration and provides access to:
- Build output paths and directories
- Next.js build configuration
- Netlify deployment context
- Feature flags and environment variables

### Build Process

1. **Static Export**: For `output: 'export'` - copies static files and sets up image handler
2. **Full Build**: Creates server/edge handlers, processes static/prerendered content, configures headers and image CDN

### Skew Protection

When `NEXT_SKEW_PROTECTION_ENABLED=1` is set, the plugin automatically:

1. **Sets deployment ID**: Maps `NETLIFY_DEPLOY_ID` to `VERCEL_DEPLOYMENT_ID` for Next.js compatibility
2. **Creates edge function**: Generates a skew protection edge function at `___netlify-skew-protection`
3. **Handles routing**: Routes requests with deployment IDs (`?dpl=<id>`, `X-Deployment-Id` header, or `__vdpl` cookie) to appropriate deployments
4. **Asset routing**: Static assets and API routes are routed to old deployments, while HTML pages use current deployment

The edge function is automatically added to the edge functions manifest with highest priority (pattern: `^.*$`).

## Testing

### Test Organization
- **Unit tests**: Individual module testing
- **Integration tests**: End-to-end plugin functionality with real Next.js projects
- **Smoke tests**: Compatibility testing across Next.js versions
- **E2E tests**: Full deployment scenarios using Playwright

### Important Test Configuration
- Some integration tests run in isolation due to side effects (configured in `vitest.config.ts`)
- Test fixtures in `tests/fixtures/` cover various Next.js configurations
- Custom sequencer handles test sharding for CI

### Test Fixtures
Extensive test fixtures cover scenarios like:
- Middleware configurations
- API routes and edge functions
- Static exports and ISR
- Monorepo setups (Nx, Turborepo)
- Various Next.js features (PPR, image optimization, etc.)

## Environment Variables

- `NETLIFY_NEXT_PLUGIN_SKIP` - Skip plugin execution entirely
- `NEXT_PRIVATE_STANDALONE` - Enabled automatically for builds
- `IS_LOCAL` - Indicates local development vs deployment
- `NEXT_SKEW_PROTECTION_ENABLED` - Enable Next.js skew protection (set to '1')
- `VERCEL_DEPLOYMENT_ID` - Set automatically from `NETLIFY_DEPLOY_ID` when skew protection is enabled

## Build Tools

- Custom build script at `tools/build.js` handles compilation
- Uses esbuild for fast builds
- Supports watch mode for development
6 changes: 2 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 36 additions & 1 deletion src/build/functions/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { pathToRegexp } from 'path-to-regexp'

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

const SKEW_PROTECTION_HANDLER_NAME = '___netlify-skew-protection'

const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => {
await mkdir(ctx.edgeFunctionsDir, { recursive: true })
await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
Expand Down Expand Up @@ -189,16 +191,49 @@ const buildHandlerDefinition = (
}))
}

const createSkewProtectionHandler = async (ctx: PluginContext): Promise<void> => {
const handlerDirectory = join(ctx.edgeFunctionsDir, SKEW_PROTECTION_HANDLER_NAME)
const handlerFile = join(handlerDirectory, `${SKEW_PROTECTION_HANDLER_NAME}.js`)

// Read the skew protection template
const templatePath = join(ctx.pluginDir, 'src/build/templates/skew-protection.tmpl.js')
const template = await readFile(templatePath, 'utf8')

await mkdir(handlerDirectory, { recursive: true })
await writeFile(handlerFile, template)
}

export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
}

export const createEdgeHandlers = async (ctx: PluginContext) => {
const nextManifest = await ctx.getMiddlewareManifest()
const nextDefinitions = [...Object.values(nextManifest.middleware)]
await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))

const edgeHandlerPromises = nextDefinitions.map((def) => createEdgeHandler(ctx, def))

// Create skew protection handler if enabled
if (process.env.NEXT_SKEW_PROTECTION_ENABLED === '1') {
edgeHandlerPromises.push(createSkewProtectionHandler(ctx))
}

await Promise.all(edgeHandlerPromises)

const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))

// Add skew protection handler to manifest if enabled
if (process.env.NEXT_SKEW_PROTECTION_ENABLED === '1') {
const skewProtectionDefinition: ManifestFunction = {
function: SKEW_PROTECTION_HANDLER_NAME,
name: 'Next.js Skew Protection Handler',
pattern: '^.*$', // Match all paths
cache: 'manual',
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
}
netlifyDefinitions.unshift(skewProtectionDefinition) // Add at beginning for higher priority
}

const netlifyManifest: Manifest = {
version: 1,
functions: netlifyDefinitions,
Expand Down
116 changes: 116 additions & 0 deletions src/build/templates/skew-protection.tmpl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Skew Protection Edge Function for Next.js on Netlify
*
* This function implements Next.js skew protection by:
* 1. Checking for deployment ID in query param (?dpl=<id>), header (X-Deployment-Id), or cookie (__vdpl)
* 2. Routing requests to the appropriate deployment
*
* Note: Next.js automatically sets the __vdpl cookie when VERCEL_SKEW_PROTECTION_ENABLED=1,
* so this edge function only needs to handle the routing logic.
*/

const SKEW_PROTECTION_COOKIE = '__vdpl'
const DEPLOYMENT_ID_HEADER = 'X-Deployment-Id'
const DEPLOYMENT_ID_QUERY_PARAM = 'dpl'

export default async (request, context) => {
const url = new URL(request.url)
const currentDeployId = context.deploy?.id

// Skip in dev mode
if (!currentDeployId) {
return
}

// Get deployment ID from request in priority order:
// 1. Query parameter (?dpl=<id>)
// 2. Header (X-Deployment-Id)
// 3. Cookie (__vdpl)
let requestedDeployId = url.searchParams.get(DEPLOYMENT_ID_QUERY_PARAM)

if (!requestedDeployId) {
requestedDeployId = request.headers.get(DEPLOYMENT_ID_HEADER)
}

if (!requestedDeployId) {
const cookies = request.headers.get('cookie')
if (cookies) {
const cookieMatch = cookies.match(new RegExp(`${SKEW_PROTECTION_COOKIE}=([^;]+)`))
requestedDeployId = cookieMatch?.[1]
}
}

// If no deployment ID is specified or it matches current deployment, continue normally
if (!requestedDeployId || requestedDeployId === currentDeployId) {
return
}

// Route to the requested deployment
try {
const targetUrl = new URL(request.url)

// Check if this is a request that should be routed to old deployment
if (shouldRouteToOldDeployment(url.pathname)) {
// Route to the old deployment by changing the hostname
targetUrl.hostname = `${requestedDeployId}--${context.site.name}.netlify.app`

// Remove the dpl query parameter to avoid infinite loops
targetUrl.searchParams.delete(DEPLOYMENT_ID_QUERY_PARAM)

// Create new request with the updated URL, preserving all headers
const newRequest = new Request(targetUrl.toString(), {
method: request.method,
headers: request.headers,
body: request.body,
})

// Remove the deployment ID header to avoid confusion
newRequest.headers.delete(DEPLOYMENT_ID_HEADER)

console.log(`[Skew Protection] Routing ${url.pathname} to deployment ${requestedDeployId}`)
return fetch(newRequest)
}
} catch (error) {
console.error('[Skew Protection] Error routing to old deployment:', error)
// Fall through to continue with current deployment
}

// For other requests, continue with current deployment
}

function shouldRouteToOldDeployment(pathname) {
// Route static assets and API routes to old deployments
// But not HTML pages (those should use current deployment for skew protection)

// Static assets (JS, CSS, images, etc.)
if (/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico|webp|avif)$/.test(pathname)) {
return true
}

// Next.js static assets
if (pathname.startsWith('/_next/static/')) {
return true
}

// API routes
if (pathname.startsWith('/api/')) {
return true
}

// Server actions and chunks
if (pathname.includes('/_next/static/chunks/')) {
return true
}

// Image optimization
if (pathname.startsWith('/_next/image')) {
return true
}

// Don't route HTML pages - they should use current deployment
return false
}

export const config = {
path: "/*"
}
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => {
await tracer.withActiveSpan('onPreBuild', async () => {
// Enable Next.js standalone mode at build time
process.env.NEXT_PRIVATE_STANDALONE = 'true'

// Set up skew protection if enabled
if (process.env.NEXT_SKEW_PROTECTION_ENABLED === '1') {
// Use Netlify's deploy ID as the deployment ID for Next.js
const deployId = process.env.NETLIFY_DEPLOY_ID
if (deployId) {
process.env.VERCEL_DEPLOYMENT_ID = deployId
console.log('[Skew Protection] Enabled with deployment ID:', deployId)
} else {
console.warn('[Skew Protection] NEXT_SKEW_PROTECTION_ENABLED is set but NETLIFY_DEPLOY_ID is not available')
}
}

const ctx = new PluginContext(options)
if (options.constants.IS_LOCAL) {
// Only clear directory if we are running locally as then we might have stale functions from previous
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/skew-protection/app/api/test/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function GET() {
return Response.json({
message: 'Hello from API route',
deploymentId: process.env.VERCEL_DEPLOYMENT_ID || 'not-set'
})
}
7 changes: 7 additions & 0 deletions tests/fixtures/skew-protection/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/skew-protection/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div>
<h1>Skew Protection Test</h1>
<p>This page tests Next.js skew protection on Netlify.</p>
</div>
)
}
6 changes: 6 additions & 0 deletions tests/fixtures/skew-protection/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
experimental: {
useDeploymentId: true,
useDeploymentIdServerActions: true,
},
}
13 changes: 13 additions & 0 deletions tests/fixtures/skew-protection/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start"
},
"dependencies": {
"next": "latest",
"react": "latest",
"react-dom": "latest"
}
}
Loading