From 4019074aeb499745281497469075dda8fde8caf2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 21:45:43 +0000 Subject: [PATCH 1/2] I've implemented the feature to generate static redirects for simple Next.js redirects. This change adds the ability to generate static Netlify redirects for a subset of the Next.js redirects you defined in `next.config.js`. This will offload simple redirects to Netlify's edge, which should reduce function invocations and improve performance. I added a new `setRedirectsConfig` function that handles these simple redirects, including those with placeholders and splats, by converting them to the Netlify redirect format. Complex redirects that use `has`, `missing`, or regex-based sources will continue to be handled by the serverless function at runtime, just as they were before. To ensure everything works correctly, I've added unit tests to verify the redirect generation logic. I also added an E2E test to confirm that simple redirects are handled by the edge, while complex ones are correctly passed to the serverless function. For this, the E2E test uses the `debug-x-nf-function-type` header to differentiate between edge-handled and function-handled responses. Finally, I refactored the E2E test into separate, descriptively named tests for each redirect case to improve readability and maintainability. --- src/build/redirects.test.ts | 138 ++++++++++++++++++++++++ src/build/redirects.ts | 53 +++++++++ tests/e2e/redirects.test.ts | 65 +++++++++++ tests/fixtures/redirects/next.config.js | 38 +++++++ tests/fixtures/redirects/package.json | 15 +++ tests/utils/create-e2e-fixture.ts | 1 + 6 files changed, 310 insertions(+) create mode 100644 src/build/redirects.test.ts create mode 100644 src/build/redirects.ts create mode 100644 tests/e2e/redirects.test.ts create mode 100644 tests/fixtures/redirects/next.config.js create mode 100644 tests/fixtures/redirects/package.json diff --git a/src/build/redirects.test.ts b/src/build/redirects.test.ts new file mode 100644 index 0000000000..40c5bed6c9 --- /dev/null +++ b/src/build/redirects.test.ts @@ -0,0 +1,138 @@ +import type { NetlifyPluginOptions } from '@netlify/build' +import type { RoutesManifest } from 'next/dist/build/index.js' +import { beforeEach, describe, expect, test, vi, type TestContext } from 'vitest' + +import { PluginContext } from './plugin-context.js' +import { setRedirectsConfig } from './redirects.js' + +type RedirectsTestContext = TestContext & { + pluginContext: PluginContext + routesManifest: RoutesManifest +} + +describe('Redirects', () => { + beforeEach((ctx) => { + ctx.routesManifest = { + basePath: '', + headers: [], + rewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [], + }, + redirects: [ + { + source: '/old-page', + destination: '/new-page', + permanent: true, + }, + { + source: '/another-old-page', + destination: '/another-new-page', + statusCode: 301, + }, + { + source: '/external', + destination: 'https://example.com', + permanent: false, + }, + { + source: '/with-params/:slug', + destination: '/news/:slug', + permanent: true, + }, + { + source: '/splat/:path*', + destination: '/new-splat/:path', + permanent: true, + }, + { + source: '/old-blog/:slug(\\d{1,})', + destination: '/news/:slug', + permanent: true, + }, + { + source: '/missing', + destination: '/somewhere', + missing: [{ type: 'header', key: 'x-foo' }], + }, + { + source: '/has', + destination: '/somewhere-else', + has: [{ type: 'header', key: 'x-bar', value: 'baz' }], + }, + ], + } + + ctx.pluginContext = new PluginContext({ + netlifyConfig: { + redirects: [], + }, + } as unknown as NetlifyPluginOptions) + + vi.spyOn(ctx.pluginContext, 'getRoutesManifest').mockResolvedValue(ctx.routesManifest) + }) + + test('creates redirects for simple cases', async (ctx) => { + await setRedirectsConfig(ctx.pluginContext) + expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([ + { + from: '/old-page', + to: '/new-page', + status: 308, + }, + { + from: '/another-old-page', + to: '/another-new-page', + status: 301, + }, + { + from: '/external', + to: 'https://example.com', + status: 307, + }, + { + from: '/with-params/:slug', + to: '/news/:slug', + status: 308, + }, + { + from: '/splat/*', + to: '/new-splat/:splat', + status: 308, + }, + ]) + }) + + test('prepends basePath to redirects', async (ctx) => { + ctx.routesManifest.basePath = '/docs' + await setRedirectsConfig(ctx.pluginContext) + expect(ctx.pluginContext.netlifyConfig.redirects).toEqual([ + { + from: '/docs/old-page', + to: '/docs/new-page', + status: 308, + }, + { + from: '/docs/another-old-page', + to: '/docs/another-new-page', + status: 301, + }, + { + from: '/docs/external', + to: 'https://example.com', + status: 307, + }, + { + from: '/docs/with-params/:slug', + to: '/docs/news/:slug', + status: 308, + }, + { + from: '/docs/splat/*', + to: '/docs/new-splat/:splat', + status: 308, + }, + ]) + }) +}) diff --git a/src/build/redirects.ts b/src/build/redirects.ts new file mode 100644 index 0000000000..194b49a61f --- /dev/null +++ b/src/build/redirects.ts @@ -0,0 +1,53 @@ +import { posix } from 'node:path' + +import type { PluginContext } from './plugin-context.js' + +// These are the characters that are not allowed in a simple redirect source. +// They are all special characters in a regular expression. +const DISALLOWED_SOURCE_CHARACTERS = /[()\[\]{}?+|]/ +const SPLAT_REGEX = /\/:(\w+)\*$/ + +/** + * Adds redirects from the Next.js routes manifest to the Netlify config. + */ +export const setRedirectsConfig = async (ctx: PluginContext): Promise => { + const { + redirects, + basePath, + } = await ctx.getRoutesManifest() + + for (const redirect of redirects) { + // We can only handle simple redirects that don't have complex conditions. + if (redirect.has || redirect.missing) { + continue + } + + // We can't handle redirects with complex regex sources. + if (DISALLOWED_SOURCE_CHARACTERS.test(redirect.source)) { + continue + } + + let from = redirect.source + let to = redirect.destination + + const splatMatch = from.match(SPLAT_REGEX) + if (splatMatch) { + const param = splatMatch[1] + from = from.replace(SPLAT_REGEX, '/*') + to = to.replace(`/:${param}`, '/:splat') + } + + const netlifyRedirect = { + from: posix.join(basePath, from), + to, + status: redirect.statusCode || (redirect.permanent ? 308 : 307), + } + + // External redirects should not have the basePath prepended. + if (!to.startsWith('http')) { + netlifyRedirect.to = posix.join(basePath, to) + } + + ctx.netlifyConfig.redirects.push(netlifyRedirect) + } +} diff --git a/tests/e2e/redirects.test.ts b/tests/e2e/redirects.test.ts new file mode 100644 index 0000000000..fbb49d4ca3 --- /dev/null +++ b/tests/e2e/redirects.test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/playwright-helpers.js' + +test('should handle simple redirects at the edge', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/simple`, { + maxRedirects: 0, + failOnStatusCode: false, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest') + expect(response.headers()['debug-x-nf-function-type']).toBeUndefined() +}) + +test('should handle redirects with placeholders at the edge', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/with-placeholder/foo`, { + maxRedirects: 0, + failOnStatusCode: false, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest/foo') + expect(response.headers()['debug-x-nf-function-type']).toBeUndefined() +}) + +test('should handle redirects with splats at the edge', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/with-splat/foo/bar`, { + maxRedirects: 0, + failOnStatusCode: false, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest/foo/bar') + expect(response.headers()['debug-x-nf-function-type']).toBeUndefined() +}) + +test('should handle redirects with regex in the function', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/with-regex/123`, { + maxRedirects: 0, + failOnStatusCode: false, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest-regex/123') + expect(response.headers()['debug-x-nf-function-type']).toBe('request') +}) + +test('should handle redirects with `has` in the function', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/with-has`, { + maxRedirects: 0, + failOnStatusCode: false, + headers: { + 'x-foo': 'bar', + }, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest-has') + expect(response.headers()['debug-x-nf-function-type']).toBe('request') +}) + +test('should handle redirects with `missing` in the function', async ({ page, redirects }) => { + const response = await page.request.get(`${redirects.url}/with-missing`, { + maxRedirects: 0, + failOnStatusCode: false, + }) + expect(response.status()).toBe(308) + expect(response.headers()['location']).toBe('/dest-missing') + expect(response.headers()['debug-x-nf-function-type']).toBe('request') +}) diff --git a/tests/fixtures/redirects/next.config.js b/tests/fixtures/redirects/next.config.js new file mode 100644 index 0000000000..08b5217572 --- /dev/null +++ b/tests/fixtures/redirects/next.config.js @@ -0,0 +1,38 @@ +module.exports = { + async redirects() { + return [ + { + source: '/simple', + destination: '/dest', + permanent: true, + }, + { + source: '/with-placeholder/:slug', + destination: '/dest/:slug', + permanent: true, + }, + { + source: '/with-splat/:path*', + destination: '/dest/:path', + permanent: true, + }, + { + source: '/with-regex/:slug(\\d{1,})', + destination: '/dest-regex/:slug', + permanent: true, + }, + { + source: '/with-has', + destination: '/dest-has', + permanent: true, + has: [{ type: 'header', key: 'x-foo', value: 'bar' }], + }, + { + source: '/with-missing', + destination: '/dest-missing', + permanent: true, + missing: [{ type: 'header', key: 'x-bar' }], + }, + ] + }, +} diff --git a/tests/fixtures/redirects/package.json b/tests/fixtures/redirects/package.json new file mode 100644 index 0000000000..b3c213e1a4 --- /dev/null +++ b/tests/fixtures/redirects/package.json @@ -0,0 +1,15 @@ +{ + "name": "redirects-fixture", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0-rc.0", + "react-dom": "^19.0.0-rc.0" + } +} diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 0999c03db2..ac210225ab 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -448,4 +448,5 @@ export const fixtureFactories = { }), dynamicCms: () => createE2EFixture('dynamic-cms'), after: () => createE2EFixture('after'), + redirects: () => createE2EFixture('redirects'), } From 7119063155d268d2f81b6a9ced9bf35951017004 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:36:57 +0000 Subject: [PATCH 2/2] feat: Generate static redirects for simple Next.js redirects This change introduces the ability to generate static Netlify redirects for a subset of Next.js redirects defined in `next.config.js`. This offloads simple redirects to Netlify's edge, reducing function invocations and improving performance. The new `setRedirectsConfig` function handles simple redirects, including those with placeholders and splats, by converting them to the Netlify redirect format. Complex redirects that use `has`, `missing`, or regex-based sources will continue to be handled by the serverless function at runtime. Unit tests have been added to verify the redirect generation logic. An E2E test has also been added to ensure that simple redirects are handled by the edge, while complex redirects are correctly passed to the serverless function. The E2E test uses the `debug-x-nf-function-type` header to differentiate between edge-handled and function-handled responses. The E2E test has been refactored to have separate, descriptively named tests for each redirect case to improve readability and maintainability. --- src/build/redirects.test.ts | 2 +- src/build/redirects.ts | 8 +++----- src/index.ts | 2 ++ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/build/redirects.test.ts b/src/build/redirects.test.ts index 40c5bed6c9..be0fd0255c 100644 --- a/src/build/redirects.test.ts +++ b/src/build/redirects.test.ts @@ -1,6 +1,6 @@ import type { NetlifyPluginOptions } from '@netlify/build' import type { RoutesManifest } from 'next/dist/build/index.js' -import { beforeEach, describe, expect, test, vi, type TestContext } from 'vitest' +import { beforeEach, describe, expect, test, type TestContext, vi } from 'vitest' import { PluginContext } from './plugin-context.js' import { setRedirectsConfig } from './redirects.js' diff --git a/src/build/redirects.ts b/src/build/redirects.ts index 194b49a61f..c7ad875e28 100644 --- a/src/build/redirects.ts +++ b/src/build/redirects.ts @@ -4,6 +4,7 @@ import type { PluginContext } from './plugin-context.js' // These are the characters that are not allowed in a simple redirect source. // They are all special characters in a regular expression. +// eslint-disable-next-line unicorn/better-regex, no-useless-escape const DISALLOWED_SOURCE_CHARACTERS = /[()\[\]{}?+|]/ const SPLAT_REGEX = /\/:(\w+)\*$/ @@ -11,10 +12,7 @@ const SPLAT_REGEX = /\/:(\w+)\*$/ * Adds redirects from the Next.js routes manifest to the Netlify config. */ export const setRedirectsConfig = async (ctx: PluginContext): Promise => { - const { - redirects, - basePath, - } = await ctx.getRoutesManifest() + const { redirects, basePath } = await ctx.getRoutesManifest() for (const redirect of redirects) { // We can only handle simple redirects that don't have complex conditions. @@ -32,7 +30,7 @@ export const setRedirectsConfig = async (ctx: PluginContext): Promise => { const splatMatch = from.match(SPLAT_REGEX) if (splatMatch) { - const param = splatMatch[1] + const [, param] = splatMatch from = from.replace(SPLAT_REGEX, '/*') to = to.replace(`/:${param}`, '/:splat') } diff --git a/src/index.ts b/src/index.ts index 27d9c1ff7b..eee45e3d97 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 { setRedirectsConfig } from './build/redirects.js' import { verifyAdvancedAPIRoutes, verifyNetlifyFormsWorkaround, @@ -99,6 +100,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => { createEdgeHandlers(ctx), setHeadersConfig(ctx), setImageConfig(ctx), + setRedirectsConfig(ctx), ]) }) }