Skip to content

Commit 034223f

Browse files
authored
feat(trailing-slash): add alwaysRedirect option to support wildcard routes (#4658)
* feat(trailing-slash): add `strict` option to support wildcard routes * use `eager` instead of `strict` * replace `eager` to `alwaysRedirect`
1 parent 16321af commit 034223f

File tree

2 files changed

+202
-2
lines changed

2 files changed

+202
-2
lines changed

src/middleware/trailing-slash/index.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,73 @@ describe('Resolve trailing slash', () => {
8787
})
8888
})
8989

90+
describe('trimTrailingSlash middleware with alwaysRedirect option', () => {
91+
const app = new Hono()
92+
app.use('*', trimTrailingSlash({ alwaysRedirect: true }))
93+
94+
app.get('/', async (c) => {
95+
return c.text('ok')
96+
})
97+
app.get('/my-path/*', async (c) => {
98+
return c.text('wildcard')
99+
})
100+
app.get('/exact-path', async (c) => {
101+
return c.text('exact')
102+
})
103+
104+
it('should handle GET request for root path correctly', async () => {
105+
const resp = await app.request('/')
106+
107+
expect(resp).not.toBeNull()
108+
expect(resp.status).toBe(200)
109+
})
110+
111+
it('should redirect wildcard route with trailing slash', async () => {
112+
const resp = await app.request('/my-path/something/else/')
113+
const loc = new URL(resp.headers.get('location')!)
114+
115+
expect(resp).not.toBeNull()
116+
expect(resp.status).toBe(301)
117+
expect(loc.pathname).toBe('/my-path/something/else')
118+
})
119+
120+
it('should not redirect wildcard route without trailing slash', async () => {
121+
const resp = await app.request('/my-path/something/else')
122+
123+
expect(resp).not.toBeNull()
124+
expect(resp.status).toBe(200)
125+
expect(await resp.text()).toBe('wildcard')
126+
})
127+
128+
it('should redirect exact route with trailing slash', async () => {
129+
const resp = await app.request('/exact-path/')
130+
const loc = new URL(resp.headers.get('location')!)
131+
132+
expect(resp).not.toBeNull()
133+
expect(resp.status).toBe(301)
134+
expect(loc.pathname).toBe('/exact-path')
135+
})
136+
137+
it('should preserve query parameters when redirecting', async () => {
138+
const resp = await app.request('/my-path/something/?param=1')
139+
const loc = new URL(resp.headers.get('location')!)
140+
141+
expect(resp).not.toBeNull()
142+
expect(resp.status).toBe(301)
143+
expect(loc.pathname).toBe('/my-path/something')
144+
expect(loc.searchParams.get('param')).toBe('1')
145+
})
146+
147+
it('should handle HEAD request for wildcard route with trailing slash', async () => {
148+
const resp = await app.request('/my-path/something/', { method: 'HEAD' })
149+
const loc = new URL(resp.headers.get('location')!)
150+
151+
expect(resp).not.toBeNull()
152+
expect(resp.status).toBe(301)
153+
expect(loc.pathname).toBe('/my-path/something')
154+
})
155+
})
156+
90157
describe('appendTrailingSlash middleware', () => {
91158
const app = new Hono({ strict: true })
92159
app.use('*', appendTrailingSlash())
@@ -187,4 +254,71 @@ describe('Resolve trailing slash', () => {
187254
expect(loc.searchParams.get('exampleParam')).toBe('1')
188255
})
189256
})
257+
258+
describe('appendTrailingSlash middleware with alwaysRedirect option', () => {
259+
const app = new Hono()
260+
app.use('*', appendTrailingSlash({ alwaysRedirect: true }))
261+
262+
app.get('/', async (c) => {
263+
return c.text('ok')
264+
})
265+
app.get('/my-path/*', async (c) => {
266+
return c.text('wildcard')
267+
})
268+
app.get('/exact-path/', async (c) => {
269+
return c.text('exact')
270+
})
271+
272+
it('should handle GET request for root path correctly', async () => {
273+
const resp = await app.request('/')
274+
275+
expect(resp).not.toBeNull()
276+
expect(resp.status).toBe(200)
277+
})
278+
279+
it('should redirect wildcard route without trailing slash', async () => {
280+
const resp = await app.request('/my-path/something/else')
281+
const loc = new URL(resp.headers.get('location')!)
282+
283+
expect(resp).not.toBeNull()
284+
expect(resp.status).toBe(301)
285+
expect(loc.pathname).toBe('/my-path/something/else/')
286+
})
287+
288+
it('should not redirect wildcard route with trailing slash', async () => {
289+
const resp = await app.request('/my-path/something/else/')
290+
291+
expect(resp).not.toBeNull()
292+
expect(resp.status).toBe(200)
293+
expect(await resp.text()).toBe('wildcard')
294+
})
295+
296+
it('should redirect exact route without trailing slash', async () => {
297+
const resp = await app.request('/exact-path')
298+
const loc = new URL(resp.headers.get('location')!)
299+
300+
expect(resp).not.toBeNull()
301+
expect(resp.status).toBe(301)
302+
expect(loc.pathname).toBe('/exact-path/')
303+
})
304+
305+
it('should preserve query parameters when redirecting', async () => {
306+
const resp = await app.request('/my-path/something?param=1')
307+
const loc = new URL(resp.headers.get('location')!)
308+
309+
expect(resp).not.toBeNull()
310+
expect(resp.status).toBe(301)
311+
expect(loc.pathname).toBe('/my-path/something/')
312+
expect(loc.searchParams.get('param')).toBe('1')
313+
})
314+
315+
it('should handle HEAD request for wildcard route without trailing slash', async () => {
316+
const resp = await app.request('/my-path/something', { method: 'HEAD' })
317+
const loc = new URL(resp.headers.get('location')!)
318+
319+
expect(resp).not.toBeNull()
320+
expect(resp.status).toBe(301)
321+
expect(loc.pathname).toBe('/my-path/something/')
322+
})
323+
})
190324
})

src/middleware/trailing-slash/index.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@
55

66
import type { MiddlewareHandler } from '../../types'
77

8+
type TrimTrailingSlashOptions = {
9+
/**
10+
* If `true`, the middleware will always redirect requests with a trailing slash
11+
* before executing handlers.
12+
* This is useful for routes with wildcards (`*`).
13+
* If `false` (default), it will only redirect when the route is not found (404).
14+
* @default false
15+
*/
16+
alwaysRedirect?: boolean
17+
}
18+
819
/**
920
* Trailing Slash Middleware for Hono.
1021
*
1122
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
1223
*
24+
* @param {TrimTrailingSlashOptions} options - The options for the middleware.
1325
* @returns {MiddlewareHandler} The middleware handler function.
1426
*
1527
* @example
@@ -19,12 +31,35 @@ import type { MiddlewareHandler } from '../../types'
1931
* app.use(trimTrailingSlash())
2032
* app.get('/about/me/', (c) => c.text('With Trailing Slash'))
2133
* ```
34+
*
35+
* @example
36+
* ```ts
37+
* // With alwaysRedirect option for wildcard routes
38+
* const app = new Hono()
39+
*
40+
* app.use(trimTrailingSlash({ alwaysRedirect: true }))
41+
* app.get('/my-path/*', (c) => c.text('Wildcard route'))
42+
* ```
2243
*/
23-
export const trimTrailingSlash = (): MiddlewareHandler => {
44+
export const trimTrailingSlash = (options?: TrimTrailingSlashOptions): MiddlewareHandler => {
2445
return async function trimTrailingSlash(c, next) {
46+
if (options?.alwaysRedirect) {
47+
if (
48+
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
49+
c.req.path !== '/' &&
50+
c.req.path.at(-1) === '/'
51+
) {
52+
const url = new URL(c.req.url)
53+
url.pathname = url.pathname.substring(0, url.pathname.length - 1)
54+
55+
return c.redirect(url.toString(), 301)
56+
}
57+
}
58+
2559
await next()
2660

2761
if (
62+
!options?.alwaysRedirect &&
2863
c.res.status === 404 &&
2964
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
3065
c.req.path !== '/' &&
@@ -38,12 +73,24 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
3873
}
3974
}
4075

76+
type AppendTrailingSlashOptions = {
77+
/**
78+
* If `true`, the middleware will always redirect requests without a trailing slash
79+
* before executing handlers.
80+
* This is useful for routes with wildcards (`*`).
81+
* If `false` (default), it will only redirect when the route is not found (404).
82+
* @default false
83+
*/
84+
alwaysRedirect?: boolean
85+
}
86+
4187
/**
4288
* Append trailing slash middleware for Hono.
4389
* Append a trailing slash to the URL if it doesn't have one. For example, `/path/to/page` will be redirected to `/path/to/page/`.
4490
*
4591
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
4692
*
93+
* @param {AppendTrailingSlashOptions} options - The options for the middleware.
4794
* @returns {MiddlewareHandler} The middleware handler function.
4895
*
4996
* @example
@@ -52,12 +99,31 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
5299
*
53100
* app.use(appendTrailingSlash())
54101
* ```
102+
*
103+
* @example
104+
* ```ts
105+
* // With alwaysRedirect option for wildcard routes
106+
* const app = new Hono()
107+
*
108+
* app.use(appendTrailingSlash({ alwaysRedirect: true }))
109+
* app.get('/my-path/*', (c) => c.text('Wildcard route'))
110+
* ```
55111
*/
56-
export const appendTrailingSlash = (): MiddlewareHandler => {
112+
export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): MiddlewareHandler => {
57113
return async function appendTrailingSlash(c, next) {
114+
if (options?.alwaysRedirect) {
115+
if ((c.req.method === 'GET' || c.req.method === 'HEAD') && c.req.path.at(-1) !== '/') {
116+
const url = new URL(c.req.url)
117+
url.pathname += '/'
118+
119+
return c.redirect(url.toString(), 301)
120+
}
121+
}
122+
58123
await next()
59124

60125
if (
126+
!options?.alwaysRedirect &&
61127
c.res.status === 404 &&
62128
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
63129
c.req.path.at(-1) !== '/'

0 commit comments

Comments
 (0)