Skip to content
Merged
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
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ router.post('/do', (req, res) => {
server.listen(3000)
```

### Trie router (dependency-free, fastest)
A segment-trie router with the same API, middleware chaining, nested routers
and error containment as the default sequential router, but no regex per route,
no route cache to size, and no external dependencies. Static routes resolve in
constant time and dynamic routes stay flat as the route count grows.

```js
const trie = require('0http/lib/router/trie')
const { router, server } = cero({ router: trie() })

router.use((req, res, next) => next())
router.get('/users/:id', (req, res) => res.end(req.params.id))
router.get('/files/*', (req, res) => res.end(req.params['*']))
```

Options: `caseSensitive` (default `true`), `ignoreTrailingSlash` (default
`true`), plus `defaultRoute`, `errorHandler` and `id`. Patterns: `/static`,
`/:param`, `/:param?`, `/:name.ext` (or `/:name.ext?`), trailing `/*` and
`RegExp` (named groups become params). Static segments are literal, so `/a+b`
only matches `/a+b`.

Differences from the sequential router, on purpose:
- Case-sensitive by default; `caseSensitive: false` folds ASCII letters only.
- A `*` that is not the last segment, or a `?` that is not the last character
of a param segment, throws at registration instead of matching too much.
- An absent optional param is simply not present on `req.params` (the
sequential router sets it to `undefined`, which also clobbers an inherited
param of the same name).
- A trailing `/*` also matches the bare prefix, with `'*'` equal to `''`.
- Empty route segments are ignored (`/a//b` is `/a/b`); the sequential router
truncates the route at the first empty segment.
- With `ignoreTrailingSlash: false`, a route registered as `/dir/` matches
`/dir/` only and `/dir` matches `/dir` only.
- A request path that does not start with `/` matches nothing.
- `router.routes` lists RegExp routes only; there is no `cacheSize` option.

# Support / Donate 💚
You can support the maintenance of this project:
- PayPal: https://www.paypal.me/kyberneees
Expand All @@ -52,10 +88,18 @@ You can support the maintenance of this project:
- **Errors never crash the process.** A route handler that throws (or rejects)
after response headers are already sent is contained: the response is ended or
the socket closed, and the server keeps serving. A custom `errorHandler` that
itself throws or rejects is also contained with a bare `500`.
- **Route matching is case-insensitive.** `/Admin`, `/admin` and `/ADMIN` all
match a route registered as `/admin`. Reverse proxies, WAFs and auth layers
that classify paths case-sensitively must be aligned accordingly.
itself throws or rejects is also contained with a bare `500`. If the response
has already ended, the custom `errorHandler` is skipped (writing to an ended
response would raise an uncatchable stream error). A throwing or rejecting
`defaultRoute` is contained the same way.
- **Mount prefixes are stripped from the path only.** The query string never
becomes part of a nested router's path, so `GET /api/acme?x=/admin` reaches
the router mounted at `/api/:tenant` as `/?x=/admin`, never as `/admin`.
- **Sequential route matching is case-insensitive.** `/Admin`, `/admin` and
`/ADMIN` all match a route registered as `/admin`. Reverse proxies, WAFs and
auth layers that classify paths case-sensitively must be aligned accordingly.
The trie router is case-sensitive by default and treats static segments
literally (no regex meaning), so it agrees with such layers out of the box.
- **Mount nested routers with `use()`.** Only `router.use(prefix, subRouter)`
rewrites the URL when entering a nested router. A router passed to `get()` or
`on()` is treated as a plain middleware and will not match its sub-routes.
Expand All @@ -64,7 +108,9 @@ You can support the maintenance of this project:
resumed (unlike Express mounted apps, which fall through).
- **The route cache is memory-bounded.** Matches with parameters, `404`s, and
paths longer than 4KB are never cached, and the cache is additionally capped
by total size — so attacker-controlled unique paths cannot pin memory.
by total size — so attacker-controlled unique paths cannot pin memory. The
trie router has no request-keyed cache at all: its static table holds one
entry per registered route and known HTTP method, never per request path.

# More
- Website and documentation: https://0http.21no.de
23 changes: 23 additions & 0 deletions bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const { router: routerPrevious } = httpPrevious({
cacheSize: 0
})
setupRouter(routerPrevious)

const { router: routerTrie } = httpNext({
router: require('./lib/router/trie')({ id: '/' })
})
setupRouter(routerTrie)
import('mitata').then(({ run, bench, group }) => {
group('Routers', () => {
bench('Next Router Parameter URL', () => {
Expand All @@ -58,6 +63,12 @@ import('mitata').then(({ run, bench, group }) => {

routerPrevious.lookup(req, res)
}).gc('inner')
bench('Trie Router Parameter URL', () => {
const req = getReqObject('/0')
const res = getResObject()

routerTrie.lookup(req, res)
}).gc('inner')
bench('Next Router Not Found URL', () => {
const req = getReqObject('/0/404')
const res = getResObject()
Expand All @@ -70,6 +81,12 @@ import('mitata').then(({ run, bench, group }) => {

routerPrevious.lookup(req, res)
}).gc('inner')
bench('Trie Router Not Found URL', () => {
const req = getReqObject('/0/404')
const res = getResObject()

routerTrie.lookup(req, res)
}).gc('inner')
bench('Next Router Error URL', () => {
const req = getReqObject('/0/error')
const res = getResObject()
Expand All @@ -82,6 +99,12 @@ import('mitata').then(({ run, bench, group }) => {

routerPrevious.lookup(req, res)
}).gc('inner')
bench('Trie Router Error URL', () => {
const req = getReqObject('/0/error')
const res = getResObject()

routerTrie.lookup(req, res)
}).gc('inner')
})

run({
Expand Down
22 changes: 22 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ const { router } = zero({
})
```

#### Trie Router
A dependency-free segment-trie router shipped with `0http`. Same API, middleware
chaining, nested routers and error containment as the sequential router, with a
different matching engine: no regex per route and no route cache to size.
- **Best for**: APIs with many routes; constant-time static routes, flat cost for dynamic routes.
- **Security defaults**: case-sensitive matching, literal static segments, params without `Object.prototype`, no request-keyed cache.
- **Patterns**: `/static`, `/:param`, `/:param?`, `/:name.ext`, trailing `/*`, `RegExp` (named groups become params).

```js
const trie = require('0http/lib/router/trie')
const { router } = zero({
router: trie({ caseSensitive: true, ignoreTrailingSlash: true })
})
```

#### Find-My-Way Router
Integration with [find-my-way](https://github.com/delvedor/find-my-way), a super-fast Radix Tree router.
- **Best for**: Static paths and high performance without regex overhead.
Expand Down Expand Up @@ -204,6 +219,13 @@ Pass a configuration object to `zero(config)`:
|--------|-------------|---------|
| `cacheSize` | LRU cache size. `0` to disable, `<0` for unlimited. | `-1` (Unlimited) |

### Trie Router Options
| Option | Description | Default |
|--------|-------------|---------|
| `caseSensitive` | Match paths case-sensitively. | `true` |
| `ignoreTrailingSlash` | Treat `/users/` and `/users` as the same path. | `true` |
| `id` | Router identifier for nested mounting. | random |

---

## Benchmarks
Expand Down
60 changes: 50 additions & 10 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@
}
.hero h1 { margin-bottom: 12px; }
.hero h1 .accent { white-space: nowrap; }
.hero-badge {
display: inline-flex; align-items: center; gap: 8px; margin: 0 auto 20px;
padding: 6px 14px; border-radius: 999px; font-size: 13px; font-weight: 500;
color: var(--accent); background: var(--accent-subtle); border: 1px solid rgba(56,189,248,0.25);
}
.hero-badge .tag {
font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
padding: 2px 7px; border-radius: 6px; background: var(--accent); color: #0b1220;
}
.hero-badge a { color: inherit; text-decoration: none; }
.hero-badge a:hover { text-decoration: underline; }

/* ── Stats ── */
.hero-stats {
Expand Down Expand Up @@ -201,21 +212,23 @@
<section class="hero">
<div class="container">
<div class="hero-logo">0</div>
<div class="hero-badge"><span class="tag">NEW</span><a href="#trie-router">v5.2 ships the Trie router: 2&times; faster static routes, flat cost at any route count &rarr;</a></div>
<h1>
Zero friction HTTP<br />
<span class="accent">framework for Node.js</span>
</h1>
<p>Tweaked for high throughput, low overhead, and maximum flexibility. The router that doesn't get in your way.</p>
<p>Tweaked for high throughput, low overhead, and maximum flexibility. The router that doesn't get in your way &mdash; now with a dependency-free trie engine that resolves static routes in ~80ns.</p>
<div class="hero-actions">
<a href="#install" class="btn btn-primary">Get Started</a>
<a href="#trie-router" class="btn btn-secondary">Meet the Trie Router</a>
<a href="https://github.com/BackendStack21/0http" target="_blank" class="btn btn-secondary">View on GitHub</a>
</div>
<div class="hero-stats">
<div class="stat"><span class="num">88K+</span><div class="label">req/sec</div></div>
<div class="stat"><span class="num">&lt;1ms</span><div class="label">overhead</div></div>
<div class="stat"><span class="num">2</span><div class="label">router engines</div></div>
<div class="stat"><span class="num">97.7%</span><div class="label">test coverage</div></div>
<div class="stat"><span class="num">v5.0</span><div class="label">latest</div></div>
<div class="stat"><span class="num">~80ns</span><div class="label">static route lookup</div></div>
<div class="stat"><span class="num">2&times;</span><div class="label">faster static routes</div></div>
<div class="stat"><span class="num">0</span><div class="label">deps in the trie router</div></div>
<div class="stat"><span class="num">v5.2</span><div class="label">latest</div></div>
</div>
</div>
</section>
Expand All @@ -230,7 +243,7 @@ <h2>Why 0http?</h2>
<div class="feature-card">
<span class="icon">⚡</span>
<h3>Blazing Fast</h3>
<p>One of the fastest Node.js web frameworks. Smart LRU caching and efficient routing keep overhead near zero.</p>
<p>One of the fastest Node.js web frameworks. The new trie router resolves static routes in ~80ns and keeps dynamic routes flat as your API grows &mdash; no regex per route, no cache to size.</p>
</div>
<div class="feature-card">
<span class="icon">🎛</span>
Expand All @@ -250,7 +263,7 @@ <h3>Minimal, Fast, Secure</h3>
<div class="feature-card">
<span class="icon">🧩</span>
<h3>Nested Routing</h3>
<p>Modular architecture with nested routers. Static paths optimized for maximum performance.</p>
<p>Modular architecture with nested routers, on both engines. Mount prefixes are stripped from the path only, so a query string can never become a nested route.</p>
</div>
<div class="feature-card">
<span class="icon">🛡</span>
Expand All @@ -271,7 +284,15 @@ <h2>Core Capabilities</h2>
<div class="capability-list">
<div class="capability-item">
<h3>1. Pluggable Routers</h3>
<p>0http lets you define the router implementation you prefer. Two engines ship by default.</p>
<p>0http lets you define the router implementation you prefer. Three engines are supported out of the box.</p>
<h4 id="trie-router">Trie Router (New in v5.2)</h4>
<p>A dependency-free segment-trie engine with the same API, middleware chain, nested routers and error containment as the default router. Static routes resolve through a compile-on-demand table in constant time; dynamic routes walk the trie with no regex per route, so lookup cost stays flat whether you register 5 routes or 500.</p>
<ul>
<li>~80ns static lookups (2&times; faster than sequential), 175ns param lookups, flat at 500 routes where sequential climbs past 1&micro;s</li>
<li>Safer defaults: case-sensitive matching, literal static segments, null-prototype params, no request-keyed cache to exhaust</li>
<li>Patterns: <code>/static</code>, <code>/:param</code>, <code>/:param?</code>, <code>/:name.ext</code>, trailing <code>/*</code> and RegExp with named groups</li>
<li>Opt in with <code>require('0http/lib/router/trie')()</code></li>
</ul>
<h4>Sequential Router (Default)</h4>
<p>Extended <a href="https://www.npmjs.com/package/trouter" target="_blank">trouter</a> with middleware, nested routers, and regex matching. Uses internal LRU cache for blazing-fast repeated lookups.</p>
<ul>
Expand Down Expand Up @@ -324,7 +345,24 @@ <h2>Get Started</h2>
<h2>Quick Start</h2>
<p class="lead">Common patterns to get you moving fast.</p>
<div class="examples-grid">


<div class="example-card">
<span class="ex-title">trie-router.js</span>
<pre><code><span class="k">const</span> trie = <span class="f">require</span>(<span class="s">'0http/lib/router/trie'</span>)
<span class="k">const</span> { router, server } = <span class="f">require</span>(<span class="s">'0http'</span>)({
router: <span class="f">trie</span>()
})

router.<span class="f">get</span>(<span class="s">'/users/:id'</span>, (req, res) => {
res.<span class="f">end</span>(req.params.id)
})
router.<span class="f">get</span>(<span class="s">'/files/*'</span>, (req, res) => {
res.<span class="f">end</span>(req.params[<span class="s">'*'</span>])
})

server.<span class="f">listen</span>(<span class="n">3000</span>)</code></pre>
</div>

<div class="example-card">
<span class="ex-title">basic-server.js</span>
<pre><code><span class="k">const</span> { router, server } = <span class="f">require</span>(<span class="s">'0http'</span>)()
Expand Down Expand Up @@ -420,7 +458,9 @@ <h2>Configuration</h2>
<tr><td>defaultRoute</td><td>Handler for 404 Not Found</td><td>404 + res.end()</td></tr>
<tr><td>errorHandler</td><td>Global error handler</td><td>Safe-by-default (v5.0+)</td></tr>
<tr><td>prioRequestsProcessing</td><td>Use setImmediate for priority processing</td><td>true</td></tr>
<tr><td>cacheSize</td><td>LRU cache size (0=off, &lt;0=unlimited)</td><td>&minus;1</td></tr>
<tr><td>cacheSize</td><td>Sequential router: LRU cache size (0=off, &lt;0=unlimited)</td><td>&minus;1</td></tr>
<tr><td>caseSensitive</td><td>Trie router: match paths case-sensitively</td><td>true</td></tr>
<tr><td>ignoreTrailingSlash</td><td>Trie router: treat <code>/users/</code> and <code>/users</code> alike</td><td>true</td></tr>
</tbody>
</table>
</div>
Expand Down
5 changes: 4 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ module.exports = (config = {}) => {
prioRequestsProcessing && (server instanceof httpServer || server instanceof httpsServer)

if (server.prioRequestsProcessing) {
// Pass req/res as setImmediate arguments instead of closing over them:
// one fewer allocation per request on the dispatch path.
const dispatch = (req, res) => router.lookup(req, res)
server.on('request', (req, res) => {
setImmediate(() => router.lookup(req, res))
setImmediate(dispatch, req, res)
})
} else {
server.on('request', (req, res) => {
Expand Down
Loading
Loading