Skip to content

Commit 8b5687c

Browse files
ascorbiceduardoboucas
andauthoredJan 19, 2024
fix: handle middleware rewrites to in data requests (#180)
* ci: don't retry e2e tests * wip * chore: don't copy test files * fix: normalise data URLs * Handle rewrites * Update edge-runtime/lib/response.ts Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com> --------- Co-authored-by: Eduardo Bouças <mail@eduardoboucas.com>
1 parent cac9a75 commit 8b5687c

29 files changed

+1027
-18
lines changed
 

‎.github/workflows/deno-test.yml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Deno test
2+
on:
3+
pull_request:
4+
branches: [main]
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: Checkout
10+
uses: actions/checkout@v3
11+
- name: Setup Deno
12+
uses: denoland/setup-deno@v1
13+
with:
14+
deno-version: vx.x.x
15+
- name: Test
16+
run: deno test -A edge-runtime/

‎edge-runtime/lib/next-request.ts

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

3-
import { normalizeDataUrl } from './util.ts'
3+
import { normalizeDataUrl, removeBasePath } from './util.ts'
44

55
interface I18NConfig {
66
defaultLocale: string
@@ -33,17 +33,19 @@ export interface RequestData {
3333
body?: ReadableStream<Uint8Array>
3434
}
3535

36-
const normalizeRequestURL = (originalURL: string, enforceTrailingSlash: boolean) => {
36+
const normalizeRequestURL = (originalURL: string, nextConfig?: RequestData['nextConfig']) => {
3737
const url = new URL(originalURL)
3838

39+
url.pathname = removeBasePath(url.pathname, nextConfig?.basePath)
40+
3941
// We want to run middleware for data requests and expose the URL of the
4042
// corresponding pages, so we have to normalize the URLs before running
4143
// the handler.
4244
url.pathname = normalizeDataUrl(url.pathname)
4345

4446
// Normalizing the trailing slash based on the `trailingSlash` configuration
4547
// property from the Next.js config.
46-
if (enforceTrailingSlash && url.pathname !== '/' && !url.pathname.endsWith('/')) {
48+
if (nextConfig?.trailingSlash && url.pathname !== '/' && !url.pathname.endsWith('/')) {
4749
url.pathname = `${url.pathname}/`
4850
}
4951

@@ -69,7 +71,7 @@ export const buildNextRequest = (
6971
return {
7072
headers: Object.fromEntries(headers.entries()),
7173
geo,
72-
url: normalizeRequestURL(url, Boolean(nextConfig?.trailingSlash)),
74+
url: normalizeRequestURL(url, nextConfig),
7375
method,
7476
ip: context.ip,
7577
body: body ?? undefined,

‎edge-runtime/lib/response.ts

+19-7
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { HTMLRewriter } from '../vendor/deno.land/x/html_rewriter@v0.1.0-pre.17/
33

44
import { updateModifiedHeaders } from './headers.ts'
55
import type { StructuredLogger } from './logging.ts'
6-
import { normalizeDataUrl, relativizeURL } from './util.ts'
6+
import { normalizeDataUrl, relativizeURL, rewriteDataPath } from './util.ts'
77
import { addMiddlewareHeaders, isMiddlewareRequest, isMiddlewareResponse } from './middleware.ts'
8+
import { RequestData } from './next-request.ts'
89

910
export interface FetchEventResult {
1011
response: Response
@@ -16,9 +17,16 @@ interface BuildResponseOptions {
1617
logger: StructuredLogger
1718
request: Request
1819
result: FetchEventResult
20+
nextConfig?: RequestData['nextConfig']
1921
}
2022

21-
export const buildResponse = async ({ context, logger, request, result }: BuildResponseOptions) => {
23+
export const buildResponse = async ({
24+
context,
25+
logger,
26+
request,
27+
result,
28+
nextConfig,
29+
}: BuildResponseOptions) => {
2230
logger
2331
.withFields({ is_nextresponse_next: result.response.headers.has('x-middleware-next') })
2432
.debug('Building Next.js response')
@@ -103,31 +111,35 @@ export const buildResponse = async ({ context, logger, request, result }: BuildR
103111
const rewrite = res.headers.get('x-middleware-rewrite')
104112

105113
// Data requests (i.e. requests for /_next/data ) need special handling
106-
const isDataReq = request.headers.get('x-nextjs-data')
114+
const isDataReq = request.headers.has('x-nextjs-data')
107115

108116
if (rewrite) {
109117
logger.withFields({ rewrite_url: rewrite }).debug('Found middleware rewrite')
110118

111119
const rewriteUrl = new URL(rewrite, request.url)
112120
const baseUrl = new URL(request.url)
113121
const relativeUrl = relativizeURL(rewrite, request.url)
122+
const originalPath = new URL(request.url, `http://n`).pathname
114123

115124
// Data requests might be rewritten to an external URL
116125
// This header tells the client router the redirect target, and if it's external then it will do a full navigation
117126
if (isDataReq) {
118127
res.headers.set('x-nextjs-rewrite', relativeUrl)
128+
rewriteUrl.pathname = rewriteDataPath({
129+
dataUrl: originalPath,
130+
newRoute: relativeUrl,
131+
basePath: nextConfig?.basePath,
132+
})
119133
}
120134
if (rewriteUrl.origin !== baseUrl.origin) {
121135
// Netlify Edge Functions don't support proxying to external domains, but Next middleware does
122-
const proxied = fetch(new Request(rewriteUrl.toString(), request))
136+
const proxied = fetch(new Request(rewriteUrl, request))
123137
return addMiddlewareHeaders(proxied, res)
124138
}
125139
res.headers.set('x-middleware-rewrite', relativeUrl)
126-
127-
request.headers.set('x-original-path', new URL(request.url, `http://n`).pathname)
128140
request.headers.set('x-middleware-rewrite', rewrite)
129141

130-
return addMiddlewareHeaders(context.rewrite(rewrite), res)
142+
return addMiddlewareHeaders(fetch(new Request(rewriteUrl, request)), res)
131143
}
132144

133145
const redirect = res.headers.get('Location')

‎edge-runtime/lib/util.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { assertEquals } from 'https://deno.land/std@0.175.0/testing/asserts.ts'
2+
import { rewriteDataPath } from './util.ts'
3+
4+
Deno.test('rewriteDataPath', async (t) => {
5+
await t.step('should rewrite a data url', async () => {
6+
const dataUrl = '/_next/data/build-id/rewrite-me.json'
7+
const newRoute = '/target'
8+
const result = rewriteDataPath({ dataUrl, newRoute })
9+
assertEquals(result, '/_next/data/build-id/target.json')
10+
})
11+
12+
await t.step('should rewrite a data url with a base path', async () => {
13+
const dataUrl = '/baseDir/_next/data/build-id/rewrite-me.json'
14+
const newRoute = '/target'
15+
const result = rewriteDataPath({ dataUrl, newRoute, basePath: '/baseDir' })
16+
assertEquals(result, '/baseDir/_next/data/build-id/target.json')
17+
})
18+
19+
await t.step('should rewrite from an index data url', async () => {
20+
const dataUrl = '/_next/data/build-id/index.json'
21+
const newRoute = '/target'
22+
const result = rewriteDataPath({ dataUrl, newRoute })
23+
assertEquals(result, '/_next/data/build-id/target.json')
24+
})
25+
26+
await t.step('should rewrite to an index data url', async () => {
27+
const dataUrl = '/_next/data/build-id/rewrite-me.json'
28+
const newRoute = '/'
29+
const result = rewriteDataPath({ dataUrl, newRoute })
30+
assertEquals(result, '/_next/data/build-id/index.json')
31+
})
32+
33+
await t.step('should rewrite to a route with a trailing slash', async () => {
34+
const dataUrl = '/_next/data/build-id/rewrite-me.json'
35+
const newRoute = '/target/'
36+
const result = rewriteDataPath({ dataUrl, newRoute })
37+
assertEquals(result, '/_next/data/build-id/target.json')
38+
})
39+
})

‎edge-runtime/lib/util.ts

+32
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export function normalizeDataUrl(urlPath: string) {
1313
return urlPath
1414
}
1515

16+
export const removeBasePath = (path: string, basePath?: string) => {
17+
if (basePath && path.startsWith(basePath)) {
18+
return path.replace(basePath, '')
19+
}
20+
return path
21+
}
22+
1623
/**
1724
* This is how Next handles rewritten URLs.
1825
*/
@@ -24,3 +31,28 @@ export function relativizeURL(url: string | string, base: string | URL) {
2431
? relative.toString().replace(origin, '')
2532
: relative.toString()
2633
}
34+
35+
export const normalizeIndex = (path: string) => (path === '/' ? '/index' : path)
36+
37+
const stripTrailingSlash = (path: string) =>
38+
path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path
39+
40+
/**
41+
* Modify a data url to point to a new page route.
42+
*/
43+
export function rewriteDataPath({
44+
dataUrl,
45+
newRoute,
46+
basePath,
47+
}: {
48+
dataUrl: string
49+
newRoute: string
50+
basePath?: string
51+
}) {
52+
const normalizedDataUrl = normalizeDataUrl(removeBasePath(dataUrl, basePath))
53+
54+
return dataUrl.replace(
55+
normalizeIndex(normalizedDataUrl),
56+
stripTrailingSlash(normalizeIndex(newRoute)),
57+
)
58+
}

‎src/build/functions/edge.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
22
import { dirname, join } from 'node:path'
33

4+
import { glob } from 'fast-glob'
45
import type { EdgeFunctionDefinition as NextDefinition } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
56

67
import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js'
@@ -23,6 +24,19 @@ const writeEdgeManifest = async (ctx: PluginContext, manifest: NetlifyManifest)
2324
await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
2425
}
2526

27+
const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promise<void> => {
28+
const files = await glob('edge-runtime/**/*', {
29+
cwd: ctx.pluginDir,
30+
ignore: ['**/*.test.ts'],
31+
dot: true,
32+
})
33+
await Promise.all(
34+
files.map((path) =>
35+
cp(join(ctx.pluginDir, path), join(handlerDirectory, path), { recursive: true }),
36+
),
37+
)
38+
}
39+
2640
const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
2741
const nextConfig = await ctx.getBuildConfig()
2842
const handlerName = getHandlerName({ name })
@@ -31,9 +45,7 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
3145

3246
// Copying the runtime files. These are the compatibility layer between
3347
// Netlify Edge Functions and the Next.js edge runtime.
34-
await cp(join(ctx.pluginDir, 'edge-runtime'), handlerRuntimeDirectory, {
35-
recursive: true,
36-
})
48+
await copyRuntime(ctx, handlerDirectory)
3749

3850
// Writing a file with the matchers that should trigger this function. We'll
3951
// read this file from the function at runtime.

‎tests/fixtures/middleware-conditions/package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { NextResponse, URLPattern } from 'next/server'
2+
3+
export async function middleware(request) {
4+
const url = request.nextUrl
5+
6+
// this is needed for tests to get the BUILD_ID
7+
if (url.pathname.startsWith('/_next/static/__BUILD_ID')) {
8+
return NextResponse.next()
9+
}
10+
11+
if (request.headers.get('x-prerender-revalidate')) {
12+
return NextResponse.next({
13+
headers: { 'x-middleware': 'hi' },
14+
})
15+
}
16+
17+
if (url.pathname === '/about/') {
18+
return NextResponse.rewrite(new URL('/about/a', request.url))
19+
}
20+
21+
if (url.pathname === '/ssr-page/') {
22+
url.pathname = '/ssr-page-2'
23+
return NextResponse.rewrite(url)
24+
}
25+
26+
if (url.pathname === '/') {
27+
url.pathname = '/ssg/first'
28+
return NextResponse.rewrite(url)
29+
}
30+
31+
if (url.pathname === '/to-ssg/') {
32+
url.pathname = '/ssg/hello'
33+
url.searchParams.set('from', 'middleware')
34+
return NextResponse.rewrite(url)
35+
}
36+
37+
if (url.pathname === '/sha/') {
38+
url.pathname = '/shallow'
39+
return NextResponse.rewrite(url)
40+
}
41+
42+
if (url.pathname === '/rewrite-to-dynamic/') {
43+
url.pathname = '/blog/from-middleware'
44+
url.searchParams.set('some', 'middleware')
45+
return NextResponse.rewrite(url)
46+
}
47+
48+
if (url.pathname === '/rewrite-to-config-rewrite/') {
49+
url.pathname = '/rewrite-3'
50+
url.searchParams.set('some', 'middleware')
51+
return NextResponse.rewrite(url)
52+
}
53+
54+
if (url.pathname === '/redirect-to-somewhere/') {
55+
url.pathname = '/somewhere'
56+
return NextResponse.redirect(url, {
57+
headers: {
58+
'x-redirect-header': 'hi',
59+
},
60+
})
61+
}
62+
63+
const original = new URL(request.url)
64+
return NextResponse.next({
65+
headers: {
66+
'req-url-path': `${original.pathname}${original.search}`,
67+
'req-url-basepath': request.nextUrl.basePath,
68+
'req-url-pathname': request.nextUrl.pathname,
69+
'req-url-query': request.nextUrl.searchParams.get('foo'),
70+
'req-url-locale': request.nextUrl.locale,
71+
'req-url-params':
72+
url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}',
73+
},
74+
})
75+
}
76+
77+
const PATTERNS = [
78+
[
79+
new URLPattern({ pathname: '/:locale/:id' }),
80+
({ pathname }) => ({
81+
pathname: '/:locale/:id',
82+
params: pathname.groups,
83+
}),
84+
],
85+
[
86+
new URLPattern({ pathname: '/:id' }),
87+
({ pathname }) => ({
88+
pathname: '/:id',
89+
params: pathname.groups,
90+
}),
91+
],
92+
]
93+
94+
const params = (url) => {
95+
const input = url.split('?')[0]
96+
let result = {}
97+
98+
for (const [pattern, handler] of PATTERNS) {
99+
const patternResult = pattern.exec(input)
100+
if (patternResult !== null && 'pathname' in patternResult) {
101+
result = handler(patternResult)
102+
break
103+
}
104+
}
105+
return result
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/basic-features/typescript for more information.

0 commit comments

Comments
 (0)
Please sign in to comment.