Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/middleware/trailing-slash/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,73 @@ describe('Resolve trailing slash', () => {
})
})

describe('trimTrailingSlash middleware with strict option', () => {
const app = new Hono()
app.use('*', trimTrailingSlash({ strict: true }))

app.get('/', async (c) => {
return c.text('ok')
})
app.get('/my-path/*', async (c) => {
return c.text('wildcard')
})
app.get('/exact-path', async (c) => {
return c.text('exact')
})

it('should handle GET request for root path correctly', async () => {
const resp = await app.request('/')

expect(resp).not.toBeNull()
expect(resp.status).toBe(200)
})

it('should redirect wildcard route with trailing slash', async () => {
const resp = await app.request('/my-path/something/else/')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something/else')
})

it('should not redirect wildcard route without trailing slash', async () => {
const resp = await app.request('/my-path/something/else')

expect(resp).not.toBeNull()
expect(resp.status).toBe(200)
expect(await resp.text()).toBe('wildcard')
})

it('should redirect exact route with trailing slash', async () => {
const resp = await app.request('/exact-path/')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/exact-path')
})

it('should preserve query parameters when redirecting', async () => {
const resp = await app.request('/my-path/something/?param=1')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something')
expect(loc.searchParams.get('param')).toBe('1')
})

it('should handle HEAD request for wildcard route with trailing slash', async () => {
const resp = await app.request('/my-path/something/', { method: 'HEAD' })
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something')
})
})

describe('appendTrailingSlash middleware', () => {
const app = new Hono({ strict: true })
app.use('*', appendTrailingSlash())
Expand Down Expand Up @@ -187,4 +254,71 @@ describe('Resolve trailing slash', () => {
expect(loc.searchParams.get('exampleParam')).toBe('1')
})
})

describe('appendTrailingSlash middleware with strict option', () => {
const app = new Hono()
app.use('*', appendTrailingSlash({ strict: true }))

app.get('/', async (c) => {
return c.text('ok')
})
app.get('/my-path/*', async (c) => {
return c.text('wildcard')
})
app.get('/exact-path/', async (c) => {
return c.text('exact')
})

it('should handle GET request for root path correctly', async () => {
const resp = await app.request('/')

expect(resp).not.toBeNull()
expect(resp.status).toBe(200)
})

it('should redirect wildcard route without trailing slash', async () => {
const resp = await app.request('/my-path/something/else')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something/else/')
})

it('should not redirect wildcard route with trailing slash', async () => {
const resp = await app.request('/my-path/something/else/')

expect(resp).not.toBeNull()
expect(resp.status).toBe(200)
expect(await resp.text()).toBe('wildcard')
})

it('should redirect exact route without trailing slash', async () => {
const resp = await app.request('/exact-path')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/exact-path/')
})

it('should preserve query parameters when redirecting', async () => {
const resp = await app.request('/my-path/something?param=1')
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something/')
expect(loc.searchParams.get('param')).toBe('1')
})

it('should handle HEAD request for wildcard route without trailing slash', async () => {
const resp = await app.request('/my-path/something', { method: 'HEAD' })
const loc = new URL(resp.headers.get('location')!)

expect(resp).not.toBeNull()
expect(resp.status).toBe(301)
expect(loc.pathname).toBe('/my-path/something/')
})
})
})
68 changes: 66 additions & 2 deletions src/middleware/trailing-slash/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@

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

type TrimTrailingSlashOptions = {
/**
* If `true`, the middleware will always redirect requests with a trailing slash.
* This is useful for routes with wildcards (`*`).
* If `false` (default), it will only redirect when the route is not found (404).
* @default false
*/
strict?: boolean
}

/**
* Trailing Slash Middleware for Hono.
*
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
*
* @param {TrimTrailingSlashOptions} options - The options for the middleware.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
Expand All @@ -19,12 +30,35 @@ import type { MiddlewareHandler } from '../../types'
* app.use(trimTrailingSlash())
* app.get('/about/me/', (c) => c.text('With Trailing Slash'))
* ```
*
* @example
* ```ts
* // With strict option for wildcard routes
* const app = new Hono()
*
* app.use(trimTrailingSlash({ strict: true }))
* app.get('/my-path/*', (c) => c.text('Wildcard route'))
* ```
*/
export const trimTrailingSlash = (): MiddlewareHandler => {
export const trimTrailingSlash = (options?: TrimTrailingSlashOptions): MiddlewareHandler => {
return async function trimTrailingSlash(c, next) {
if (options?.strict) {
if (
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
c.req.path !== '/' &&
c.req.path.at(-1) === '/'
) {
const url = new URL(c.req.url)
url.pathname = url.pathname.substring(0, url.pathname.length - 1)

return c.redirect(url.toString(), 301)
}
}

await next()

if (
!options?.strict &&
c.res.status === 404 &&
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
c.req.path !== '/' &&
Expand All @@ -38,12 +72,23 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
}
}

type AppendTrailingSlashOptions = {
/**
* If `true`, the middleware will always redirect requests without a trailing slash.
* This is useful for routes with wildcards (`*`).
* If `false` (default), it will only redirect when the route is not found (404).
* @default false
*/
strict?: boolean
}

/**
* Append trailing slash middleware for Hono.
* 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/`.
*
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
*
* @param {AppendTrailingSlashOptions} options - The options for the middleware.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
Expand All @@ -52,12 +97,31 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
*
* app.use(appendTrailingSlash())
* ```
*
* @example
* ```ts
* // With strict option for wildcard routes
* const app = new Hono()
*
* app.use(appendTrailingSlash({ strict: true }))
* app.get('/my-path/*', (c) => c.text('Wildcard route'))
* ```
*/
export const appendTrailingSlash = (): MiddlewareHandler => {
export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): MiddlewareHandler => {
return async function appendTrailingSlash(c, next) {
if (options?.strict) {
if ((c.req.method === 'GET' || c.req.method === 'HEAD') && c.req.path.at(-1) !== '/') {
const url = new URL(c.req.url)
url.pathname += '/'

return c.redirect(url.toString(), 301)
}
}

await next()

if (
!options?.strict &&
c.res.status === 404 &&
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
c.req.path.at(-1) !== '/'
Expand Down
Loading