Skip to content

Commit 4035cca

Browse files
authored
feat: add router support (#64)
1 parent 5dd59f1 commit 4035cca

File tree

11 files changed

+162
-12
lines changed

11 files changed

+162
-12
lines changed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
✔️  **Extendable:** Ships with a set of composable utilities but can be extended
2323

24+
✔️  **Router:** Super fast route matching using [unjs/radix3](https://github.com/unjs/radix3)
25+
2426
## Install
2527

2628
```bash
@@ -57,7 +59,29 @@ listen(app)
5759
```
5860
</details>
5961

60-
## Examples
62+
## Router
63+
64+
The `app` instance created by `h3` uses a middleware stack (see [how it works](#how-it-works)) with the ability to match route prefix and apply matched middleware.
65+
66+
To opt-in using a more advanced and convenient routing system, we can create a router instance and register it to app instance.
67+
68+
```ts
69+
import { createApp, createRouter } from 'h3'
70+
71+
const app = createApp()
72+
73+
const router = createRouter()
74+
.get('/', () => 'Hello World!')
75+
.get('/hello/:name', req => `Hello ${req.params.name}!`)
76+
77+
app.use(router)
78+
```
79+
80+
**Tip:** We can register same route more than once with different methods.
81+
82+
Routes are internally stored in a [Radix Tree](https://en.wikipedia.org/wiki/Radix_tree) and matched using [unjs/radix3](https://github.com/unjs/radix3).
83+
84+
## More usage examples
6185

6286
```js
6387
// Handle can directly return object or Promise<object> for JSON response

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"cookie": "^0.4.2",
3030
"destr": "^1.1.0",
31+
"radix3": "^0.1.1",
3132
"ufo": "^0.7.11"
3233
},
3334
"devDependencies": {

playground/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { listen } from 'listhen'
2-
import { createApp } from '../src'
2+
import { createApp, createRouter } from '../src'
33

4-
const app = createApp({ debug: true })
4+
const app = createApp()
5+
const router = createRouter()
6+
.get('/', () => 'Hello World!')
7+
.get('/hello/:name', req => `Hello ${req.params.name}!`)
58

6-
app.use('/', () => {
7-
// throw new Error('Foo bar')
8-
return 'Hi!'
9-
})
9+
app.use(router)
1010

1111
listen(app)

src/handle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { withoutTrailingSlash, withoutBase } from 'ufo'
22
import type { IncomingMessage, ServerResponse } from './types/node'
33

4-
export type Handle<T = any> = (req: IncomingMessage, res: ServerResponse) => T
4+
export type Handle<T = any, ReqT={}> = (req: IncomingMessage & ReqT, res: ServerResponse) => T
55
export type PHandle = Handle<Promise<any>>
66
export type Middleware = (req: IncomingMessage, res: ServerResponse, next: (err?: Error) => any) => any
77
export type LazyHandle = () => Handle | Promise<Handle>

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './app'
22
export * from './error'
33
export * from './handle'
44
export * from './utils'
5+
export * from './router'

src/router.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { createRouter as _createRouter } from 'radix3'
2+
import type { Handle } from './handle'
3+
import type { HTTPMethod } from './types/http'
4+
import { createError } from './error'
5+
6+
export type RouterMethod = Lowercase<HTTPMethod>
7+
const RouterMethods: Lowercase<RouterMethod>[] = ['connect', 'delete', 'get', 'head', 'options', 'post', 'put', 'trace']
8+
9+
export type HandleWithParams = Handle<any, { params: Record<string, string> }>
10+
11+
export type AddWithMethod = (path: string, handle: HandleWithParams) => Router
12+
export type AddRouteShortcuts = Record<Lowercase<HTTPMethod>, AddWithMethod>
13+
14+
export interface Router extends AddRouteShortcuts {
15+
add: (path: string, handle: HandleWithParams, method?: RouterMethod | 'all') => Router
16+
handle: Handle
17+
}
18+
19+
interface RouteNode {
20+
handlers: Partial<Record<RouterMethod| 'all', HandleWithParams>>
21+
}
22+
23+
export function createRouter (): Router {
24+
const _router = _createRouter<RouteNode>({})
25+
const routes: Record<string, RouteNode> = {}
26+
27+
const router: Router = {} as Router
28+
29+
// Utilities to add a new route
30+
router.add = (path, handle, method = 'all') => {
31+
let route = routes[path]
32+
if (!route) {
33+
routes[path] = route = { handlers: {} }
34+
_router.insert(path, route)
35+
}
36+
route.handlers[method] = handle
37+
return router
38+
}
39+
for (const method of RouterMethods) {
40+
router[method] = (path, handle) => router.add(path, handle, method)
41+
}
42+
43+
// Main handle
44+
router.handle = (req, res) => {
45+
// Match route
46+
const matched = _router.lookup(req.url || '/')
47+
if (!matched) {
48+
throw createError({
49+
statusCode: 404,
50+
name: 'Not Found',
51+
statusMessage: `Cannot find any route matching ${req.url || '/'}.`
52+
})
53+
}
54+
55+
// Match method
56+
const method = (req.method || 'get').toLowerCase() as RouterMethod
57+
const handler: HandleWithParams | undefined = matched.handlers[method] || matched.handlers.all
58+
if (!handler) {
59+
throw createError({
60+
statusCode: 405,
61+
name: 'Method Not Allowed',
62+
statusMessage: `Method ${method} is not allowed on this route.`
63+
})
64+
}
65+
66+
// Add params
67+
// @ts-ignore
68+
req.params = matched.params || {}
69+
70+
// Call handler
71+
// @ts-ignore
72+
return handler(req, res)
73+
}
74+
75+
return router
76+
}

src/types/http.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// https://www.rfc-editor.org/rfc/rfc7231#section-4.1
2+
export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE'

src/utils/body.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { IncomingMessage } from 'http'
22
import destr from 'destr'
33
import type { Encoding } from '../types/node'
4-
import { HTTPMethod, assertMethod } from './request'
4+
import type { HTTPMethod } from '../types/http'
5+
import { assertMethod } from './request'
56

67
const RawBodySymbol = Symbol('h3RawBody')
78
const ParsedBodySymbol = Symbol('h3RawBody')

src/utils/request.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import type { IncomingMessage } from 'http'
22
import { getQuery } from 'ufo'
33
import { createError } from '../error'
4+
import type { HTTPMethod } from '../types/http'
45

56
export function useQuery (req: IncomingMessage) {
67
return getQuery(req.url || '')
78
}
89

9-
// https://www.rfc-editor.org/rfc/rfc7231#section-4.1
10-
export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE'
11-
1210
export function useMethod (req: IncomingMessage, defaultMethod: HTTPMethod = 'GET'): HTTPMethod {
1311
return (req.method || defaultMethod).toUpperCase() as HTTPMethod
1412
}

test/router.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import supertest, { SuperTest, Test } from 'supertest'
2+
import { describe, it, expect, beforeEach } from 'vitest'
3+
import { createApp, createRouter, App, Router } from '../src'
4+
5+
describe('router', () => {
6+
let app: App
7+
let router: Router
8+
let request: SuperTest<Test>
9+
10+
beforeEach(() => {
11+
app = createApp({ debug: false })
12+
router = createRouter()
13+
.add('/', () => 'Hello')
14+
.get('/test', () => 'Test (GET)')
15+
.post('/test', () => 'Test (POST)')
16+
17+
app.use(router)
18+
request = supertest(app)
19+
})
20+
21+
it('Handle route', async () => {
22+
const res = await request.get('/')
23+
expect(res.text).toEqual('Hello')
24+
})
25+
26+
it('Handle different methods', async () => {
27+
const res1 = await request.get('/test')
28+
expect(res1.text).toEqual('Test (GET)')
29+
const res2 = await request.post('/test')
30+
expect(res2.text).toEqual('Test (POST)')
31+
})
32+
33+
it('Not matching route', async () => {
34+
const res = await request.get('/404')
35+
expect(res.status).toEqual(404)
36+
})
37+
38+
it('Not matching route method', async () => {
39+
const res = await request.head('/test')
40+
expect(res.status).toEqual(405)
41+
})
42+
})

0 commit comments

Comments
 (0)