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'), }