From c7d4d3a68a2e664a496256e644c0be8bae161f8c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 9 Mar 2022 21:34:46 +0100 Subject: [PATCH 1/5] feat: add router --- package.json | 1 + playground/index.ts | 11 ++++--- src/handle.ts | 2 +- src/index.ts | 1 + src/router.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ src/types/http.ts | 2 ++ src/utils/request.ts | 4 +-- yarn.lock | 5 +++ 8 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/router.ts create mode 100644 src/types/http.ts diff --git a/package.json b/package.json index ffc82038..5aeed9cb 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "cookie": "^0.4.2", "destr": "^1.1.0", + "radix3": "^0.1.0", "ufo": "^0.7.11" }, "devDependencies": { diff --git a/playground/index.ts b/playground/index.ts index c3aded40..2cd5ec2a 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,11 +1,12 @@ import { listen } from 'listhen' -import { createApp } from '../src' +import { createApp, createRouter } from '../src' const app = createApp({ debug: true }) +const router = createRouter() -app.use('/', () => { - // throw new Error('Foo bar') - return 'Hi!' -}) +app.use(router) + +router.get('/', () => 'Hello World!') +router.get('/hello/:name', req => `Hello ${req.params.name}!`) listen(app) diff --git a/src/handle.ts b/src/handle.ts index c4948d33..0425db82 100644 --- a/src/handle.ts +++ b/src/handle.ts @@ -1,7 +1,7 @@ import { withoutTrailingSlash, withoutBase } from 'ufo' import type { IncomingMessage, ServerResponse } from './types/node' -export type Handle = (req: IncomingMessage, res: ServerResponse) => T +export type Handle = (req: IncomingMessage & ReqT, res: ServerResponse) => T export type PHandle = Handle> export type Middleware = (req: IncomingMessage, res: ServerResponse, next: (err?: Error) => any) => any export type LazyHandle = () => Handle | Promise diff --git a/src/index.ts b/src/index.ts index 2ad3b201..14deb221 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './app' export * from './error' export * from './handle' export * from './utils' +export * from './router' diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 00000000..bee1179e --- /dev/null +++ b/src/router.ts @@ -0,0 +1,76 @@ +import { createRouter as _createRouter } from 'radix3' +import type { Handle } from './handle' +import type { HTTPMethod } from './types/http' +import { createError } from './error' + +export type RouterMethod = Lowercase +const RouterMethods: Lowercase[] = ['connect', 'delete', 'get', 'head', 'options', 'post', 'put', 'trace'] + +export type HandleWithParams = Handle }> + +export type AddWithMethod = (path: string, handle: HandleWithParams) => Router +export type AddRouteShortcuts = Record, AddWithMethod> + +export interface Router extends AddRouteShortcuts { + add: (path: string, handle: HandleWithParams, method?: RouterMethod | 'all') => Router + handle: Handle +} + +interface RouteNode { + handlers: Partial> +} + +export function createRouter (): Router { + const _router = _createRouter({}) + const routes: Record = {} + + const router: Router = {} as Router + + // Utilities to add a new route + router.add = (path, handle, method = 'all') => { + let route = routes[path] + if (!route) { + routes[path] = route = { handlers: {} } + _router.insert(path, route) + } + route.handlers[method] = handle + return router + } + for (const method of RouterMethods) { + router[method] = (path, handle) => router.add(path, handle, method) + } + + // Main handle + router.handle = (req, res) => { + // Match route + const matched = _router.lookup(req.url || '/') + if (!matched) { + throw createError({ + statusCode: 404, + name: 'Not Found', + statusMessage: `Cannot find any route matching ${req.url || '/'}.` + }) + } + + // Match method + const method = (req.method || 'get').toLowerCase() as RouterMethod + const handler: HandleWithParams | undefined = matched.handlers[method] || matched.handlers.all + if (!handler) { + throw createError({ + statusCode: 405, + name: 'Method Not Allowed', + statusMessage: `Method ${method} is not allowed on this route.` + }) + } + + // Add params + // @ts-ignore + req.params = matched.params || {} + + // Call handler + // @ts-ignore + return handler(req, res) + } + + return router +} diff --git a/src/types/http.ts b/src/types/http.ts new file mode 100644 index 00000000..8fa06a98 --- /dev/null +++ b/src/types/http.ts @@ -0,0 +1,2 @@ +// https://www.rfc-editor.org/rfc/rfc7231#section-4.1 +export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' diff --git a/src/utils/request.ts b/src/utils/request.ts index 0322b866..dbd4a4fd 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,14 +1,12 @@ import type { IncomingMessage } from 'http' import { getQuery } from 'ufo' import { createError } from '../error' +import type { HTTPMethod } from '../types/http' export function useQuery (req: IncomingMessage) { return getQuery(req.url || '') } -// https://www.rfc-editor.org/rfc/rfc7231#section-4.1 -export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' - export function useMethod (req: IncomingMessage, defaultMethod: HTTPMethod = 'GET'): HTTPMethod { return (req.method || defaultMethod).toUpperCase() as HTTPMethod } diff --git a/yarn.lock b/yarn.lock index 1ae2b550..cbfb2739 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4689,6 +4689,11 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +radix3@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/radix3/-/radix3-0.1.0.tgz#f06e7417eff16fd44bed1992af0bf25cabdc8668" + integrity sha512-OeAaZXMTjUn3u3kE4jF2m26AiA3ERoI5AwZZtUHByBPTDbU5BXVU7/M0+JbBrbwp7K7jn8RIk+hy522EldSvSQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" From b90dcb97b7ccf8dcb2ebdee0133ecd390031b343 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 9 Mar 2022 21:39:54 +0100 Subject: [PATCH 2/5] update import --- playground/index.ts | 7 +++---- src/utils/body.ts | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/playground/index.ts b/playground/index.ts index 2cd5ec2a..52052363 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,12 +1,11 @@ import { listen } from 'listhen' import { createApp, createRouter } from '../src' -const app = createApp({ debug: true }) +const app = createApp() const router = createRouter() + .get('/', () => 'Hello World!') + .get('/hello/:name', req => `Hello ${req.params.name}!`) app.use(router) -router.get('/', () => 'Hello World!') -router.get('/hello/:name', req => `Hello ${req.params.name}!`) - listen(app) diff --git a/src/utils/body.ts b/src/utils/body.ts index a5068602..9ff7362e 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,7 +1,8 @@ import type { IncomingMessage } from 'http' import destr from 'destr' import type { Encoding } from '../types/node' -import { HTTPMethod, assertMethod } from './request' +import type { HTTPMethod } from '../types/http' +import { assertMethod } from './request' const RawBodySymbol = Symbol('h3RawBody') const ParsedBodySymbol = Symbol('h3RawBody') From e24d706394634972e4fb823a3cef5c6ec0a96495 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 9 Mar 2022 22:01:45 +0100 Subject: [PATCH 3/5] docs: add basic router usage --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c18eb4e..aacdd9f2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ ✔️  **Extendable:** Ships with a set of composable utilities but can be extended +✔️  **Router:** Super fast route matching using [unjs/radix3](https://github.com/unjs/radix3) + ## Install ```bash @@ -57,7 +59,29 @@ listen(app) ``` -## Examples +## Router + +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. + +To opt-in using a more advanced and convenient routing system, we can create a router instance and register it to app instance. + +```ts +import { createApp, createRouter } from 'h3' + +const app = createApp() + +const router = createRouter() + .get('/', () => 'Hello World!') + .get('/hello/:name', req => `Hello ${req.params.name}!`) + +app.use(router) +``` + +**Tip:** We can register same route more than once with different methods. + +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). + +## More usage examples ```js // Handle can directly return object or Promise for JSON response From c2de84cae7c76837fc326ccc6db96c9050ffa055 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 9 Mar 2022 22:18:16 +0100 Subject: [PATCH 4/5] add basic tests --- test/router.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/router.test.ts diff --git a/test/router.test.ts b/test/router.test.ts new file mode 100644 index 00000000..d3add5ba --- /dev/null +++ b/test/router.test.ts @@ -0,0 +1,42 @@ +import supertest, { SuperTest, Test } from 'supertest' +import { describe, it, expect, beforeEach } from 'vitest' +import { createApp, createRouter, App, Router } from '../src' + +describe('router', () => { + let app: App + let router: Router + let request: SuperTest + + beforeEach(() => { + app = createApp({ debug: false }) + router = createRouter() + .add('/', () => 'Hello') + .get('/test', () => 'Test (GET)') + .post('/test', () => 'Test (POST)') + + app.use(router) + request = supertest(app) + }) + + it('Handle route', async () => { + const res = await request.get('/') + expect(res.text).toEqual('Hello') + }) + + it('Handle different methods', async () => { + const res1 = await request.get('/test') + expect(res1.text).toEqual('Test (GET)') + const res2 = await request.post('/test') + expect(res2.text).toEqual('Test (POST)') + }) + + it('Not matching route', async () => { + const res = await request.get('/404') + expect(res.status).toEqual(404) + }) + + it('Not matching route method', async () => { + const res = await request.head('/test') + expect(res.status).toEqual(405) + }) +}) From bda3038ff7e98a3af2a2622f6f2270bba54fedd9 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 9 Mar 2022 22:35:27 +0100 Subject: [PATCH 5/5] update radix3 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5aeed9cb..88565c1d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "cookie": "^0.4.2", "destr": "^1.1.0", - "radix3": "^0.1.0", + "radix3": "^0.1.1", "ufo": "^0.7.11" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index cbfb2739..622fe4bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4689,10 +4689,10 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== -radix3@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/radix3/-/radix3-0.1.0.tgz#f06e7417eff16fd44bed1992af0bf25cabdc8668" - integrity sha512-OeAaZXMTjUn3u3kE4jF2m26AiA3ERoI5AwZZtUHByBPTDbU5BXVU7/M0+JbBrbwp7K7jn8RIk+hy522EldSvSQ== +radix3@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/radix3/-/radix3-0.1.1.tgz#33f783184fc6f2ae2b1d15427ae63c17d44e43cb" + integrity sha512-9Np01fn+penHvC05A9EkRpyObPMS0ht3t1UP6KlnQPCfTNzArmEZW/+t2SLsDtPcZyXPDbYCGWA8dSQqWaVwQQ== randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0"