diff --git a/.eslintignore b/.eslintignore index de4d1f0..70b5ab8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ dist node_modules +README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..85a8b63 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +[![cover][cover-src]][cover-href] +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![bundle][bundle-src]][bundle-href] [![JSDocs][jsdocs-src]][jsdocs-href] +[![License][license-src]][license-href] + +# ๐ŸŒณ radix-rapid + +> โœจ Lightweight and fast router for JavaScript based on [Radix Tree](https://en.wikipedia.org/wiki/Radix_tree)๐ŸŒฑ. + +## ๐Ÿ“ Usage + +**Install:** + +```sh +# nyxi +nyxi radix-rapid + +# pnpm +pnpm i radix-rapid + +# npm +npm i radix-rapid + +# yarn +yarn add radix-rapid +``` + +**Import:** + +```js +// ESM +import { createRouter } from 'radix-rapid' + +// CJS +const { createRouter } = require('radix-rapid') +``` + +**Create a router instance and insert routes:** + +```js +const router = createRouter(/* options */) + +router.insert('/path', { payload: 'this path' }) +router.insert('/path/:name', { payload: 'named route' }) +router.insert('/path/foo/**', { payload: 'wildcard route' }) +router.insert('/path/foo/**:name', { payload: 'named wildcard route' }) +``` + +**Match route to access matched data:** + +```js +router.lookup('/path') +// { payload: 'this path' } + +router.lookup('/path/fooval') +// { payload: 'named route', params: { name: 'fooval' } } + +router.lookup('/path/foo/bar/baz') +// { payload: 'wildcard route' } + +router.lookup('/') +// null (no route matched for/) +``` + +## โšก๏ธ Methods + +### โž• `router.insert(path, data)` + +`path` can be static or using `:placeholder` or `**` for wildcard paths. + +The `data` object will be returned on matching params. It should be an object like `{ handler }` and not containing reserved keyword `params`. + +### ๐Ÿ” `router.lookup(path)` + +Returns matched data for `path` with optional `params` key if mached route using placeholders. + +### โŒ `router.remove(path)` + +Remove route matching `path`. + +## โš™๏ธ Options + +You can initialize router instance with options: + +```ts +const router = createRouter({ + strictTrailingSlash: true, + routes: { + '/foo': {} + } +}) +``` + +- ๐Ÿ›ฃ๏ธ `routes`: An object specifying initial routes to add +- ๐Ÿšฆ `strictTrailingSlash`: By default, the router ignores trailing slashes for matching and adding routes. When set to `true`, matching with trailing slashes is handled differently. + +### ๐Ÿ”Ž Route Matcher + +Creates a multi matcher from router tree that can match **all routes** matching path: + +```ts +import { createRouter, toRouteMatcher } from 'radix-rapid' + +const router = createRouter({ + routes: { + '/foo': { m: 'foo' }, // Matches /foo only + '/foo/**': { m: 'foo/**' }, // Matches /foo/ + '/foo/bar': { m: 'foo/bar' }, // Matches /foo/bar only + '/foo/bar/baz': { m: 'foo/bar/baz' }, // Matches /foo/bar/baz only + '/foo/*/baz': { m: 'foo/*/baz' } // Matches /foo//baz + } +}) + +const matcher = toRouteMatcher(router) + +const matches = matcher.matchAll('/foo/bar/baz') + +// [ +// { +// "m": "foo/**", +// }, +// { +// "m": "foo/*/baz", +// }, +// { +// "m": "foo/bar/baz", +// }, +// ] +``` + +## โšก๏ธ Performance + +See [benchmark](./benchmark). + + +## ๐Ÿ“œ License + +[MIT](./LICENSE) - Made with ๐Ÿ’ž + + + +[npm-version-src]: https://img.shields.io/npm/v/radix-rapid?style=flat&colorA=18181B&colorB=14F195 +[npm-version-href]: https://npmjs.com/package/radix-rapid +[npm-downloads-src]: https://img.shields.io/npm/dm/radix-rapid?style=flat&colorA=18181B&colorB=14F195 +[npm-downloads-href]: https://npmjs.com/package/radix-rapid +[bundle-src]: https://img.shields.io/bundlephobia/minzip/radix-rapid?style=flat&colorA=18181B&colorB=14F195 +[bundle-href]: https://bundlephobia.com/result?p=radix-rapid +[jsdocs-src]: https://img.shields.io/badge/jsDocs.io-reference-18181B?style=flat&colorA=18181B&colorB=14F195 +[jsdocs-href]: https://www.jsdocs.io/package/radix-rapid +[license-src]: https://img.shields.io/github/license/nyxblabs/radix-rapid.svg?style=flat&colorA=18181B&colorB=14F195 +[license-href]: https://github.com/nyxblabs/radix-rapid/blob/main/LICENSE + + +[cover-src]: https://raw.githubusercontent.com/nyxblabs/radix-rapid/main/.github/assets/cover-github-radix-rapid.png +[cover-href]: https://๐Ÿ’ปnyxb.ws diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..7437377 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,110 @@ +# ๐Ÿ“Š Benchmark Results + +Benchmarks are mainly focusing on benchmarking `lookup` method performance. + +Below results are based on my personal PC using Windows 11. You can use provided scripts to test in your own env. + +## โšก๏ธ Direct benchmark + +Directly benchmarking `lookup` performance using [benchmark](https://www.npmjs.com/package/benchmark) + +Scripts: +- ๐Ÿ‹๏ธโ€โ™€๏ธ `nyxr bench` +- ๐Ÿ‹๏ธโ€โ™‚๏ธ `nyxr bench:profile` (using [0x](https://www.npmjs.com/package/0x) to generate flamegraph) + + +``` +--- ๐Ÿงช Test environment --- + +Node.js version: 18.16.0 +radix-rapid version: 0.0.1 +OS: win32 +CPU count: 8 +Current load: [ 0, 0, 0 ] + + +--- ๐Ÿšง static route --- + +lookup x 18,670,265 ops/sec ยฑ4.69% (76 runs sampled) +Stats: + - /choot: 96837315 + +--- ๐Ÿ”ง dynamic route --- + +lookup x 403,374 ops/sec ยฑ3.18% (63 runs sampled) +Stats: + - /choot/123: 2065943 + ``` + +## โšก๏ธ HTTP Benchmark + + +Using [`autocannon`](https://github.com/mcollina/autocannon) and a simple http listener using lookup for realworld performance. + +Scripts: +- ๐Ÿš€ `nyxr bench:http` + +``` +--- ๐Ÿงช Test environment --- + +Node.js version: 18.16.0 +radix-rapid version: 0.0.1 +OS: win32 +CPU count: 8 +Current load: [ 0, 0, 0 ] + + +--- ๐Ÿ“Š Benchmark: static route --- + +Running 10s test @ http://localhost:3000/ +10 connections + + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Stat โ”‚ 2.5% โ”‚ 50% โ”‚ 97.5% โ”‚ 99% โ”‚ Avg โ”‚ Stdev โ”‚ Max โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Latency โ”‚ 0 ms โ”‚ 0 ms โ”‚ 1 ms โ”‚ 2 ms โ”‚ 0.13 ms โ”‚ 0.65 ms โ”‚ 30 ms โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Stat โ”‚ 1% โ”‚ 2.5% โ”‚ 50% โ”‚ 97.5% โ”‚ Avg โ”‚ Stdev โ”‚ Min โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Req/Sec โ”‚ 9663 โ”‚ 9663 โ”‚ 17183 โ”‚ 21935 โ”‚ 15848.8 โ”‚ 4391.92 โ”‚ 9660 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Bytes/Sec โ”‚ 1.35 MB โ”‚ 1.35 MB โ”‚ 2.41 MB โ”‚ 3.07 MB โ”‚ 2.22 MB โ”‚ 615 kB โ”‚ 1.35 MB โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Req/Bytes counts sampled once per second. +# of samples: 10 + +159k requests in 10.02s, 22.2 MB read +Stats: + - /choot: 158510 + +--- ๐Ÿ“Š Benchmark: dynamic route --- + +Running 10s test @ http://localhost:3000/ +10 connections + + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Stat โ”‚ 2.5% โ”‚ 50% โ”‚ 97.5% โ”‚ 99% โ”‚ Avg โ”‚ Stdev โ”‚ Max โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Latency โ”‚ 0 ms โ”‚ 0 ms โ”‚ 1 ms โ”‚ 2 ms โ”‚ 0.14 ms โ”‚ 0.56 ms โ”‚ 17 ms โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Stat โ”‚ 1% โ”‚ 2.5% โ”‚ 50% โ”‚ 97.5% โ”‚ Avg โ”‚ Stdev โ”‚ Min โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Req/Sec โ”‚ 9663 โ”‚ 9663 โ”‚ 14935 โ”‚ 17631 โ”‚ 14243.6 โ”‚ 2791.28 โ”‚ 9660 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Bytes/Sec โ”‚ 1.64 MB โ”‚ 1.64 MB โ”‚ 2.54 MB โ”‚ 3 MB โ”‚ 2.42 MB โ”‚ 475 kB โ”‚ 1.64 MB โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Req/Bytes counts sampled once per second. +# of samples: 10 + +142k requests in 10.01s, 24.2 MB read +Stats: + - /choot/123: 142410 +``` + + diff --git a/benchmark/direct.mjs b/benchmark/direct.mjs new file mode 100644 index 0000000..8baa8c1 --- /dev/null +++ b/benchmark/direct.mjs @@ -0,0 +1,29 @@ +/* eslint-disable no-console */ +import Benchmark from 'benchmark' // https://www.npmjs.com/package/benchmark' +import { benchSets, logSection, printEnv, printStats, router } from './utils.mjs' + +async function main() { + printEnv() + + for (const bench of benchSets) { + logSection(bench.title) + const suite = new Benchmark.Suite() + const stats = {} + suite.add('lookup', () => { + for (const req of bench.requests) { + const match = router.lookup(req.path) + if (!match) + stats[match] = (stats[match] || 0) + 1 + stats[req.path] = (stats[req.path] || 0) + 1 + } + }) + // eslint-disable-next-line max-statements-per-line + suite.on('cycle', (event) => { console.log(String(event.target)) }) + const promise = new Promise(resolve => suite.on('complete', () => resolve())) + suite.run({ async: true }) + await promise + printStats(stats) + } +} + +main().catch(console.error) diff --git a/benchmark/http.mjs b/benchmark/http.mjs new file mode 100644 index 0000000..4fae7ee --- /dev/null +++ b/benchmark/http.mjs @@ -0,0 +1,41 @@ +import autocannon from 'autocannon' // https://github.com/mcollina/autocannon +import { listen } from 'earlist' +import { benchSets, logSection, printEnv, printStats, router } from './utils.mjs' + +async function main() { + printEnv() + + for (const bench of benchSets) { + logSection(`Benchmark: ${bench.title}`) + const { listener, stats } = await createServer() + const instance = autocannon({ + url: listener.url, + requests: bench.requests, + }) + autocannon.track(instance) + process.once('SIGINT', () => { + instance.stop() + listener.close() + process.exit(1) + }) + await instance // Resolves to details results + printStats(stats) + await listener.close() + } +} + +main().catch(console.error) + +async function createServer() { + const stats = {} + const listener = await listen((req, res) => { + stats[req.url] = (stats[req.url] || 0) + 1 + const match = router.lookup(req.url) + if (!match) + stats[match] = (stats[match] || 0) + 1 + + res.end(JSON.stringify((match || { error: 404 }))) + }, { showURL: false }) + + return { listener, stats } +} diff --git a/benchmark/utils.mjs b/benchmark/utils.mjs new file mode 100644 index 0000000..6f0db9b --- /dev/null +++ b/benchmark/utils.mjs @@ -0,0 +1,53 @@ +/* eslint-disable no-console */ +import { readFileSync } from 'node:fs' +import os from 'node:os' +import { createRouter } from 'radix-rapid' + +// eslint-disable-next-line max-statements-per-line +export function logSection(title) { console.log(`\n--- ${title} ---\n`) } + +const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version + +export function printEnv() { + logSection('Test environment') + console.log('Node.js version:', process.versions.node) + console.log('radix-rapid version:', pkgVersion) + console.log('OS:', os.platform()) + console.log('CPU count:', os.cpus().length) + console.log('Current load:', os.loadavg()) + console.log('') +} + +export function printStats(stats) { + console.log(`Stats:\n${Object.entries(stats).map(([path, hits]) => ` - ${path}: ${hits}`).join('\n')}`) +} + +export const router = createRouter({ + routes: Object.fromEntries([ + '/hello', + '/cool', + '/hi', + '/helium', + '/coooool', + '/chrome', + '/choot', + '/choot/:choo', + '/ui/**', + '/ui/components/**', + ].map(path => [path, { path }])), +}) + +export const benchSets = [ + { + title: 'static route', + requests: [ + { path: '/choot' }, + ], + }, + { + title: 'dynamic route', + requests: [ + { path: '/choot/123' }, + ], + }, +]