diff --git a/README.md b/README.md
index c249b33..7cde7f9 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
@@ -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
diff --git a/bench.js b/bench.js
index 3cd834d..7f038b0 100644
--- a/bench.js
+++ b/bench.js
@@ -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', () => {
@@ -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()
@@ -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()
@@ -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({
diff --git a/docs/README.md b/docs/README.md
index 014bc94..e2f0df9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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.
@@ -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
diff --git a/docs/index.html b/docs/index.html
index 59122a3..68a5f0a 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -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 {
@@ -201,21 +212,23 @@
Tweaked for high throughput, low overhead, and maximum flexibility. The router that doesn't get in your way. Tweaked for high throughput, low overhead, and maximum flexibility. The router that doesn't get in your way — now with a dependency-free trie engine that resolves static routes in ~80ns.
Zero friction HTTP
-
framework for Node.js
One of the fastest Node.js web frameworks. Smart LRU caching and efficient routing keep overhead near zero.
+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 — no regex per route, no cache to size.
Modular architecture with nested routers. Static paths optimized for maximum performance.
+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.
0http lets you define the router implementation you prefer. Two engines ship by default.
+0http lets you define the router implementation you prefer. Three engines are supported out of the box.
+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.
+/static, /:param, /:param?, /:name.ext, trailing /* and RegExp with named groupsrequire('0http/lib/router/trie')()Extended trouter with middleware, nested routers, and regex matching. Uses internal LRU cache for blazing-fast repeated lookups.
Common patterns to get you moving fast.
const trie = require('0http/lib/router/trie')
+const { router, server } = require('0http')({
+ router: trie()
+})
+
+router.get('/users/:id', (req, res) => {
+ res.end(req.params.id)
+})
+router.get('/files/*', (req, res) => {
+ res.end(req.params['*'])
+})
+
+server.listen(3000)
+ const { router, server } = require('0http')()
@@ -420,7 +458,9 @@ Configuration
defaultRoute Handler for 404 Not Found 404 + res.end()
errorHandler Global error handler Safe-by-default (v5.0+)
prioRequestsProcessing Use setImmediate for priority processing true
- cacheSize LRU cache size (0=off, <0=unlimited) −1
+ cacheSize Sequential router: LRU cache size (0=off, <0=unlimited) −1
+ caseSensitive Trie router: match paths case-sensitively true
+ ignoreTrailingSlash Trie router: treat /users/ and /users alike true
(config?: TrieRouterConfig): IRouter
diff --git a/lib/router/trie.js b/lib/router/trie.js
new file mode 100644
index 0000000..8bc3cca
--- /dev/null
+++ b/lib/router/trie.js
@@ -0,0 +1,567 @@
+/**
+ * Trie router: a segment-trie matcher with compile-on-demand static lookups.
+ *
+ * Same public API, middleware executor, nested-router behaviour and error
+ * containment as the sequential router, with a different matching engine:
+ * - string routes are literal: no regex is built or executed for them;
+ * the path is walked segment by segment through a trie, and every
+ * matching route/middleware still chains in registration order
+ * - fully static routes resolve through a per-path table compiled the first
+ * time they are hit (bounded by the number of registered routes, so there
+ * is no attacker-growable cache to size or evict)
+ * - no `lru-cache`, `trouter` or `regexparam` on the request path
+ *
+ * Security defaults differ from the sequential router on purpose:
+ * - matching is case-sensitive (`caseSensitive: false` opts out and folds
+ * ASCII letters only, so lengths never change) so the router agrees with
+ * proxies, WAFs and auth layers about what was hit
+ * - a static segment never has regex meaning (`/a+b` matches `/a+b` only)
+ * - params objects have a null prototype chain (no Object.prototype)
+ * - a path that does not start with '/' matches nothing
+ *
+ * Pattern syntax: `/static`, `/:param`, `/:param?` (optional; absent params
+ * are not present on req.params), `/:name.ext` (param with literal suffix,
+ * `/:name.ext?` to make it optional), a trailing `/*` (rest of the path, may
+ * be empty) and RegExp patterns (named groups become params). A `*` that is
+ * not the last segment, or a `?` that is not the last character of a param
+ * segment, is rejected at registration. Empty route segments are ignored. A
+ * trailing slash on the request is ignored unless `ignoreTrailingSlash: false`,
+ * in which case a route registered as `/dir/` matches `/dir/` only.
+ */
+const next = require('./../next')
+const queryparams = require('./../utils/queryparams')
+const { dispatchError, runDefaultRoute } = next
+
+const DEFAULT_ROUTE = (req, res) => {
+ res.statusCode = 404
+ res.end()
+}
+
+const DEFAULT_ERROR_HANDLER = (err, req, res) => {
+ // Safe by default: only expose error details in explicit development mode.
+ if (res.headersSent) {
+ try { res.end() } catch (_) { res.destroy() }
+ return
+ }
+ res.statusCode = 500
+ res.setHeader('Content-Type', 'text/plain')
+ res.end(process.env.NODE_ENV === 'development' ? err.message : 'Internal Server Error')
+}
+
+const generateId = () => Math.random().toString(36).slice(2, 10).toUpperCase()
+
+const HTTP_METHODS = ['GET', 'HEAD', 'PATCH', 'OPTIONS', 'CONNECT', 'DELETE', 'TRACE', 'POST', 'PUT']
+
+// Only these method tokens get a slot in the compiled static table: the table
+// must stay bounded by registered routes, never by what a client sends.
+const CACHEABLE_METHODS = new Set(HTTP_METHODS)
+
+/**
+ * Params objects: null prototype chain (no Object.prototype, so a route param
+ * named `__proto__` or `constructor` can never reach shared state) but created
+ * through a constructor so V8 keeps them in fast mode. Object.create(null)
+ * yields dictionary-mode objects, several times slower to build and read.
+ */
+function Params () {}
+Params.prototype = Object.freeze(Object.create(null))
+
+// Shared, immutable "no params" result; identity-checked on the hot path.
+const EMPTY_PARAMS = Object.freeze(new Params())
+const EMPTY_KEYS = Object.freeze([])
+
+// Own-property copy; measurably cheaper than Object.assign for tiny objects.
+const copyInto = (dst, src) => {
+ for (const k in src) dst[k] = src[k]
+ return dst
+}
+
+const SLASH = 47 // '/'
+
+/**
+ * Static children are kept in two parallel arrays (`keys`/`nodes`) so the
+ * walk can compare a segment in place with `startsWith(key, pos)` and never
+ * has to allocate a substring for static segments. Nodes have few children
+ * in practice, so a linear scan with a length check first beats hashing.
+ */
+const createNode = () => ({
+ keys: [], // static segments, parallel to `nodes`
+ nodes: [],
+ params: null, // [{ suffix, node }], tried after the static children
+ wildcard: null, // node matching the rest of the path
+ prefix: null, // use() entries: apply to this node and everything below
+ exact: null // route entries: apply when the path ends at this node
+})
+
+const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+// ASCII-only case folding: never changes the string length, so capture
+// offsets stay valid on the original path (unlike String#toLowerCase, which
+// maps e.g. U+0130 to two code units and U+212A to ASCII 'k').
+const asciiLower = (s) => /[A-Z]/.test(s) ? s.replace(/[A-Z]+/g, (m) => m.toLowerCase()) : s
+
+/**
+ * Split a route string into segments, dropping empty ones (`/a//b` β a, b).
+ */
+const splitRoute = (route) => {
+ const out = []
+ for (const seg of route.split('/')) if (seg !== '') out.push(seg)
+ return out
+}
+
+/**
+ * Validate a route and expand optional params (`:id?`) into every concrete
+ * segment list, most specific first (a present param beats an absent one at
+ * every position, earlier positions first). A route with k optional params
+ * yields 2^k variants; k is author-controlled. Variants share one `seq` and
+ * `find` keeps only the most specific one that matches.
+ */
+const expandRoute = (segments) => {
+ const variants = []
+ const walkSeg = (i, acc) => {
+ if (i === segments.length) { variants.push(acc); return }
+ const seg = segments[i]
+ const c = seg.charCodeAt(0)
+ if (c === 42) { // '*'
+ if (i !== segments.length - 1) throw new TypeError('"*" must be the last segment of a route')
+ walkSeg(i + 1, acc.concat(seg))
+ return
+ }
+ if (c === 58) { // ':'
+ const q = seg.indexOf('?', 1)
+ if (q !== -1 && q !== seg.length - 1) throw new TypeError('"?" must be the last character of an optional param segment')
+ if (q !== -1) {
+ walkSeg(i + 1, acc.concat(seg.slice(0, q)))
+ walkSeg(i + 1, acc)
+ return
+ }
+ }
+ walkSeg(i + 1, acc.concat(seg))
+ }
+ walkSeg(0, [])
+ return variants
+}
+
+const keysOf = (segments) => {
+ const keys = []
+ for (const seg of segments) {
+ const c = seg.charCodeAt(0)
+ if (c === 42) { keys.push('*'); break }
+ if (c === 58) {
+ const dot = seg.indexOf('.', 1)
+ keys.push(dot === -1 ? seg.slice(1) : seg.slice(1, dot))
+ }
+ }
+ return keys
+}
+
+const normalizeRegExp = (pattern) => {
+ // 'g'/'y' mutate lastIndex across exec calls: strip them, keep the rest.
+ return pattern.global || pattern.sticky
+ ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
+ : pattern
+}
+
+module.exports = (config = {}) => {
+ const {
+ defaultRoute = DEFAULT_ROUTE,
+ errorHandler = DEFAULT_ERROR_HANDLER,
+ id = generateId(),
+ caseSensitive = true,
+ ignoreTrailingSlash = true
+ } = config
+
+ const root = createNode()
+ const regexRoutes = [] // RegExp patterns, tested after the trie
+ const routers = Object.create(null) // nested router id -> prefix length | RegExp | RegExp[]
+ const mounts = Object.create(null) // nested router id -> every mount RegExp
+ const staticTable = new Map() // normalized static path -> { gen, byMethod }
+ let seq = 0
+ let generation = 0 // bumped on every registration; stale buckets recompile
+
+ const normalizeCase = caseSensitive ? (s) => s : asciiLower
+
+ // ---------------------------------------------------------------- insert
+
+ const insertVariant = (segments, loose, entry) => {
+ let node = root
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i]
+ const c = seg.charCodeAt(0)
+ if (c === 42) { // '*': rest of the path, always last (validated)
+ node = node.wildcard ??= createNode()
+ } else if (c === 58) { // ':'
+ const dot = seg.indexOf('.', 1)
+ const suffix = dot === -1 ? '' : normalizeCase(seg.slice(dot))
+ node.params ??= []
+ let p = node.params.find(p => p.suffix === suffix)
+ if (p === undefined) {
+ p = { suffix, node: createNode() }
+ // Longer suffixes first so `.tar.gz` is tried before `.gz`.
+ node.params.push(p)
+ node.params.sort((a, b) => b.suffix.length - a.suffix.length)
+ }
+ node = p.node
+ } else {
+ const key = normalizeCase(seg)
+ const idx = node.keys.indexOf(key)
+ if (idx === -1) {
+ const child = createNode()
+ node.keys.push(key)
+ node.nodes.push(child)
+ node = child
+ } else {
+ node = node.nodes[idx]
+ }
+ }
+ }
+ if (loose) (node.prefix ??= []).push(entry)
+ else (node.exact ??= []).push(entry)
+ }
+
+ const register = (method, pattern, handlers, loose) => {
+ const routeSeq = seq++
+ handlers = handlers.flat()
+ if (pattern instanceof RegExp) {
+ regexRoutes.push({ seq: routeSeq, rank: 0, method, handlers, pattern: normalizeRegExp(pattern), keys: EMPTY_KEYS, slash: false })
+ } else {
+ if (typeof pattern !== 'string') throw new TypeError('Route pattern must be a string or RegExp')
+ // Only meaningful with ignoreTrailingSlash: false; root is never "slashed".
+ const slash = !loose && pattern.length > 1 && pattern.charCodeAt(pattern.length - 1) === SLASH
+ const variants = expandRoute(splitRoute(pattern))
+ for (let rank = 0; rank < variants.length; rank++) {
+ const segments = variants[rank]
+ const keys = keysOf(segments)
+ insertVariant(segments, loose, { seq: routeSeq, rank, method, handlers, keys: keys.length ? keys : EMPTY_KEYS, slash })
+ if (!loose && keys.length === 0) {
+ const key = normalizeCase('/' + segments.join('/'))
+ if (ignoreTrailingSlash) {
+ if (!staticTable.has(key)) staticTable.set(key, { gen: -1, byMethod: null })
+ if (key !== '/' && !staticTable.has(key + '/')) staticTable.set(key + '/', { gen: -1, byMethod: null })
+ } else {
+ const k = slash ? key + '/' : key
+ if (!staticTable.has(k)) staticTable.set(k, { gen: -1, byMethod: null })
+ }
+ }
+ }
+ }
+ // Any new entry can change what an already compiled static path chains:
+ // buckets compiled under an older generation recompile on their next hit.
+ generation++
+ }
+
+ // ----------------------------------------------------------------- match
+
+ // Scratch state for the synchronous, non-reentrant matcher: no per-request
+ // allocation for captures or candidates. Slots are addressed by counters;
+ // `length` is never reassigned (that is a slow runtime call in V8).
+ const caps = []
+ const outEntries = []
+ const outParams = []
+ let outN = 0
+
+ // mode: 0 = any entry, 1 = only entries registered without a trailing
+ // slash, 2 = only entries registered with one (ignoreTrailingSlash: false).
+ const collect = (list, method, isHEAD, mode) => {
+ for (let i = 0; i < list.length; i++) {
+ const e = list[i]
+ const m = e.method
+ if (m !== method && m !== '' && !(isHEAD && m === 'GET')) continue
+ if (mode !== 0 && e.slash !== (mode === 2)) continue
+ let params = null
+ const keys = e.keys
+ if (keys.length !== 0) {
+ params = new Params()
+ for (let j = 0; j < keys.length; j++) params[keys[j]] = caps[j]
+ }
+ outEntries[outN] = e
+ outParams[outN] = params
+ outN++
+ }
+ }
+
+ // `path` is the (case-folded) string being matched; captures are sliced
+ // from `src`, the original path, which has identical offsets. `depth` is
+ // the number of captures taken so far (caps[0..depth)).
+ const walk = (node, path, src, pos, len, method, isHEAD, depth) => {
+ if (node.prefix !== null) collect(node.prefix, method, isHEAD, 0)
+ // pos > len: the whole path was consumed. pos === len: a trailing slash
+ // was just consumed and nothing follows.
+ if (pos >= len) {
+ if (node.exact !== null) {
+ collect(node.exact, method, isHEAD, ignoreTrailingSlash ? 0 : (pos > len ? 1 : 2))
+ }
+ if (node.wildcard !== null) {
+ caps[depth] = ''
+ walk(node.wildcard, path, src, len + 1, len, method, isHEAD, depth + 1)
+ }
+ return
+ }
+ let end = path.indexOf('/', pos)
+ if (end === -1) end = len
+ const nextPos = end + 1
+ const slen = end - pos
+
+ // Static children: in-place comparison, no substring allocation.
+ const keys = node.keys
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i]
+ if (key.length === slen && path.startsWith(key, pos)) {
+ walk(node.nodes[i], path, src, nextPos, len, method, isHEAD, depth)
+ break // keys are unique per node
+ }
+ }
+
+ if (node.params !== null && slen !== 0) {
+ const params = node.params
+ for (let i = 0; i < params.length; i++) {
+ const p = params[i]
+ const sfx = p.suffix.length
+ if (sfx === 0) {
+ caps[depth] = src.slice(pos, end)
+ } else if (slen > sfx && path.endsWith(p.suffix, end)) {
+ caps[depth] = src.slice(pos, end - sfx)
+ } else {
+ continue
+ }
+ walk(p.node, path, src, nextPos, len, method, isHEAD, depth + 1)
+ }
+ }
+
+ if (node.wildcard !== null) {
+ caps[depth] = src.slice(pos)
+ walk(node.wildcard, path, src, len + 1, len, method, isHEAD, depth + 1)
+ }
+ }
+
+ const find = (method, path) => {
+ const len = path.length
+ // Only '' (root) and paths starting with '/' can match anything.
+ if (len !== 0 && path.charCodeAt(0) !== SLASH) return { params: EMPTY_PARAMS, handlers: [] }
+ const matchPath = caseSensitive ? path : asciiLower(path)
+ const isHEAD = method === 'HEAD'
+ outN = 0
+ // Start after the leading slash; '' and '/' start "exhausted" so root
+ // routes match them.
+ walk(root, matchPath, path, len <= 1 ? len + 1 : 1, len, method, isHEAD, 0)
+
+ if (regexRoutes.length !== 0) {
+ for (let i = 0; i < regexRoutes.length; i++) {
+ const r = regexRoutes[i]
+ const m = r.method
+ if (m !== method && m !== '' && !(isHEAD && m === 'GET')) continue
+ const matches = r.pattern.exec(path)
+ if (matches === null) continue
+ let params = null
+ if (matches.groups !== undefined) {
+ params = new Params()
+ for (const k in matches.groups) params[k] = matches.groups[k]
+ }
+ outEntries[outN] = r
+ outParams[outN] = params
+ outN++
+ }
+ }
+
+ let n = outN
+ if (n === 0) return { params: EMPTY_PARAMS, handlers: [] }
+
+ // Registration order decides the chain: insertion sort, n is tiny.
+ for (let i = 1; i < n; i++) {
+ const e = outEntries[i]; const p = outParams[i]
+ let j = i - 1
+ while (j >= 0 && outEntries[j].seq > e.seq) {
+ outEntries[j + 1] = outEntries[j]; outParams[j + 1] = outParams[j]; j--
+ }
+ outEntries[j + 1] = e; outParams[j + 1] = p
+ }
+
+ // Variants of one registration (optional params) share a seq: keep only
+ // the most specific match so a route or middleware never runs twice.
+ let w = 0
+ for (let i = 0; i < n; i++) {
+ const e = outEntries[i]
+ if (w !== 0 && outEntries[w - 1].seq === e.seq) {
+ const kept = outEntries[w - 1]
+ if (e.keys.length > kept.keys.length || (e.keys.length === kept.keys.length && e.rank < kept.rank)) {
+ outEntries[w - 1] = e
+ outParams[w - 1] = outParams[i]
+ }
+ outParams[i] = null
+ continue
+ }
+ outEntries[w] = e
+ outParams[w] = outParams[i]
+ w++
+ }
+ n = w
+
+ const handlers = []
+ let params = EMPTY_PARAMS
+ for (let i = 0; i < n; i++) {
+ const hs = outEntries[i].handlers
+ for (let j = 0; j < hs.length; j++) handlers.push(hs[j])
+ const p = outParams[i]
+ if (p !== null) {
+ if (params === EMPTY_PARAMS) params = p
+ else copyInto(params, p) // later routes override, as before
+ outParams[i] = null // never retain request data between lookups
+ }
+ }
+ return { params, handlers }
+ }
+
+ // ---------------------------------------------------------------- public
+
+ const router = {
+ id,
+ find,
+ routes: regexRoutes // informational: RegExp routes only
+ }
+
+ router.add = (method, pattern, ...handlers) => {
+ register(method, pattern, handlers, false)
+ return router
+ }
+ router.all = router.add.bind(router, '')
+ for (const method of HTTP_METHODS) router[method.toLowerCase()] = router.add.bind(router, method)
+ router.on = (method, pattern, ...handlers) => router.add(method, pattern, handlers)
+
+ // RegExp used by the executor to strip a string mount prefix from the path.
+ const prefixRegExp = (segments) => {
+ const source = segments.map(s => {
+ const c = s.charCodeAt(0)
+ if (c === 42) return '(?:\\/(.*))?'
+ if (c === 58) return s.indexOf('?', 1) !== -1 ? '(?:\\/[^/]+)?' : '\\/[^/]+'
+ return '\\/' + escapeRegExp(s)
+ }).join('')
+ return new RegExp('^' + source + '(?=$|\\/)', caseSensitive ? '' : 'i')
+ }
+
+ router.use = (prefix, ...middlewares) => {
+ if (typeof prefix === 'function') {
+ middlewares = [prefix, ...middlewares]
+ prefix = '/'
+ }
+ if (prefix instanceof RegExp) prefix = normalizeRegExp(prefix)
+ register('', prefix, middlewares, true)
+
+ const first = middlewares[0]
+ if (first?.id) {
+ // Nested router: tell the executor how to strip the prefix from the path.
+ let single
+ let regex
+ if (prefix instanceof RegExp) {
+ single = regex = prefix
+ } else {
+ const segments = splitRoute(prefix)
+ regex = prefixRegExp(segments)
+ const dynamic = segments.some(s => s.charCodeAt(0) === 58 || s.charCodeAt(0) === 42)
+ // Static prefixes strip by normalized length ('/a///b' matches '/a/b').
+ single = dynamic ? regex : ('/' + segments.join('/')).length
+ }
+ const list = mounts[first.id] ??= []
+ list.push(regex)
+ // Mounted once: cheapest form. Mounted at several prefixes: the
+ // executor picks the first mount RegExp that matches the path.
+ routers[first.id] = list.length === 1 ? single : list
+ }
+ return router
+ }
+
+ // Nested-router restore helpers: identical contract to the sequential router.
+ const restoreNestedUrlContext = (req, context) => {
+ if (context.hasUrlContext) {
+ req.url = context.url
+ req.path = context.path
+ delete req.preRouterUrl
+ delete req.preRouterPath
+ }
+ }
+
+ const createCleanupMiddleware = (step, context) => (req, res, next) => {
+ restoreNestedUrlContext(req, context)
+ req.params = context.params
+ return step()
+ }
+
+ router.lookup = (req, res, step) => {
+ req.url ??= '/'
+ req.originalUrl ??= req.url
+ if (typeof req.url !== 'string') req.url = String(req.url)
+
+ queryparams(req, req.url)
+
+ const path = req.path
+ const method = req.method
+ let match
+ let shared = false // true when `match` lives in the static table
+ const bucket = staticTable.get(caseSensitive ? path : asciiLower(path))
+ if (bucket !== undefined) {
+ if (bucket.gen !== generation) {
+ bucket.byMethod = Object.create(null)
+ bucket.gen = generation
+ }
+ match = bucket.byMethod[method]
+ if (match === undefined) {
+ match = find(method, path)
+ // Case-folded keys can map differently cased requests to one slot: a
+ // match carrying captures is specific to this request, so only
+ // parameter-less matches are shared in that mode.
+ if (CACHEABLE_METHODS.has(method) && (caseSensitive || match.params === EMPTY_PARAMS)) {
+ bucket.byMethod[method] = match
+ shared = true
+ }
+ } else {
+ shared = true
+ }
+ } else {
+ match = find(method, path)
+ }
+
+ const { handlers, params } = match
+ const activeErrorHandler = step?.errorHandler || errorHandler
+ if (handlers.length === 0) {
+ return runDefaultRoute(defaultRoute, req, res, activeErrorHandler)
+ }
+
+ // Per-level params: a fresh object per request, never a shared one. A
+ // match that did not come from the table was built for this request only,
+ // so its params object can be handed over without copying.
+ const inherited = req.params
+ if (inherited === undefined) {
+ req.params = params === EMPTY_PARAMS ? new Params() : (shared ? copyInto(new Params(), params) : params)
+ } else {
+ req.params = copyInto(copyInto(new Params(), inherited), params)
+ }
+
+ if (step === undefined && req.preRouterUrl === undefined) {
+ return next(handlers, req, res, 0, routers, defaultRoute, errorHandler)
+ }
+
+ const context = {
+ hasUrlContext: req.preRouterUrl !== undefined,
+ url: req.preRouterUrl,
+ path: req.preRouterPath,
+ params: inherited
+ }
+
+ let middlewares = handlers
+ if (step !== undefined) {
+ middlewares = handlers.slice()
+ middlewares.push(createCleanupMiddleware(step, context))
+ }
+
+ // When this router is used as a nested router, the parent executor passes
+ // a step function that carries the parent's error handler. Use the
+ // parent's error handler so errors bubble up. Restore the URL context
+ // before invoking it; a throwing handler can never take the process down.
+ const errorHandlerWithCleanup = (err, req, res) => {
+ restoreNestedUrlContext(req, context)
+ return dispatchError(activeErrorHandler, err, req, res)
+ }
+
+ return next(middlewares, req, res, 0, routers, defaultRoute, errorHandlerWithCleanup)
+ }
+
+ return router
+}
diff --git a/lib/utils/queryparams.js b/lib/utils/queryparams.js
index 2ac4bc9..cf227d0 100644
--- a/lib/utils/queryparams.js
+++ b/lib/utils/queryparams.js
@@ -4,6 +4,77 @@ const DANGEROUS_PROPERTIES = new Set(['__proto__', 'constructor', 'prototype'])
// Pre-created empty query object to avoid allocations
const EMPTY_QUERY = Object.freeze(Object.create(null))
+/**
+ * Store one parsed parameter, applying the prototype-pollution guard and the
+ * repeated-name β array rule. Shared by both parsing strategies below.
+ */
+function assign (query, name, value) {
+ // Prototype-pollution guard. The segment split/filter allocates per
+ // parameter, so it only runs for the rare names that could actually carry a
+ // dangerous segment. Every dangerous key ('__proto__', 'prototype',
+ // 'constructor') contains 'proto' or 'constructor' as a substring.
+ if (name.indexOf('proto') !== -1 || name.indexOf('constructor') !== -1) {
+ // Split parameter name into segments by dot or bracket notation
+ /* eslint-disable-next-line */
+ const segments = name.split(/[\.\[\]]+/).filter(Boolean)
+ if (segments.some(segment => DANGEROUS_PROPERTIES.has(segment))) {
+ return // Skip dangerous property names
+ }
+ }
+
+ const existing = query[name]
+ if (existing !== undefined) {
+ // Optimized array handling - check type once, then branch
+ if (Array.isArray(existing)) {
+ existing.push(value)
+ } else {
+ query[name] = [existing, value]
+ }
+ } else {
+ query[name] = value
+ }
+}
+
+/**
+ * True when the WHATWG application/x-www-form-urlencoded parser would return
+ * every name and value byte-for-byte unchanged: no percent-escapes (`%`), no
+ * plus-to-space (`+`), and no surrogates (which its UTF-8 round-trip would
+ * rewrite). For such strings the structural split below is exact.
+ */
+function isPlain (search) {
+ for (let i = 0; i < search.length; i++) {
+ const c = search.charCodeAt(i)
+ if (c === 37 || c === 43 || (c >= 0xD800 && c <= 0xDFFF)) return false
+ }
+ return true
+}
+
+/**
+ * Structural parse for plain strings, mirroring URLSearchParams exactly:
+ * one leading `?` is dropped, empty `&`-separated sequences are skipped, a
+ * sequence without `=` yields an empty value, and only the first `=` splits.
+ */
+function parsePlain (search, query) {
+ const len = search.length
+ let start = search.charCodeAt(0) === 63 ? 1 : 0 // 63 is '?'
+ // Track the next '=' instead of rescanning from every segment: a query like
+ // `a&a&a&...&=` would otherwise cost O(n^2) in the number of segments.
+ let eq = search.indexOf('=', start)
+ while (start <= len) {
+ let end = search.indexOf('&', start)
+ if (end === -1) end = len
+ if (end > start) {
+ if (eq !== -1 && eq < start) eq = search.indexOf('=', start)
+ if (eq === -1 || eq >= end) {
+ assign(query, search.slice(start, end), '')
+ } else {
+ assign(query, search.slice(start, eq), search.slice(eq + 1, end))
+ }
+ }
+ start = end + 1
+ }
+}
+
module.exports = (req, url) => {
// Single indexOf call - more efficient than multiple operations
const questionMarkIndex = url.indexOf('?')
@@ -20,7 +91,7 @@ module.exports = (req, url) => {
// Extract path and search in one operation each
req.path = url.slice(0, questionMarkIndex)
- const search = url.slice(questionMarkIndex + 1)
+ let search = url.slice(questionMarkIndex + 1)
if (search.length === 0) {
// Fast path: empty query string
@@ -30,34 +101,14 @@ module.exports = (req, url) => {
// Only rewrite array notation (a[]=1 -> a=1) when it is actually present,
// avoiding a regex scan/allocation on the common query-string case.
- const searchParams = new URLSearchParams(
- search.indexOf('[]=') === -1 ? search : search.replace(/\[\]=/g, '=')
- )
+ if (search.indexOf('[]=') !== -1) search = search.replace(/\[\]=/g, '=')
- for (const [name, value] of searchParams.entries()) {
- // Prototype-pollution guard. The segment split/filter allocates per
- // parameter, so it only runs for the rare names that could actually carry a
- // dangerous segment. Every dangerous key ('__proto__', 'prototype',
- // 'constructor') contains 'proto' or 'constructor' as a substring.
- if (name.indexOf('proto') !== -1 || name.indexOf('constructor') !== -1) {
- // Split parameter name into segments by dot or bracket notation
- /* eslint-disable-next-line */
- const segments = name.split(/[\.\[\]]+/).filter(Boolean)
- if (segments.some(segment => DANGEROUS_PROPERTIES.has(segment))) {
- continue // Skip dangerous property names
- }
- }
-
- const existing = query[name]
- if (existing !== undefined) {
- // Optimized array handling - check type once, then branch
- if (Array.isArray(existing)) {
- existing.push(value)
- } else {
- query[name] = [existing, value]
- }
- } else {
- query[name] = value
+ if (isPlain(search)) {
+ parsePlain(search, query)
+ } else {
+ // Decoding needed: defer to the spec-compliant parser.
+ for (const [name, value] of new URLSearchParams(search).entries()) {
+ assign(query, name, value)
}
}
diff --git a/package.json b/package.json
index 5d4927f..cd3a6b9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "0http",
- "version": "5.1.1",
+ "version": "5.2.0",
"description": "Zero friction HTTP request router. The need for speed!",
"main": "index.js",
"scripts": {
diff --git a/tests/fast-paths.test.js b/tests/fast-paths.test.js
new file mode 100644
index 0000000..a0cddd6
--- /dev/null
+++ b/tests/fast-paths.test.js
@@ -0,0 +1,213 @@
+/* global describe, it */
+const expect = require('chai').expect
+const { Trouter } = require('trouter')
+const sequential = require('../lib/router/sequential')
+const queryparams = require('../lib/utils/queryparams')
+
+const fakeRes = () => ({ end () {}, write () {}, setHeader () {} })
+
+// Reference matcher: regexparam patterns consumed by the stock Trouter#find.
+const referenceFind = (router, method, url) => Trouter.prototype.find.call(router, method, url)
+
+describe('0http - hot-path fast paths (perf pass)', () => {
+ describe('queryparams: structural fast path vs WHATWG decoder', () => {
+ it('decodes percent-escapes and plus signs through the decoding path', () => {
+ const req = {}
+ queryparams(req, '/p?na%6De=J%C3%BCrgen+Doe&q=a+b&x=%zz&y=100%25')
+ expect(req.query).to.deep.equal({ name: 'JΓΌrgen Doe', q: 'a b', x: '%zz', y: '100%' })
+ })
+
+ it('blocks dangerous names even when percent-encoded', () => {
+ const req = {}
+ queryparams(req, '/p?%5F%5Fproto%5F%5F=x&constructor%2Eprototype=y&ok=1')
+ expect(Object.keys(req.query)).to.deep.equal(['ok'])
+ expect(({}).polluted).to.equal(undefined)
+ })
+
+ it('parses segment-heavy strings in linear time (no rescans for "=")', () => {
+ const search = 'a&'.repeat(200000) + '='
+ const req = {}
+ const started = process.hrtime.bigint()
+ queryparams(req, '/p?' + search)
+ const ms = Number(process.hrtime.bigint() - started) / 1e6
+ expect(req.query).to.deep.equal({ a: Array(200000).fill(''), '': '' })
+ expect(ms).to.be.below(500) // quadratic version needed seconds here
+ })
+
+ it('matches URLSearchParams exactly on structural edge cases', () => {
+ const cases = [
+ 'a', 'a=', '=a', '=', '&', '&&', 'a&&b', 'a=1&', '&a=1', 'a=b=c', 'a=1&a=2&a=3',
+ '?a=1', '??a=1', 'a?=1', 'a=?', 'a#b=c', 'a[]=1&a[]=2', 'a[]=1&a=2', 'a..b=1', 'a[b]=1',
+ 'proto=1', 'a.proto.b=2', 'Γ©=ΓΌ', 'π=π', 'x=\ud800', '\udc00=1',
+ 'a=1+2', 'a%=1', 'a=%', 'a=%E2%82%AC', '%41=%42'
+ ]
+ for (const search of cases) {
+ const req = {}
+ queryparams(req, '/p?' + search)
+ const expected = Object.create(null)
+ for (const [name, value] of new URLSearchParams(search.replace(/\[\]=/g, '=')).entries()) {
+ if (name.split(/[.[\]]+/).some(s => ['__proto__', 'constructor', 'prototype'].includes(s))) continue
+ if (expected[name] === undefined) expected[name] = value
+ else if (Array.isArray(expected[name])) expected[name].push(value)
+ else expected[name] = [expected[name], value]
+ }
+ expect(req.query, JSON.stringify(search)).to.deep.equal(expected)
+ expect(Object.getPrototypeOf(req.query)).to.equal(null)
+ }
+ })
+ })
+
+ describe('find: length prefilters never reject a URL the pattern accepts', () => {
+ const cases = [
+ ['/static', ['/static', '/static/', '/STATIC', '/statics', '/stati', '/', '']],
+ ['/', ['/', '', '/x']],
+ ['/file.json', ['/file.json', '/filexjson', '/file.jsonx', '/file.json/']],
+ ['/a+b', ['/ab', '/aab', '/aaab', '/a+b', '/b']],
+ ['/(x)', ['/x', '/(x)', '/xx']],
+ ['/x|y', ['/x', 'y', '/x|y', '/y']],
+ ['/users/:id', ['/users/1', '/users/', '/users', '/users/1/2', '/users/1/']],
+ ['/users/:id?', ['/users', '/users/', '/users/1', '/users/1/']],
+ ['/files/*', ['/files', '/files/', '/files/a/b', '/file']],
+ ['/files/*?', ['/files', '/files/', '/files/a/b']],
+ ['/img/:name.png', ['/img/a.png', '/img/.png', '/img/a.jpg', '/img/a.png/', '/img/axpng']],
+ ['/img/:name.png?', ['/img/a.png', '/img/a', '/img/a.pn']],
+ ['/a/:b/c/:d', ['/a/1/c/2', '/a/1/c', '/a//c/2', '/a/1/c/2/']]
+ ]
+
+ for (const loose of [false, true]) {
+ it(`${loose ? 'use' : 'get'}: agrees with Trouter#find on every case`, () => {
+ for (const [route, urls] of cases) {
+ const router = sequential({ cacheSize: 0 })
+ const handler = () => {}
+ if (loose) router.use(route, handler); else router.get(route, handler)
+ for (const url of urls) {
+ const ours = router.find('GET', url)
+ const ref = referenceFind(router, 'GET', url)
+ expect(ours.handlers, `${route} ${url}`).to.deep.equal(ref.handlers)
+ expect(ours.params, `${route} ${url}`).to.deep.equal(ref.params)
+ }
+ }
+ })
+ }
+
+ it('keeps HEAD β GET fallback, method filtering and registration order', () => {
+ const router = sequential({ cacheSize: 0 })
+ const a = () => 'a'; const b = () => 'b'; const c = () => 'c'; const d = () => 'd'
+ router.use(a).get('/x', b, c).post('/x', d)
+ expect(router.find('HEAD', '/x').handlers).to.deep.equal([a, b, c])
+ expect(router.find('POST', '/x').handlers).to.deep.equal([a, d])
+ expect(router.find('PUT', '/x').handlers).to.deep.equal([a])
+ expect(router.find('__proto__', '/x').handlers).to.deep.equal([a])
+ })
+
+ it('captures named groups from RegExp routes and shares no params object', () => {
+ const router = sequential({ cacheSize: 0 })
+ router.get(/^\/re\/(?