-
Notifications
You must be signed in to change notification settings - Fork 93
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
biilmann
wants to merge
2
commits into
opennextjs:main
Choose a base branch
from
biilmann:feat/next-skew-protection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 `VERCEL_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 | ||
- `VERCEL_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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "/*" | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
}) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
module.exports = { | ||
experimental: { | ||
useDeploymentId: true, | ||
useDeploymentIdServerActions: true, | ||
}, | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.