Skip to content

Commit 15fa50c

Browse files
authored
Merge 8bae958 into 69ad885
2 parents 69ad885 + 8bae958 commit 15fa50c

File tree

4 files changed

+305
-0
lines changed

4 files changed

+305
-0
lines changed

jsr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"./pretty-json": "./src/middleware/pretty-json/index.ts",
5151
"./request-id": "./src/middleware/request-id/request-id.ts",
5252
"./language": "./src/middleware/language/language.ts",
53+
"./route-check": "./src/middleware/route-check/index.ts",
5354
"./secure-headers": "./src/middleware/secure-headers/secure-headers.ts",
5455
"./combine": "./src/middleware/combine/index.ts",
5556
"./ssg": "./src/helper/ssg/index.ts",

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,11 @@
255255
"import": "./dist/middleware/language/index.js",
256256
"require": "./dist/cjs/middleware/language/index.js"
257257
},
258+
"./route-check": {
259+
"types": "./dist/types/middleware/route-check/index.d.ts",
260+
"import": "./dist/middleware/route-check/index.js",
261+
"require": "./dist/cjs/middleware/route-check/index.js"
262+
},
258263
"./secure-headers": {
259264
"types": "./dist/types/middleware/secure-headers/index.d.ts",
260265
"import": "./dist/middleware/secure-headers/index.js",
@@ -539,6 +544,9 @@
539544
"ssg": [
540545
"./dist/types/helper/ssg"
541546
],
547+
"route-check": [
548+
"./dist/types/middleware/route-check"
549+
],
542550
"secure-headers": [
543551
"./dist/types/middleware/secure-headers"
544552
],
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { Hono } from '../../hono'
2+
import { bearerAuth } from '../bearer-auth'
3+
import { routeCheck } from '.'
4+
5+
describe('Route Check Middleware', () => {
6+
let app: Hono
7+
8+
beforeEach(() => {
9+
app = new Hono()
10+
})
11+
12+
describe('Basic functionality', () => {
13+
it('Should allow access to existing routes', async () => {
14+
app.use('/admin/*', routeCheck())
15+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
16+
17+
const res = await app.request('/admin/dashboard')
18+
expect(res.status).toBe(200)
19+
expect(await res.text()).toBe('Admin Dashboard')
20+
})
21+
22+
it('Should return 404 for non-existent routes', async () => {
23+
app.use('/admin/*', routeCheck())
24+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
25+
26+
const res = await app.request('/admin/non-existent')
27+
expect(res.status).toBe(404)
28+
expect(await res.text()).toBe('404 Not Found')
29+
})
30+
31+
it('Should work with POST routes', async () => {
32+
app.use('/api/*', routeCheck())
33+
app.post('/api/users', (c) => c.text('User Created'))
34+
35+
const res = await app.request('/api/users', { method: 'POST' })
36+
expect(res.status).toBe(200)
37+
expect(await res.text()).toBe('User Created')
38+
})
39+
40+
it('Should return 404 for non-existent POST routes', async () => {
41+
app.use('/api/*', routeCheck())
42+
app.post('/api/users', (c) => c.text('User Created'))
43+
44+
const res = await app.request('/api/posts', { method: 'POST' })
45+
expect(res.status).toBe(404)
46+
})
47+
})
48+
49+
describe('Integration with authentication middleware', () => {
50+
it('Should skip authentication for non-existent routes', async () => {
51+
let authExecuted = false
52+
53+
app.use('/admin/*', routeCheck())
54+
app.use('/admin/*', async (c, next) => {
55+
authExecuted = true
56+
await next()
57+
})
58+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
59+
60+
const res = await app.request('/admin/non-existent')
61+
expect(res.status).toBe(404)
62+
expect(authExecuted).toBe(false)
63+
})
64+
65+
it('Should execute authentication for existing routes', async () => {
66+
let authExecuted = false
67+
68+
app.use('/admin/*', routeCheck())
69+
app.use('/admin/*', async (c, next) => {
70+
authExecuted = true
71+
await next()
72+
})
73+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
74+
75+
const res = await app.request('/admin/dashboard')
76+
expect(res.status).toBe(200)
77+
expect(authExecuted).toBe(true)
78+
})
79+
80+
it('Should work with bearerAuth middleware', async () => {
81+
app.use('/admin/*', routeCheck())
82+
app.use('/admin/*', bearerAuth({ token: 'my-secret' }))
83+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
84+
85+
// Non-existent route should return 404 without auth
86+
const res1 = await app.request('/admin/non-existent')
87+
expect(res1.status).toBe(404)
88+
89+
// Existing route without auth should return 401
90+
const res2 = await app.request('/admin/dashboard')
91+
expect(res2.status).toBe(401)
92+
93+
// Existing route with auth should return 200
94+
const res3 = await app.request('/admin/dashboard', {
95+
headers: { Authorization: 'Bearer my-secret' },
96+
})
97+
expect(res3.status).toBe(200)
98+
expect(await res3.text()).toBe('Admin Dashboard')
99+
})
100+
})
101+
102+
describe('Support for different route definitions', () => {
103+
it('Should work with app.all()', async () => {
104+
app.use('/admin/*', routeCheck())
105+
app.all('/admin/settings', (c) => c.text('Settings'))
106+
107+
const res1 = await app.request('/admin/settings', { method: 'GET' })
108+
expect(res1.status).toBe(200)
109+
expect(await res1.text()).toBe('Settings')
110+
111+
const res2 = await app.request('/admin/settings', { method: 'POST' })
112+
expect(res2.status).toBe(200)
113+
expect(await res2.text()).toBe('Settings')
114+
})
115+
116+
it('Should distinguish between handlers and middleware', async () => {
117+
app.use('/api/*', routeCheck())
118+
app.use('/api/*', async (c, next) => {
119+
// This is middleware, not a handler
120+
await next()
121+
})
122+
app.get('/api/users', (c) => c.text('Users'))
123+
124+
// Should return 200 because handler exists
125+
const res1 = await app.request('/api/users')
126+
expect(res1.status).toBe(200)
127+
128+
// Should return 404 because no handler exists
129+
const res2 = await app.request('/api/posts')
130+
expect(res2.status).toBe(404)
131+
})
132+
133+
it('Should work with multiple HTTP methods on same path', async () => {
134+
app.use('/api/*', routeCheck())
135+
app.get('/api/users', (c) => c.text('Get Users'))
136+
app.post('/api/users', (c) => c.text('Create User'))
137+
138+
const res1 = await app.request('/api/users', { method: 'GET' })
139+
expect(res1.status).toBe(200)
140+
expect(await res1.text()).toBe('Get Users')
141+
142+
const res2 = await app.request('/api/users', { method: 'POST' })
143+
expect(res2.status).toBe(200)
144+
expect(await res2.text()).toBe('Create User')
145+
146+
// PUT should return 404 as no handler exists
147+
const res3 = await app.request('/api/users', { method: 'PUT' })
148+
expect(res3.status).toBe(404)
149+
})
150+
})
151+
152+
describe('Custom onNotFound handler', () => {
153+
it('Should use custom handler when route not found', async () => {
154+
app.use(
155+
'/api/*',
156+
routeCheck({
157+
onNotFound: (c) => c.json({ error: 'API endpoint not found' }, 404),
158+
})
159+
)
160+
app.get('/api/users', (c) => c.text('Users'))
161+
162+
const res = await app.request('/api/posts')
163+
expect(res.status).toBe(404)
164+
expect(await res.json()).toEqual({ error: 'API endpoint not found' })
165+
})
166+
167+
it('Should use default handler when onNotFound not specified', async () => {
168+
app.use('/api/*', routeCheck())
169+
app.get('/api/users', (c) => c.text('Users'))
170+
171+
const res = await app.request('/api/posts')
172+
expect(res.status).toBe(404)
173+
expect(await res.text()).toBe('404 Not Found')
174+
})
175+
176+
it('Should not call onNotFound when route exists', async () => {
177+
let onNotFoundCalled = false
178+
179+
app.use(
180+
'/api/*',
181+
routeCheck({
182+
onNotFound: (c) => {
183+
onNotFoundCalled = true
184+
return c.json({ error: 'Not found' }, 404)
185+
},
186+
})
187+
)
188+
app.get('/api/users', (c) => c.text('Users'))
189+
190+
const res = await app.request('/api/users')
191+
expect(res.status).toBe(200)
192+
expect(onNotFoundCalled).toBe(false)
193+
})
194+
})
195+
196+
describe('Edge cases', () => {
197+
it('Should work without any routes defined', async () => {
198+
app.use('/admin/*', routeCheck())
199+
200+
const res = await app.request('/admin/anything')
201+
expect(res.status).toBe(404)
202+
})
203+
204+
it('Should work with nested paths', async () => {
205+
app.use('/api/v1/*', routeCheck())
206+
app.get('/api/v1/users/:id', (c) => c.text(`User ${c.req.param('id')}`))
207+
208+
const res1 = await app.request('/api/v1/users/123')
209+
expect(res1.status).toBe(200)
210+
expect(await res1.text()).toBe('User 123')
211+
212+
const res2 = await app.request('/api/v1/posts/123')
213+
expect(res2.status).toBe(404)
214+
})
215+
216+
it('Should work when placed after some middleware', async () => {
217+
let middleware1Executed = false
218+
219+
app.use('/admin/*', async (c, next) => {
220+
middleware1Executed = true
221+
await next()
222+
})
223+
app.use('/admin/*', routeCheck())
224+
app.get('/admin/dashboard', (c) => c.text('Admin Dashboard'))
225+
226+
const res = await app.request('/admin/non-existent')
227+
expect(res.status).toBe(404)
228+
expect(middleware1Executed).toBe(true)
229+
})
230+
})
231+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @module
3+
* Route Check Middleware for Hono.
4+
*/
5+
6+
import type { Context } from '../../context'
7+
import { matchedRoutes } from '../../helper/route'
8+
import type { MiddlewareHandler } from '../../types'
9+
import { findTargetHandler, isMiddleware } from '../../utils/handler'
10+
11+
type RouteCheckOptions = {
12+
/**
13+
* Custom handler to execute when no route is found.
14+
* If not specified, returns default 404 response.
15+
*/
16+
onNotFound?: (c: Context) => Response | Promise<Response>
17+
}
18+
19+
/**
20+
* Route Check Middleware for Hono.
21+
*
22+
* Checks if a route handler exists before executing subsequent middleware.
23+
* Returns 404 immediately for non-existent routes, skipping expensive operations
24+
* like authentication.
25+
*
26+
* @param {RouteCheckOptions} options - Configuration options.
27+
* @returns {MiddlewareHandler} The middleware handler function.
28+
*
29+
* @example
30+
* ```ts
31+
* app.use('/admin/*', routeCheck())
32+
* app.use('/admin/*', bearerAuth({ token: 'my-secret' }))
33+
*
34+
* app.get('/admin/dashboard', (c) => c.text('Dashboard'))
35+
*
36+
* // GET /admin/non-existent returns 404 without authentication
37+
* // GET /admin/dashboard requires authentication
38+
* ```
39+
*
40+
* @example
41+
* With custom not found handler:
42+
* ```ts
43+
* app.use('/api/*', routeCheck({
44+
* onNotFound: (c) => c.json({ error: 'Not Found' }, 404)
45+
* }))
46+
* ```
47+
*/
48+
export const routeCheck = (options?: RouteCheckOptions): MiddlewareHandler => {
49+
return async (c, next) => {
50+
const routes = matchedRoutes(c)
51+
52+
// Check if there's at least one actual handler (not middleware)
53+
const hasActualHandler = routes.some((route) => {
54+
const targetHandler = findTargetHandler(route.handler)
55+
return !isMiddleware(targetHandler)
56+
})
57+
58+
if (!hasActualHandler) {
59+
// No actual handler found - return 404 immediately
60+
return options?.onNotFound ? options.onNotFound(c) : c.notFound()
61+
}
62+
63+
await next()
64+
}
65+
}

0 commit comments

Comments
 (0)