Skip to content

Commit 5155474

Browse files
feat: normalise URLs in edge middleware (#176)
* feat: normalise URLs in edge middleware * refactor: remove lib reference * chore: update fixtures * fix: remove unused import * refactor: name things better * refactor: rename parameter
1 parent 1eee565 commit 5155474

File tree

19 files changed

+640
-14
lines changed

19 files changed

+640
-14
lines changed

edge-runtime/lib/next-request.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Context } from '@netlify/edge-functions'
22

3+
import { normalizeDataUrl } from './util.ts'
4+
35
interface I18NConfig {
46
defaultLocale: string
57
localeDetection?: false
@@ -31,6 +33,23 @@ export interface RequestData {
3133
body?: ReadableStream<Uint8Array>
3234
}
3335

36+
const normalizeRequestURL = (originalURL: string, enforceTrailingSlash: boolean) => {
37+
const url = new URL(originalURL)
38+
39+
// We want to run middleware for data requests and expose the URL of the
40+
// corresponding pages, so we have to normalize the URLs before running
41+
// the handler.
42+
url.pathname = normalizeDataUrl(url.pathname)
43+
44+
// Normalizing the trailing slash based on the `trailingSlash` configuration
45+
// property from the Next.js config.
46+
if (enforceTrailingSlash && url.pathname !== '/' && !url.pathname.endsWith('/')) {
47+
url.pathname = `${url.pathname}/`
48+
}
49+
50+
return url.toString()
51+
}
52+
3453
export const buildNextRequest = (
3554
request: Request,
3655
context: Context,
@@ -50,7 +69,7 @@ export const buildNextRequest = (
5069
return {
5170
headers: Object.fromEntries(headers.entries()),
5271
geo,
53-
url,
72+
url: normalizeRequestURL(url, Boolean(nextConfig?.trailingSlash)),
5473
method,
5574
ip: context.ip,
5675
body: body ?? undefined,

edge-runtime/lib/response.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const buildResponse = async ({ context, logger, request, result }: BuildR
106106
const isDataReq = request.headers.get('x-nextjs-data')
107107

108108
if (rewrite) {
109-
logger.withFields({ rewrite_url: rewrite }).debug('Is rewrite')
109+
logger.withFields({ rewrite_url: rewrite }).debug('Found middleware rewrite')
110110

111111
const rewriteUrl = new URL(rewrite, request.url)
112112
const baseUrl = new URL(request.url)

edge-runtime/lib/util.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
// If the redirect is a data URL, we need to normalize it.
1+
// If the URL path matches a data URL, we need to normalize it.
22
// https://github.com/vercel/next.js/blob/25e0988e7c9033cb1503cbe0c62ba5de2e97849c/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts#L69-L76
3-
export function normalizeDataUrl(redirect: string) {
4-
if (redirect.startsWith('/_next/data/') && redirect.includes('.json')) {
5-
const paths = redirect
3+
export function normalizeDataUrl(urlPath: string) {
4+
if (urlPath.startsWith('/_next/data/') && urlPath.includes('.json')) {
5+
const paths = urlPath
66
.replace(/^\/_next\/data\//, '')
77
.replace(/\.json/, '')
88
.split('/')
99

10-
redirect = paths[1] !== 'index' ? `/${paths.slice(1).join('/')}` : '/'
10+
urlPath = paths[1] !== 'index' ? `/${paths.slice(1).join('/')}` : '/'
1111
}
1212

13-
return redirect
13+
return urlPath
1414
}
1515

1616
/**

edge-runtime/middleware.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context } from '@netlify/edge-functions'
22

33
import matchers from './matchers.json' assert { type: 'json' }
4+
import nextConfig from './next.config.json' assert { type: 'json' }
45

56
import { InternalHeaders } from './lib/headers.ts'
67
import { logger, LogLevel } from './lib/logging.ts'
@@ -31,7 +32,7 @@ export async function handleMiddleware(
3132
context: Context,
3233
nextHandler: NextHandler,
3334
) {
34-
const nextRequest = buildNextRequest(request, context)
35+
const nextRequest = buildNextRequest(request, context, nextConfig)
3536
const url = new URL(request.url)
3637
const reqLogger = logger
3738
.withLogLevel(

edge-runtime/next.config.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

src/build/functions/edge.ts

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const writeEdgeManifest = async (ctx: PluginContext, manifest: NetlifyManifest)
2424
}
2525

2626
const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
27+
const nextConfig = await ctx.getBuildConfig()
2728
const handlerName = getHandlerName({ name })
2829
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
2930
const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')
@@ -38,6 +39,20 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
3839
// read this file from the function at runtime.
3940
await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers))
4041

42+
// The config is needed by the edge function to match and normalize URLs. To
43+
// avoid shipping and parsing a large file at runtime, let's strip it down to
44+
// just the properties that the edge function actually needs.
45+
const minimalNextConfig = {
46+
basePath: nextConfig.basePath,
47+
i18n: nextConfig.i18n,
48+
trailingSlash: nextConfig.trailingSlash,
49+
}
50+
51+
await writeFile(
52+
join(handlerRuntimeDirectory, 'next.config.json'),
53+
JSON.stringify(minimalNextConfig),
54+
)
55+
4156
// Writing the function entry file. It wraps the middleware code with the
4257
// compatibility layer mentioned above.
4358
await writeFile(

src/run/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ export const setRunConfig = (config: NextConfigComplete) => {
2929
export type TagsManifest = Record<string, string>
3030

3131
export const getTagsManifest = async (): Promise<TagsManifest> => {
32-
return JSON.parse(await readFile(resolve('.netlify/tags-manifest.json'), 'utf-8'))
32+
return JSON.parse(await readFile(resolve(PLUGIN_DIR, '.netlify/tags-manifest.json'), 'utf-8'))
3333
}

tests/fixtures/middleware-conditions/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "middleware",
2+
"name": "middleware-conditions",
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const metadata = {
2+
title: 'Simple Next App',
3+
description: 'Description for Simple Next App',
4+
}
5+
6+
export default function RootLayout({ children }) {
7+
return (
8+
<html lang="en">
9+
<body>{children}</body>
10+
</html>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page() {
2+
return (
3+
<main>
4+
<h1>Other</h1>
5+
</main>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Home() {
2+
return (
3+
<main>
4+
<h1>Home</h1>
5+
</main>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { headers } from 'next/headers'
2+
3+
export default function Page() {
4+
const headersList = headers()
5+
const message = headersList.get('x-hello-from-middleware-req')
6+
7+
return (
8+
<main>
9+
<h1>Message from middleware: {message}</h1>
10+
</main>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Redirect() {
2+
return (
3+
<main>
4+
<h1>If middleware works, we shoudn't get here</h1>
5+
</main>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Rewrite() {
2+
return (
3+
<main>
4+
<h1>If middleware works, we shoudn't get here</h1>
5+
</main>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
4+
export function middleware(request: NextRequest) {
5+
const response = getResponse(request)
6+
7+
response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
8+
response.headers.set('x-hello-from-middleware-res', 'hello')
9+
10+
return response
11+
}
12+
13+
const getResponse = (request: NextRequest) => {
14+
const requestHeaders = new Headers(request.headers)
15+
16+
requestHeaders.set('x-hello-from-middleware-req', 'hello')
17+
18+
if (request.nextUrl.pathname === '/test/next/') {
19+
return NextResponse.next({
20+
request: {
21+
headers: requestHeaders,
22+
},
23+
})
24+
}
25+
26+
if (request.nextUrl.pathname === '/test/redirect/') {
27+
return NextResponse.redirect(new URL('/other', request.url))
28+
}
29+
30+
if (request.nextUrl.pathname === '/test/redirect-with-headers/') {
31+
return NextResponse.redirect(new URL('/other', request.url), {
32+
headers: { 'x-header-from-redirect': 'hello' },
33+
})
34+
}
35+
36+
if (request.nextUrl.pathname === '/test/rewrite-internal/') {
37+
return NextResponse.rewrite(new URL('/rewrite-target', request.url), {
38+
request: {
39+
headers: requestHeaders,
40+
},
41+
})
42+
}
43+
44+
if (request.nextUrl.pathname === '/test/rewrite-external/') {
45+
const requestURL = new URL(request.url)
46+
const externalURL = new URL(requestURL.searchParams.get('external-url') as string)
47+
48+
externalURL.searchParams.set('from', 'middleware')
49+
50+
return NextResponse.rewrite(externalURL, {
51+
request: {
52+
headers: requestHeaders,
53+
},
54+
})
55+
}
56+
57+
return NextResponse.json({ error: 'Error' }, { status: 500 })
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
trailingSlash: true,
4+
output: 'standalone',
5+
eslint: {
6+
ignoreDuringBuilds: true,
7+
},
8+
}
9+
10+
module.exports = nextConfig

0 commit comments

Comments
 (0)