Skip to content

Commit 138b19d

Browse files
authored
fix: match edge runtime pages with optional trailing slash (#1892)
* fix: match edge runtime pages with optional trailing slash * fix: handle trailing slash redirects
1 parent 3b83294 commit 138b19d

File tree

6 files changed

+241
-110
lines changed

6 files changed

+241
-110
lines changed

packages/runtime/src/helpers/edge.ts

+88-52
Original file line numberDiff line numberDiff line change
@@ -202,27 +202,18 @@ const writeEdgeFunction = async ({
202202
edgeFunctionDefinition,
203203
edgeFunctionRoot,
204204
netlifyConfig,
205-
pageRegexMap,
206-
appPathRoutesManifest = {},
207-
nextConfig,
208-
cache,
205+
functionName,
206+
matchers = [],
207+
middleware = false,
209208
}: {
210209
edgeFunctionDefinition: EdgeFunctionDefinition
211210
edgeFunctionRoot: string
212211
netlifyConfig: NetlifyConfig
213-
pageRegexMap?: Map<string, string>
214-
appPathRoutesManifest?: Record<string, string>
215-
nextConfig: NextConfig
216-
cache?: 'manual'
217-
}): Promise<
218-
Array<{
219-
function: string
220-
name: string
221-
pattern: string
222-
}>
223-
> => {
224-
const name = sanitizeName(edgeFunctionDefinition.name)
225-
const edgeFunctionDir = join(edgeFunctionRoot, name)
212+
functionName: string
213+
matchers?: Array<MiddlewareMatcher>
214+
middleware?: boolean
215+
}) => {
216+
const edgeFunctionDir = join(edgeFunctionRoot, functionName)
226217

227218
const bundle = await getMiddlewareBundle({
228219
edgeFunctionDefinition,
@@ -234,43 +225,52 @@ const writeEdgeFunction = async ({
234225

235226
await copyEdgeSourceFile({
236227
edgeFunctionDir,
237-
file: 'runtime.ts',
228+
file: middleware ? 'middleware-runtime.ts' : 'function-runtime.ts',
238229
target: 'index.ts',
239230
})
240231

241-
const matchers: EdgeFunctionDefinitionV2['matchers'] = []
232+
if (middleware) {
233+
// Functions don't have complex matchers, so we can rely on the Netlify matcher
234+
await writeJson(join(edgeFunctionDir, 'matchers.json'), matchers)
235+
}
236+
}
242237

238+
const generateEdgeFunctionMiddlewareMatchers = ({
239+
edgeFunctionDefinition,
240+
nextConfig,
241+
}: {
242+
edgeFunctionDefinition: EdgeFunctionDefinition
243+
edgeFunctionRoot: string
244+
nextConfig: NextConfig
245+
cache?: 'manual'
246+
}): Array<MiddlewareMatcher> => {
243247
// The v1 middleware manifest has a single regexp, but the v2 has an array of matchers
244248
if ('regexp' in edgeFunctionDefinition) {
245-
matchers.push({ regexp: edgeFunctionDefinition.regexp })
246-
} else if (nextConfig.i18n) {
247-
matchers.push(
248-
...edgeFunctionDefinition.matchers.map((matcher) => ({
249-
...matcher,
250-
regexp: makeLocaleOptional(matcher.regexp),
251-
})),
252-
)
253-
} else {
254-
matchers.push(...edgeFunctionDefinition.matchers)
249+
return [{ regexp: edgeFunctionDefinition.regexp }]
255250
}
256-
257-
// If the EF matches a page, it's an app dir page so needs a matcher too
258-
// The object will be empty if appDir isn't enabled in the Next config
259-
if (pageRegexMap && edgeFunctionDefinition.page in appPathRoutesManifest) {
260-
const regexp = pageRegexMap.get(appPathRoutesManifest[edgeFunctionDefinition.page])
261-
if (regexp) {
262-
matchers.push({ regexp })
263-
}
251+
if (nextConfig.i18n) {
252+
return edgeFunctionDefinition.matchers.map((matcher) => ({
253+
...matcher,
254+
regexp: makeLocaleOptional(matcher.regexp),
255+
}))
264256
}
257+
return edgeFunctionDefinition.matchers
258+
}
265259

266-
await writeJson(join(edgeFunctionDir, 'matchers.json'), matchers)
267-
268-
// We add a defintion for each matching path
269-
return matchers.map((matcher) => {
270-
const pattern = transformCaptureGroups(stripLookahead(matcher.regexp))
271-
return { function: name, pattern, name: edgeFunctionDefinition.name, cache }
272-
})
260+
const middlewareMatcherToEdgeFunctionDefinition = (
261+
matcher: MiddlewareMatcher,
262+
name: string,
263+
cache?: 'manual',
264+
): {
265+
function: string
266+
name?: string
267+
pattern: string
268+
cache?: 'manual'
269+
} => {
270+
const pattern = transformCaptureGroups(stripLookahead(matcher.regexp))
271+
return { function: name, pattern, name, cache }
273272
}
273+
274274
export const cleanupEdgeFunctions = ({
275275
INTERNAL_EDGE_FUNCTIONS_SRC = '.netlify/edge-functions',
276276
}: NetlifyPluginConstants) => emptyDir(INTERNAL_EDGE_FUNCTIONS_SRC)
@@ -348,9 +348,28 @@ export const writeRscDataEdgeFunction = async ({
348348
]
349349
}
350350

351+
const getEdgeFunctionPatternForPage = ({
352+
edgeFunctionDefinition,
353+
pageRegexMap,
354+
appPathRoutesManifest,
355+
}: {
356+
edgeFunctionDefinition: EdgeFunctionDefinitionV2
357+
pageRegexMap: Map<string, string>
358+
appPathRoutesManifest?: Record<string, string>
359+
}): string => {
360+
// We don't just use the matcher from the edge function definition, because it doesn't handle trailing slashes
361+
362+
// appDir functions have a name that _isn't_ the route name, but rather the route with `/page` appended
363+
const regexp = pageRegexMap.get(appPathRoutesManifest?.[edgeFunctionDefinition.page] ?? edgeFunctionDefinition.page)
364+
// If we need to fall back to the matcher, we need to add an optional trailing slash
365+
return regexp ?? edgeFunctionDefinition.matchers[0].regexp.replace(/([^/])\$$/, '$1/?$')
366+
}
367+
351368
/**
352369
* Writes Edge Functions for the Next middleware
353370
*/
371+
372+
// eslint-disable-next-line max-lines-per-function
354373
export const writeEdgeFunctions = async ({
355374
netlifyConfig,
356375
routesManifest,
@@ -415,16 +434,25 @@ export const writeEdgeFunctions = async ({
415434
for (const middleware of middlewareManifest.sortedMiddleware) {
416435
usesEdge = true
417436
const edgeFunctionDefinition = middlewareManifest.middleware[middleware]
418-
const functionDefinitions = await writeEdgeFunction({
437+
const functionName = sanitizeName(edgeFunctionDefinition.name)
438+
const matchers = generateEdgeFunctionMiddlewareMatchers({
419439
edgeFunctionDefinition,
420440
edgeFunctionRoot,
421-
netlifyConfig,
422441
nextConfig,
423442
})
424-
manifest.functions.push(...functionDefinitions)
443+
await writeEdgeFunction({
444+
edgeFunctionDefinition,
445+
edgeFunctionRoot,
446+
netlifyConfig,
447+
functionName,
448+
matchers,
449+
middleware: true,
450+
})
451+
452+
manifest.functions.push(
453+
...matchers.map((matcher) => middlewareMatcherToEdgeFunctionDefinition(matcher, functionName)),
454+
)
425455
}
426-
// Older versions of the manifest format don't have the functions field
427-
// No, the version field was not incremented
428456
if (typeof middlewareManifest.functions === 'object') {
429457
// When using the app dir, we also need to check if the EF matches a page
430458
const appPathRoutesManifest = await loadAppPathRoutesManifest(netlifyConfig)
@@ -438,17 +466,25 @@ export const writeEdgeFunctions = async ({
438466

439467
for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
440468
usesEdge = true
441-
const functionDefinitions = await writeEdgeFunction({
469+
const functionName = sanitizeName(edgeFunctionDefinition.name)
470+
await writeEdgeFunction({
442471
edgeFunctionDefinition,
443472
edgeFunctionRoot,
444473
netlifyConfig,
474+
functionName,
475+
})
476+
const pattern = getEdgeFunctionPatternForPage({
477+
edgeFunctionDefinition,
445478
pageRegexMap,
446479
appPathRoutesManifest,
447-
nextConfig,
480+
})
481+
manifest.functions.push({
482+
function: functionName,
483+
name: edgeFunctionDefinition.name,
484+
pattern,
448485
// cache: "manual" is currently experimental, so we restrict it to sites that use experimental appDir
449486
cache: usesAppDir ? 'manual' : undefined,
450487
})
451-
manifest.functions.push(...functionDefinitions)
452488
}
453489
}
454490
if (usesEdge) {

packages/runtime/src/templates/edge-shared/utils.test.ts

+51-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { assertEquals } from 'https://deno.land/[email protected]/testing/asserts.ts'
22
import { beforeEach, describe, it } from 'https://deno.land/[email protected]/testing/bdd.ts'
3-
import { updateModifiedHeaders, FetchEventResult } from './utils.ts'
3+
import { redirectTrailingSlash, updateModifiedHeaders } from './utils.ts'
44

55
describe('updateModifiedHeaders', () => {
66
it("does not modify the headers if 'x-middleware-override-headers' is not found", () => {
@@ -62,3 +62,53 @@ describe('updateModifiedHeaders', () => {
6262
})
6363
})
6464
})
65+
66+
describe('trailing slash redirects', () => {
67+
it('adds a trailing slash to the pathn if trailingSlash is enabled', () => {
68+
const url = new URL('https://example.com/foo')
69+
const result = redirectTrailingSlash(url, true)
70+
assertEquals(result?.status, 308)
71+
assertEquals(result?.headers.get('location'), 'https://example.com/foo/')
72+
})
73+
74+
it("doesn't add a trailing slash if trailingSlash is false", () => {
75+
const url = new URL('https://example.com/foo')
76+
const result = redirectTrailingSlash(url, false)
77+
assertEquals(result, undefined)
78+
})
79+
80+
it("doesn't add a trailing slash if the path is a file", () => {
81+
const url = new URL('https://example.com/foo.txt')
82+
const result = redirectTrailingSlash(url, true)
83+
assertEquals(result, undefined)
84+
})
85+
it('adds a trailing slash if there is a dot in the path', () => {
86+
const url = new URL('https://example.com/foo.bar/baz')
87+
const result = redirectTrailingSlash(url, true)
88+
assertEquals(result?.status, 308)
89+
assertEquals(result?.headers.get('location'), 'https://example.com/foo.bar/baz/')
90+
})
91+
it("doesn't add a trailing slash if the path is /", () => {
92+
const url = new URL('https://example.com/')
93+
const result = redirectTrailingSlash(url, true)
94+
assertEquals(result, undefined)
95+
})
96+
it('removes a trailing slash from the path if trailingSlash is false', () => {
97+
const url = new URL('https://example.com/foo/')
98+
const result = redirectTrailingSlash(url, false)
99+
assertEquals(result?.status, 308)
100+
assertEquals(result?.headers.get('location'), 'https://example.com/foo')
101+
})
102+
it("doesn't remove a trailing slash if trailingSlash is true", () => {
103+
const url = new URL('https://example.com/foo/')
104+
const result = redirectTrailingSlash(url, true)
105+
assertEquals(result, undefined)
106+
})
107+
108+
it('removes a trailing slash from the path if the path is a file', () => {
109+
const url = new URL('https://example.com/foo.txt/')
110+
const result = redirectTrailingSlash(url, false)
111+
assertEquals(result?.status, 308)
112+
assertEquals(result?.headers.get('location'), 'https://example.com/foo.txt')
113+
})
114+
})

packages/runtime/src/templates/edge-shared/utils.ts

+75
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,37 @@ interface MiddlewareRequest {
5656
rewrite(destination: string | URL, init?: ResponseInit): Response
5757
}
5858

59+
export interface I18NConfig {
60+
defaultLocale: string
61+
localeDetection?: false
62+
locales: string[]
63+
}
64+
65+
export interface RequestData {
66+
geo?: {
67+
city?: string
68+
country?: string
69+
region?: string
70+
latitude?: string
71+
longitude?: string
72+
timezone?: string
73+
}
74+
headers: Record<string, string>
75+
ip?: string
76+
method: string
77+
nextConfig?: {
78+
basePath?: string
79+
i18n?: I18NConfig | null
80+
trailingSlash?: boolean
81+
}
82+
page?: {
83+
name?: string
84+
params?: { [key: string]: string }
85+
}
86+
url: string
87+
body?: ReadableStream<Uint8Array>
88+
}
89+
5990
function isMiddlewareRequest(response: Response | MiddlewareRequest): response is MiddlewareRequest {
6091
return 'originalRequest' in response
6192
}
@@ -90,6 +121,34 @@ export function updateModifiedHeaders(requestHeaders: Headers, responseHeaders:
90121
responseHeaders.delete('x-middleware-override-headers')
91122
}
92123

124+
export const buildNextRequest = (
125+
request: Request,
126+
context: Context,
127+
nextConfig?: RequestData['nextConfig'],
128+
): RequestData => {
129+
const { url, method, body, headers } = request
130+
131+
const { country, subdivision, city, latitude, longitude, timezone } = context.geo
132+
133+
const geo: RequestData['geo'] = {
134+
country: country?.code,
135+
region: subdivision?.code,
136+
city,
137+
latitude: latitude?.toString(),
138+
longitude: longitude?.toString(),
139+
timezone,
140+
}
141+
142+
return {
143+
headers: Object.fromEntries(headers.entries()),
144+
geo,
145+
url,
146+
method,
147+
ip: context.ip,
148+
body: body ?? undefined,
149+
nextConfig,
150+
}
151+
}
93152
export const buildResponse = async ({
94153
result,
95154
request,
@@ -196,3 +255,19 @@ export const buildResponse = async ({
196255
}
197256
return res
198257
}
258+
259+
export const redirectTrailingSlash = (url: URL, trailingSlash: boolean): Response | undefined => {
260+
const { pathname } = url
261+
if (pathname === '/') {
262+
return
263+
}
264+
if (!trailingSlash && pathname.endsWith('/')) {
265+
url.pathname = pathname.slice(0, -1)
266+
return Response.redirect(url, 308)
267+
}
268+
// Add a slash, unless there's a file extension
269+
if (trailingSlash && !pathname.endsWith('/') && !pathname.split('/').pop()?.includes('.')) {
270+
url.pathname = `${pathname}/`
271+
return Response.redirect(url, 308)
272+
}
273+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Context } from 'https://edge.netlify.com'
2+
// Available at build time
3+
import edgeFunction from './bundle.js'
4+
import { buildNextRequest, buildResponse, redirectTrailingSlash } from '../edge-shared/utils.ts'
5+
import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
6+
7+
const handler = async (req: Request, context: Context) => {
8+
const url = new URL(req.url)
9+
const redirect = redirectTrailingSlash(url, nextConfig.trailingSlash)
10+
if (redirect) {
11+
return redirect
12+
}
13+
const request = buildNextRequest(req, context, nextConfig)
14+
try {
15+
const result = await edgeFunction({ request })
16+
return buildResponse({ result, request: req, context })
17+
} catch (error) {
18+
console.error(error)
19+
return new Response(error.message, { status: 500 })
20+
}
21+
}
22+
23+
export default handler

0 commit comments

Comments
 (0)