Skip to content

Commit 747af5d

Browse files
committed
fix: handle trailing slash redirects
1 parent d871cd7 commit 747af5d

File tree

5 files changed

+162
-62
lines changed

5 files changed

+162
-62
lines changed

packages/runtime/src/helpers/edge.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,15 @@ const writeEdgeFunction = async ({
203203
edgeFunctionRoot,
204204
netlifyConfig,
205205
functionName,
206-
matchers,
206+
matchers = [],
207+
middleware = false,
207208
}: {
208209
edgeFunctionDefinition: EdgeFunctionDefinition
209210
edgeFunctionRoot: string
210211
netlifyConfig: NetlifyConfig
211212
functionName: string
212-
matchers: Array<MiddlewareMatcher>
213+
matchers?: Array<MiddlewareMatcher>
214+
middleware?: boolean
213215
}) => {
214216
const edgeFunctionDir = join(edgeFunctionRoot, functionName)
215217

@@ -223,11 +225,14 @@ const writeEdgeFunction = async ({
223225

224226
await copyEdgeSourceFile({
225227
edgeFunctionDir,
226-
file: 'runtime.ts',
228+
file: middleware ? 'middleware-runtime.ts' : 'function-runtime.ts',
227229
target: 'index.ts',
228230
})
229231

230-
await writeJson(join(edgeFunctionDir, 'matchers.json'), 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+
}
231236
}
232237

233238
const generateEdgeFunctionMiddlewareMatchers = ({
@@ -440,6 +445,7 @@ export const writeEdgeFunctions = async ({
440445
netlifyConfig,
441446
functionName,
442447
matchers,
448+
middleware: true,
443449
})
444450

445451
manifest.functions.push(
@@ -465,7 +471,6 @@ export const writeEdgeFunctions = async ({
465471
edgeFunctionRoot,
466472
netlifyConfig,
467473
functionName,
468-
matchers: edgeFunctionDefinition.matchers,
469474
})
470475
const pattern = getEdgeFunctionPatternForPage({
471476
edgeFunctionDefinition,

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { assertEquals } from 'https://deno.land/std@0.167.0/testing/asserts.ts'
22
import { beforeEach, describe, it } from 'https://deno.land/std@0.167.0/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

Lines changed: 75 additions & 0 deletions
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+
}
Lines changed: 23 additions & 0 deletions
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

packages/runtime/src/templates/edge/runtime.ts renamed to packages/runtime/src/templates/edge/middleware-runtime.ts

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,13 @@
11
import type { Context } from 'https://edge.netlify.com'
22
// Available at build time
33
import matchers from './matchers.json' assert { type: 'json' }
4-
import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
54
import edgeFunction from './bundle.js'
6-
import { buildResponse } from '../edge-shared/utils.ts'
5+
import { buildNextRequest, buildResponse } from '../edge-shared/utils.ts'
76
import { getMiddlewareRouteMatcher, MiddlewareRouteMatch, searchParamsToUrlQuery } from '../edge-shared/next-utils.ts'
7+
import nextConfig from '../edge-shared/nextConfig.json' assert { type: 'json' }
88

99
const matchesMiddleware: MiddlewareRouteMatch = getMiddlewareRouteMatcher(matchers || [])
1010

11-
export interface FetchEventResult {
12-
response: Response
13-
waitUntil: Promise<any>
14-
}
15-
16-
export interface I18NConfig {
17-
defaultLocale: string
18-
localeDetection?: false
19-
locales: string[]
20-
}
21-
22-
export interface RequestData {
23-
geo?: {
24-
city?: string
25-
country?: string
26-
region?: string
27-
latitude?: string
28-
longitude?: string
29-
timezone?: string
30-
}
31-
headers: Record<string, string>
32-
ip?: string
33-
method: string
34-
nextConfig?: {
35-
basePath?: string
36-
i18n?: I18NConfig | null
37-
trailingSlash?: boolean
38-
}
39-
page?: {
40-
name?: string
41-
params?: { [key: string]: string }
42-
}
43-
url: string
44-
body?: ReadableStream<Uint8Array>
45-
}
46-
4711
export interface RequestContext {
4812
request: Request
4913
context: Context
@@ -70,15 +34,6 @@ const handler = async (req: Request, context: Context) => {
7034
return
7135
}
7236

73-
const geo: RequestData['geo'] = {
74-
country: context.geo.country?.code,
75-
region: context.geo.subdivision?.code,
76-
city: context.geo.city,
77-
latitude: context.geo.latitude?.toString(),
78-
longitude: context.geo.longitude?.toString(),
79-
timezone: context.geo.timezone,
80-
}
81-
8237
const requestId = req.headers.get('x-nf-request-id')
8338
if (!requestId) {
8439
console.error('Missing x-nf-request-id header')
@@ -89,15 +44,7 @@ const handler = async (req: Request, context: Context) => {
8944
})
9045
}
9146

92-
const request: RequestData = {
93-
headers: Object.fromEntries(req.headers.entries()),
94-
geo,
95-
url: req.url,
96-
method: req.method,
97-
ip: context.ip,
98-
body: req.body ?? undefined,
99-
nextConfig,
100-
}
47+
const request = buildNextRequest(req, context, nextConfig)
10148

10249
try {
10350
const result = await edgeFunction({ request })

0 commit comments

Comments
 (0)