Skip to content
Draft
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
138 changes: 138 additions & 0 deletions src/build/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { NetlifyPluginOptions } from '@netlify/build'
import type { RoutesManifest } from 'next/dist/build/index.js'
import { beforeEach, describe, expect, test, type TestContext, vi } from 'vitest'

import { PluginContext } from './plugin-context.js'
import { setRedirectsConfig } from './redirects.js'

type RedirectsTestContext = TestContext & {
pluginContext: PluginContext
routesManifest: RoutesManifest
}

describe('Redirects', () => {
beforeEach<RedirectsTestContext>((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<RedirectsTestContext>('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<RedirectsTestContext>('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,
},
])
})
})
51 changes: 51 additions & 0 deletions src/build/redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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.
// eslint-disable-next-line unicorn/better-regex, no-useless-escape
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<void> => {
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
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)
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -99,6 +100,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
createEdgeHandlers(ctx),
setHeadersConfig(ctx),
setImageConfig(ctx),
setRedirectsConfig(ctx),
])
})
}
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest-has/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestHasPage() {
return (
<div>
<h1>Destination Page with Has Condition</h1>
<p>This page is shown when the redirect has condition is met</p>
</div>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest-missing/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestMissingPage() {
return (
<div>
<h1>Destination Page with Missing Condition</h1>
<p>This page is shown when the redirect missing condition is met</p>
</div>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest-regex/[slug]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestRegexPage({ params }) {
return (
<div>
<h1>Destination Page with Regex</h1>
<p>Slug: {params.slug}</p>
</div>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest/[...path]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestPathPage({ params }) {
return (
<div>
<h1>Destination Page with Splat</h1>
<p>Path: {params.path?.join('/') || ''}</p>
</div>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest/[slug]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestSlugPage({ params }) {
return (
<div>
<h1>Destination Page with Placeholder</h1>
<p>Slug: {params.slug}</p>
</div>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/dest/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DestPage() {
return (
<div>
<h1>Destination Page</h1>
<p>This is the destination for simple redirects</p>
</div>
)
}
12 changes: 12 additions & 0 deletions tests/fixtures/redirects/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const metadata = {
title: 'Simple Next App',
description: 'Description for Simple Next App',
}

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/not-found.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function NotFound() {
return (
<div>
<h1>404 Not Found</h1>
<p>Custom Not Found Page</p>
</div>
)
}
7 changes: 7 additions & 0 deletions tests/fixtures/redirects/app/other/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Home() {
return (
<main>
<h1>Other</h1>
</main>
)
}
8 changes: 8 additions & 0 deletions tests/fixtures/redirects/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function Home() {
return (
<main>
<h1>Home</h1>
<img src="/squirrel.jpg" alt="a cute squirrel" width="300px" />
</main>
)
}
Loading
Loading