From 79df2a07e02f9fb2241c5f7b1450fb43d94d2f64 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 9 Oct 2026 10:18:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v5.2.0=20=E2=80=94=20trie=20router,=20h?= =?UTF-8?q?ot-path=20performance=20pass,=20executor=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dependency-free trie router (lib/router/trie.js): same API, middleware chain, nested routers and error containment as the sequential router with a segment-trie matcher and a compile-on-demand static table. ~80ns static lookups (2x faster than sequential), flat dynamic-route cost at any route count, case-sensitive by default, literal static segments, null-prototype params, no request-keyed cache. Sequential router performance pass: custom find with exact length prefilters (drop-in for Trouter#find), lazy params allocation, top-level lookup fast path, structural query-string fast path, no per-request closure in the setImmediate dispatch. 11-21% faster per lookup at small route tables. Hardening (shared executor, both routers), found by adversarial review: - mount prefixes are stripped from the path only; a query string can no longer become a nested router's path (`/api/acme?x=/admin`) - a user errorHandler is skipped once the response has ended (writing would raise an uncatchable stream error and kill the process) - defaultRoute runs under error containment - one router mounted at several prefixes keeps every mount - use() normalizes g/y RegExp flags like add() - linear query parsing for segment-heavy strings Docs: README, docs/README.md and the landing page promote the trie router. Version bumped to 5.2.0. Co-Authored-By: Claude Fable 5.1 --- README.md | 56 +++- bench.js | 23 ++ docs/README.md | 22 ++ docs/index.html | 60 +++- index.js | 5 +- lib/next.js | 70 ++++- lib/router/sequential.js | 266 ++++++++++++++---- lib/router/trie.d.ts | 16 ++ lib/router/trie.js | 567 ++++++++++++++++++++++++++++++++++++++ lib/utils/queryparams.js | 107 +++++-- package.json | 2 +- tests/fast-paths.test.js | 213 ++++++++++++++ tests/trie-router.test.js | 384 ++++++++++++++++++++++++++ 13 files changed, 1678 insertions(+), 113 deletions(-) create mode 100644 lib/router/trie.d.ts create mode 100644 lib/router/trie.js create mode 100644 tests/fast-paths.test.js create mode 100644 tests/trie-router.test.js 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 @@
+

Zero friction HTTP
framework for Node.js

-

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.

88K+
req/sec
-
<1ms
overhead
-
2
router engines
-
97.7%
test coverage
-
v5.0
latest
+
~80ns
static route lookup
+
2×
faster static routes
+
0
deps in the trie router
+
v5.2
latest
@@ -230,7 +243,7 @@

Why 0http?

⚑

Blazing Fast

-

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.

πŸŽ› @@ -250,7 +263,7 @@

Minimal, Fast, Secure

🧩

Nested Routing

-

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.

πŸ›‘ @@ -271,7 +284,15 @@

Core Capabilities

1. Pluggable Routers

-

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.

+

Trie Router (New in v5.2)

+

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.

+
    +
  • ~80ns static lookups (2× faster than sequential), 175ns param lookups, flat at 500 routes where sequential climbs past 1µs
  • +
  • Safer defaults: case-sensitive matching, literal static segments, null-prototype params, no request-keyed cache to exhaust
  • +
  • Patterns: /static, /:param, /:param?, /:name.ext, trailing /* and RegExp with named groups
  • +
  • Opt in with require('0http/lib/router/trie')()
  • +

Sequential Router (Default)

Extended trouter with middleware, nested routers, and regex matching. Uses internal LRU cache for blazing-fast repeated lookups.

    @@ -324,7 +345,24 @@

    Get Started

    Quick Start

    Common patterns to get you moving fast.

    - + +
    + trie-router.js +
    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)
    +
    +
    basic-server.js
    const { router, server } = require('0http')()
    @@ -420,7 +458,9 @@ 

    Configuration

    defaultRouteHandler for 404 Not Found404 + res.end() errorHandlerGlobal error handlerSafe-by-default (v5.0+) prioRequestsProcessingUse setImmediate for priority processingtrue - cacheSizeLRU cache size (0=off, <0=unlimited)−1 + cacheSizeSequential router: LRU cache size (0=off, <0=unlimited)−1 + caseSensitiveTrie router: match paths case-sensitivelytrue + ignoreTrailingSlashTrie router: treat /users/ and /users aliketrue
    diff --git a/index.js b/index.js index 08bbc39..6e13c92 100644 --- a/index.js +++ b/index.js @@ -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) => { diff --git a/lib/next.js b/lib/next.js index 005884d..3c44614 100644 --- a/lib/next.js +++ b/lib/next.js @@ -46,6 +46,10 @@ function failSafe (res) { } function dispatchError (errorHandler, err, req, res) { + // Nothing can be written once the response has ended: a handler that tries + // (res.end(body), res.write) makes Node emit 'error' on the next tick, which + // no try/catch here can reach. Skip the handler and finish safely instead. + if (res.writableEnded === true) return failSafe(res) let result try { result = errorHandler(err, req, res) @@ -59,11 +63,57 @@ function dispatchError (errorHandler, err, req, res) { : result } +/** + * Run the 404 handler under the same containment as any middleware: a sync + * throw or a rejected promise from `defaultRoute` goes to the error handler + * instead of escaping the request as uncaughtException / unhandledRejection. + */ +function runDefaultRoute (defaultRoute, req, res, errorHandler) { + try { + const result = defaultRoute(req, res) + return result && typeof result.then === 'function' + ? result.catch(err => dispatchError(errorHandler, err, req, res)) + : result + } catch (err) { + return dispatchError(errorHandler, err, req, res) + } +} + +/** + * Strip a mount prefix from the PATH portion of req.url, keeping the query + * string intact. Matching the prefix against the full URL let a query such as + * `?x=/admin` be consumed by a `:param` segment and the remainder become the + * nested router's path. `pattern` is a static prefix length, a RegExp, or an + * array of RegExps when the same router is mounted at several prefixes. + */ +function stripPrefix (url, pattern) { + const q = url.indexOf('?') + const path = q === -1 ? url : url.slice(0, q) + const rest = q === -1 ? '' : url.slice(q) + let stripped + if (typeof pattern === 'number') { + stripped = path.slice(pattern) + } else { + if (Array.isArray(pattern)) { + let hit = null + for (let i = 0; i < pattern.length; i++) { + if (pattern[i].test(path)) { hit = pattern[i]; break } + } + if (hit === null) return url + pattern = hit + } + stripped = path.replace(pattern, '') + } + // Ensure the nested path starts with a slash - 47 is '/' + if (stripped.length === 0 || stripped.charCodeAt(0) !== 47) stripped = '/' + stripped + return stripped + rest +} + function next (middlewares, req, res, index, routers, defaultRoute, errorHandler) { // Fast path for end of middleware chain if (index >= middlewares.length) { // Only call defaultRoute if response is not finished - return !res.finished && defaultRoute(req, res) + return !res.finished && runDefaultRoute(defaultRoute, req, res, errorHandler) } // Get current middleware @@ -76,7 +126,9 @@ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler : next(middlewares, req, res, index + 1, routers, defaultRoute, errorHandler) } // Expose the error handler so nested routers can bubble errors to the parent - // instead of being handled by their own default error handler. + // instead of being handled by their own default error handler. Set it for + // every step: a middleware may hand `step` to `child.lookup(req, res, step)` + // directly, and that public flow must bubble errors too. step.errorHandler = errorHandler try { @@ -90,17 +142,8 @@ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler req.preRouterUrl = req.url req.preRouterPath = req.path - // Replace pattern in URL - this is a hot path, optimize it - if (typeof pattern === 'number') { - req.url = req.url.slice(pattern) - } else { - req.url = req.url.replace(pattern, '') - } - - // Ensure URL starts with a slash - use charCodeAt for performance - if (req.url.length === 0 || req.url.charCodeAt(0) !== 47) { // 47 is '/' - req.url = '/' + req.url - } + // Hand the nested router the URL with the mount prefix removed + req.url = stripPrefix(req.url, pattern) } try { @@ -132,3 +175,4 @@ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler module.exports = next module.exports.dispatchError = dispatchError +module.exports.runDefaultRoute = runDefaultRoute diff --git a/lib/router/sequential.js b/lib/router/sequential.js index 286d569..60fc72d 100644 --- a/lib/router/sequential.js +++ b/lib/router/sequential.js @@ -3,7 +3,7 @@ const next = require('./../next') const { parse } = require('regexparam') const { LRUCache: Cache } = require('lru-cache') const queryparams = require('./../utils/queryparams') -const dispatchError = require('./../next').dispatchError +const { dispatchError, runDefaultRoute } = next /** * Default handlers as constants to avoid creating functions on each router instance. @@ -39,6 +39,70 @@ const generateId = () => { return Math.random().toString(36).slice(2, 10).toUpperCase() } +/** + * Shared, immutable "no params" result. `find` returns it for every match that + * carries no route parameters, so the hot path can detect the parameter-less + * case with a single identity check instead of `Object.keys(...).length`. + * Frozen so a shared object can never become a cross-request leak. + */ +const EMPTY_PARAMS = Object.freeze({}) + +// Every HTTP method trouter binds as a shortcut in its constructor. +const HTTP_METHODS = ['GET', 'HEAD', 'PATCH', 'OPTIONS', 'CONNECT', 'DELETE', 'TRACE', 'POST', 'PUT'] + +/** + * Regex metacharacters that make a string route segment match a different + * number of characters than its literal length. regexparam inserts static + * segments into the pattern verbatim, so such routes get no length prefilter. + * `.` is deliberately allowed: it still consumes exactly one character. + */ +const UNSAFE_SEGMENT = /[\\^$*+?()[\]{}|]/ + +/** + * Derive cheap, strictly necessary length conditions for a string route, + * mirroring regexparam's tokenizer exactly (same split, same early stop on an + * empty segment, same param/wildcard/extension rules): + * - min: every URL matching the pattern has at least this many characters + * - exact: for static non-loose routes, the URL length must be `exact` or + * `exact + 1` (optional trailing slash); -1 when not applicable + * Any construct whose consumed length is not provable yields the permissive + * {min: 0, exact: -1}, so a prefilter can never reject a URL the regex accepts. + */ +function routeBounds (route, loose) { + const bounds = { min: 0, exact: -1 } + if (typeof route !== 'string') return bounds + let min = 0 + let dynamic = false + let tmp + const arr = route.split('/') + arr[0] || arr.shift() + while ((tmp = arr.shift())) { + const c = tmp[0] + if (c === '*') { + dynamic = true + // '/(.*)' needs the slash; '(?:/(.*))?' needs nothing. + if (tmp[1] !== '?') min += 1 + } else if (c === ':') { + dynamic = true + const o = tmp.indexOf('?', 1) + const ext = tmp.indexOf('.', 1) + if (ext !== -1) { + // Extension handling escapes only the leading dot; stay permissive. + if (o !== -1 || UNSAFE_SEGMENT.test(tmp.slice(ext + 1))) return bounds + min += 2 + (tmp.length - ext) // '/' + 1 char + '.ext' + } else if (o === -1) { + min += 2 // '/' + at least one character + } + } else { + if (UNSAFE_SEGMENT.test(tmp)) return bounds + min += 1 + tmp.length + } + } + bounds.min = min + if (!dynamic && !loose) bounds.exact = min + return bounds +} + module.exports = (config = {}) => { // Use object destructuring with defaults for cleaner config initialization const { @@ -48,7 +112,10 @@ module.exports = (config = {}) => { id = generateId() } = config - const routers = {} + // Null-prototype maps: a router id such as 'constructor' must never resolve + // to an inherited property in the executor. + const routers = Object.create(null) // id -> prefix length | RegExp | RegExp[] + const mounts = Object.create(null) // id -> every mount RegExp /** * Initialize LRU cache for route matching results with optimized settings. @@ -84,9 +151,20 @@ module.exports = (config = {}) => { const router = new Trouter() router.id = id + const routes = router.routes const _add = router.add.bind(router) + /** + * Attach the length prefilter bounds to the route trouter just registered. + */ + const annotateLastRoute = (route, loose) => { + const entry = routes[routes.length - 1] + const { min, exact } = routeBounds(route, loose) + entry.min = min + entry.exact = exact + } + /** * Wrap router.add to normalize RegExp patterns. * The 'g' (global) and 'y' (sticky) flags mutate lastIndex on exec/test, @@ -99,12 +177,15 @@ module.exports = (config = {}) => { const safeFlags = pattern.flags.replace(/[gy]/g, '') pattern = new RegExp(pattern.source, safeFlags) } - return _add(method, pattern, ...handlers) + _add(method, pattern, ...handlers) + annotateLastRoute(pattern, false) + return router } - // Trouter binds HTTP method shortcuts (get, post, ...) to the original add - // in its constructor. Rebind them so they use our normalized add wrapper. - const HTTP_METHODS = ['GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + // Trouter binds HTTP method shortcuts (get, post, ...) and `all` to the + // original add in its constructor. Rebind every one of them so they all go + // through the normalized add wrapper (same flag handling, same prefilters). + router.all = router.add.bind(router, '') HTTP_METHODS.forEach(method => { router[method.toLowerCase()] = router.add.bind(router, method) }) @@ -122,7 +203,13 @@ module.exports = (config = {}) => { middlewares = [prefix, ...middlewares] prefix = '/' } + // Same g/y flag normalization as add(): a sticky/global prefix RegExp + // would alternate between matching and failing across requests. + if (prefix instanceof RegExp && (prefix.global || prefix.sticky)) { + prefix = new RegExp(prefix.source, prefix.flags.replace(/[gy]/g, '')) + } _use.call(router, prefix, ...middlewares) + annotateLastRoute(prefix, true) // Optimized nested router detection - check first middleware only const firstMiddleware = middlewares[0] @@ -130,14 +217,75 @@ module.exports = (config = {}) => { // Cache router -> pattern relation for URL pattern replacement in nested routing // This enables efficient URL rewriting when entering nested router contexts const { pattern, keys } = parse(prefix, true) - routers[firstMiddleware.id] = keys.length === 0 && prefix.indexOf('*') === -1 // No params and no wildcards + const single = typeof prefix === 'string' && keys.length === 0 && prefix.indexOf('*') === -1 // No params and no wildcards ? prefix.length // Static match : pattern // Regex match + const list = mounts[firstMiddleware.id] ??= [] + list.push(pattern) + // Mounted once: cheapest form. Mounted at several prefixes: the + // executor picks the first mount RegExp that matches the path. + routers[firstMiddleware.id] = list.length === 1 ? single : list } return router // Ensure chainable API by returning router instance } + /** + * Route matcher. Same semantics and result shape as Trouter#find (regexes + * from regexparam decide every match; HEAD falls back to GET handlers; + * handlers accumulate in registration order), with three hot-path changes: + * - routes whose length bounds the URL cannot satisfy skip the regex entirely + * - the params object is only allocated once a parameter is captured; + * parameter-less matches share the frozen EMPTY_PARAMS + * - multi-handler routes are appended in place instead of via concat + */ + const find = (method, url) => { + const isHEAD = method === 'HEAD' + const ulen = url.length + const handlers = [] + let params = EMPTY_PARAMS + + for (let i = 0; i < routes.length; i++) { + const tmp = routes[i] + const m = tmp.method + if (m !== method && m !== '' && !(isHEAD && m === 'GET')) continue + // Prefilters: `undefined` bounds (route pushed behind our back) compare + // false here, so they fall through to the regex, never to a rejection. + if (ulen < tmp.min) continue + + const keys = tmp.keys + if (keys === false) { + // User-supplied RegExp: named groups become params. + const matches = tmp.pattern.exec(url) + if (matches === null) continue + const groups = matches.groups + if (groups !== undefined) { + if (params === EMPTY_PARAMS) params = {} + for (const k in groups) params[k] = groups[k] + } + } else if (keys.length > 0) { + const matches = tmp.pattern.exec(url) + if (matches === null) continue + if (params === EMPTY_PARAMS) params = {} + for (let j = 0; j < keys.length; j++) params[keys[j]] = matches[j + 1] + } else { + const exact = tmp.exact + if (exact >= 0 && ulen !== exact && ulen !== exact + 1) continue + if (!tmp.pattern.test(url)) continue + } + + const hs = tmp.handlers + if (hs.length > 1) { + for (let j = 0; j < hs.length; j++) handlers.push(hs[j]) + } else { + handlers.push(hs[0]) + } + } + + return { params, handlers } + } + router.find = find + /** * Creates cleanup middleware for nested router restoration. * This middleware restores the original URL and path after nested router processing. @@ -182,19 +330,19 @@ module.exports = (config = {}) => { // Cache lookup optimization - minimize variable assignments let match - if (cache) { + if (cache !== null) { // Pre-compute cache key with direct concatenation (fastest approach) const reqCacheKey = req.method + req.path match = cache.get(reqCacheKey) - if (!match) { + if (match === undefined) { match = router.find(req.method, req.path) // Parametrized matches have unbounded distinct keys (one per param // value): caching them pins attacker-growable memory and churns hot // static entries out of the LRU. Never cache them, and never cache // 404s either (empty handlers) β€” junk unmatched paths are the same // memory-pressure vector. - if (match.handlers.length && Object.keys(match.params).length === 0) { + if (match.handlers.length !== 0 && match.params === EMPTY_PARAMS) { cache.set(reqCacheKey, match) } } @@ -204,57 +352,65 @@ module.exports = (config = {}) => { const { handlers, params } = match - if (handlers.length) { - // Snapshot the request state this lookup is about to change (URL rewrite + - // params). Cleanup and error paths restore from the snapshot β€” closure - // semantics, so deeper nesting levels cannot corrupt an outer restore. - const context = { - hasUrlContext: req.preRouterUrl !== undefined, - url: req.preRouterUrl, - path: req.preRouterPath, - params: req.params - } + if (handlers.length === 0) { + // A throwing or rejecting 404 handler is contained like any middleware. + return runDefaultRoute(defaultRoute, req, res, step?.errorHandler || errorHandler) + } - let middlewares - if (step !== undefined) { - // Create new array only when step middleware is needed - middlewares = handlers.slice() - middlewares.push(createCleanupMiddleware(step, context)) - } else { - middlewares = handlers - } + // Per-level params: never mutate an upstream params object in place. + // Nested routers inherit parent params via a shallow copy, so existing + // consumers keep seeing merged params while mutations stay confined to + // this level's own object (restored on cleanup/error). + const inherited = req.params + if (inherited === undefined) { + req.params = params === EMPTY_PARAMS ? Object.create(null) : { ...params } + } else { + req.params = { ...inherited, ...params } + } - // 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 and are not silently handled by the - // nested router's own default error handler. - const activeErrorHandler = step?.errorHandler || errorHandler - - // Wrap the active error handler so request-state restoration happens - // before the handler is invoked, and so a throwing handler can never - // take the process down. - const errorHandlerWithCleanup = (err, req, res) => { - // Restore URL context only: error handlers are terminal, so keep the - // matched route's params available to the handler. - restoreNestedUrlContext(req, context) - return dispatchError(activeErrorHandler, err, req, res) - } + // Fast path: a top-level lookup (no parent executor, no nested URL rewrite + // in effect) has nothing to restore. Skip the snapshot and the wrapping + // error handler; `next` already contains every error-handler invocation. + if (step === undefined && req.preRouterUrl === undefined) { + return next(handlers, req, res, 0, routers, defaultRoute, errorHandler) + } - // Per-level params: never mutate an upstream params object in place. - // Nested routers inherit parent params via a shallow copy, so existing - // consumers keep seeing merged params while mutations stay confined to - // this level's own object (restored on cleanup/error). - const inherited = req.params - if (inherited === undefined && Object.keys(params).length === 0) { - req.params = Object.create(null) - } else { - req.params = { ...(inherited || {}), ...(params || {}) } - } + // Snapshot the request state this lookup is about to change (URL rewrite + + // params). Cleanup and error paths restore from the snapshot β€” closure + // semantics, so deeper nesting levels cannot corrupt an outer restore. + const context = { + hasUrlContext: req.preRouterUrl !== undefined, + url: req.preRouterUrl, + path: req.preRouterPath, + params: inherited + } - return next(middlewares, req, res, 0, routers, defaultRoute, errorHandlerWithCleanup) + let middlewares + if (step !== undefined) { + // Create new array only when step middleware is needed + middlewares = handlers.slice() + middlewares.push(createCleanupMiddleware(step, context)) } else { - defaultRoute(req, res) + middlewares = handlers + } + + // 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 and are not silently handled by the + // nested router's own default error handler. + const activeErrorHandler = step?.errorHandler || errorHandler + + // Wrap the active error handler so request-state restoration happens + // before the handler is invoked, and so a throwing handler can never + // take the process down. + const errorHandlerWithCleanup = (err, req, res) => { + // Restore URL context only: error handlers are terminal, so keep the + // matched route's params available to the handler. + restoreNestedUrlContext(req, context) + return dispatchError(activeErrorHandler, err, req, res) } + + return next(middlewares, req, res, 0, routers, defaultRoute, errorHandlerWithCleanup) } /** diff --git a/lib/router/trie.d.ts b/lib/router/trie.d.ts new file mode 100644 index 0000000..844e3fd --- /dev/null +++ b/lib/router/trie.d.ts @@ -0,0 +1,16 @@ +import { Protocol, IRouter } from './../../common' + +export interface TrieRouterConfig { + /** Handler for requests no route matched. Default: 404 with an empty body. */ + defaultRoute?: (req: any, res: any) => void + /** Handler invoked for sync throws, rejected promises and next(err). */ + errorHandler?: (err: unknown, req: any, res: any) => void | Promise + /** Router identifier used when mounted as a nested router. */ + id?: string + /** Match paths case-sensitively. Default: true. */ + caseSensitive?: boolean + /** Treat `/users/` and `/users` as the same path. Default: true. */ + ignoreTrailingSlash?: boolean +} + +export default function createTrieRouter

    (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\/(?[^/]+)$/, () => {}) + router.get('/plain', () => {}) + expect(router.find('GET', '/re/abc').params).to.deep.equal({ slug: 'abc' }) + const first = router.find('GET', '/plain').params + expect(first).to.deep.equal({}) + expect(Object.isFrozen(first)).to.equal(true) + expect(router.find('GET', '/plain').params).to.equal(first) + }) + + it('gives every request its own mutable req.params', () => { + const router = sequential() + const seen = [] + router.get('/plain', (req, res) => { req.params.touched = true; seen.push(req.params); res.end() }) + router.lookup({ method: 'GET', url: '/plain' }, fakeRes()) + router.lookup({ method: 'GET', url: '/plain' }, fakeRes()) + expect(seen[0]).to.not.equal(seen[1]) + expect(Object.keys(seen[1])).to.deep.equal(['touched']) + }) + + it('normalizes g/y RegExp flags on all() and on every method shortcut', () => { + const router = sequential({ cacheSize: 0 }) + router.all(/^\/g$/g, () => {}) + router.trace(/^\/y$/y, () => {}) + for (let i = 0; i < 5; i++) { + expect(router.find('GET', '/g').handlers).to.have.lengthOf(1) + expect(router.find('TRACE', '/y').handlers).to.have.lengthOf(1) + } + expect(router.routes.every(r => !r.pattern.global && !r.pattern.sticky)).to.equal(true) + }) + + it('normalizes g/y flags on use() prefixes too', () => { + const router = sequential({ cacheSize: 0 }) + router.use(/^\/g/g, () => {}) + for (let i = 0; i < 4; i++) expect(router.find('GET', '/g/x').handlers).to.have.lengthOf(1) + }) + + it('keeps the query string out of a nested router path (dynamic prefix)', () => { + const child = sequential({ id: 'c' }) + const seen = [] + child.get('/admin', (req, res) => { seen.push('ADMIN'); res.end() }) + child.use((req, res) => { seen.push(req.url); res.end() }) + const parent = sequential().use('/api/:tenant', child) + parent.lookup({ method: 'GET', url: '/api/acme?x=/admin' }, { end () {} }) + parent.lookup({ method: 'GET', url: '/api/acme/admin' }, { end () {} }) + expect(seen).to.deep.equal(['/?x=/admin', 'ADMIN']) + }) + + it('mounts one router at two prefixes and contains a throwing defaultRoute', () => { + const child = sequential({ id: 'twice' }) + const seen = [] + child.get('/x', (req, res) => { seen.push(req.url); res.end() }) + const parent = sequential().use('/legacy', child).use('/v1', child) + parent.lookup({ method: 'GET', url: '/v1/x' }, { end () {} }) + parent.lookup({ method: 'GET', url: '/legacy/x' }, { end () {} }) + expect(seen).to.deep.equal(['/x', '/x']) + const bodies = [] + const r = sequential({ defaultRoute: () => { throw new Error('d404') }, errorHandler: (e, req, res) => res.end('EH:' + e.message) }) + r.lookup({ method: 'GET', url: '/nothing' }, { statusCode: 200, headersSent: false, setHeader () {}, end (b) { bodies.push(b) } }) + expect(bodies).to.deep.equal(['EH:d404']) + }) + + it('falls back to the regex for routes registered behind the wrapper', () => { + const router = sequential({ cacheSize: 0 }) + Trouter.prototype.add.call(router, 'GET', '/raw', () => {}) + expect(router.routes[0].min).to.equal(undefined) + expect(router.find('GET', '/raw').handlers).to.have.lengthOf(1) + expect(router.find('GET', '/raw/').handlers).to.have.lengthOf(1) + expect(router.find('GET', '/rawx').handlers).to.have.lengthOf(0) + }) + }) + + describe('lookup: error handler bubbling through a manually driven child', () => { + it('uses the parent errorHandler when middleware calls child.lookup(req, res, next)', async () => { + const parent = sequential({ errorHandler: (err, req, res) => res.end('PARENT:' + err.message) }) + const child = sequential({ errorHandler: (err, req, res) => res.end('CHILD:' + err.message) }) + child.get('/boom', () => { throw new Error('sync') }) + child.get('/async', async () => { throw new Error('async') }) + parent.use((req, res, next) => child.lookup(req, res, next)) + const bodies = [] + const mk = () => ({ statusCode: 200, headersSent: false, setHeader () {}, end (b) { bodies.push(b) } }) + parent.lookup({ method: 'GET', url: '/boom' }, mk()) + await parent.lookup({ method: 'GET', url: '/async' }, mk()) + expect(bodies).to.deep.equal(['PARENT:sync', 'PARENT:async']) + }) + }) + + describe('lookup: top-level fast path keeps error semantics', () => { + it('routes sync and async errors to the configured errorHandler', async () => { + const errors = [] + const router = sequential({ errorHandler: (err, req, res) => { errors.push(err.message); res.end() } }) + router.get('/sync', () => { throw new Error('sync') }) + router.get('/async', async () => { throw new Error('async') }) + router.get('/next', (req, res, next) => next(new Error('next'))) + router.lookup({ method: 'GET', url: '/sync' }, fakeRes()) + await router.lookup({ method: 'GET', url: '/async' }, fakeRes()) + router.lookup({ method: 'GET', url: '/next' }, fakeRes()) + expect(errors).to.deep.equal(['sync', 'async', 'next']) + }) + + it('survives a throwing errorHandler at the top level', () => { + const router = sequential({ errorHandler: () => { throw new Error('boom') } }) + router.get('/x', () => { throw new Error('x') }) + const res = { headersSent: false, statusCode: 0, body: '', setHeader () {}, end (b) { this.body = b } } + expect(() => router.lookup({ method: 'GET', url: '/x' }, res)).to.not.throw() + expect(res.statusCode).to.equal(500) + expect(res.body).to.equal('Internal Server Error') + }) + }) +}) diff --git a/tests/trie-router.test.js b/tests/trie-router.test.js new file mode 100644 index 0000000..6af1b33 --- /dev/null +++ b/tests/trie-router.test.js @@ -0,0 +1,384 @@ +/* global describe, it, before, after */ +const expect = require('chai').expect +const request = require('supertest') +const cero = require('../index') +const trie = require('../lib/router/trie') + +const fakeRes = () => ({ statusCode: 200, headersSent: false, end () {}, write () {}, setHeader () {} }) +const names = (router, method, url) => router.find(method, url).handlers.map(h => h.n) +const h = (n) => { const f = (req, res, next) => next(); f.n = n; return f } + +describe('0http - trie router', () => { + describe('matching semantics', () => { + it('chains every matching route and middleware in registration order', () => { + const r = trie() + r.use(h('mw')).get('/users/:id', h('param')).get('/users/me', h('me')).use('/users', h('users-mw')).all('/users/me', h('all')) + expect(names(r, 'GET', '/users/me')).to.deep.equal(['mw', 'param', 'me', 'users-mw', 'all']) + expect(names(r, 'GET', '/users/42')).to.deep.equal(['mw', 'param', 'users-mw']) + expect(names(r, 'POST', '/users/me')).to.deep.equal(['mw', 'users-mw', 'all']) + expect(names(r, 'GET', '/users')).to.deep.equal(['mw', 'users-mw']) + expect(names(r, 'GET', '/usersx')).to.deep.equal(['mw']) + }) + + it('falls back from HEAD to GET handlers', () => { + const r = trie().get('/x', h('get')).post('/x', h('post')) + expect(names(r, 'HEAD', '/x')).to.deep.equal(['get']) + expect(names(r, 'PUT', '/x')).to.deep.equal([]) + }) + + it('captures params, optional params, suffix params and wildcards', () => { + const r = trie() + r.get('/a/:x/b/:y', h(1)).get('/opt/:a?', h(2)).get('/img/:name.png', h(3)).get('/img/:name.tar.gz', h(4)).get('/files/*', h(5)) + expect(r.find('GET', '/a/1/b/2').params).to.deep.equal({ x: '1', y: '2' }) + expect(r.find('GET', '/opt').params).to.deep.equal({}) + expect(r.find('GET', '/opt/v').params).to.deep.equal({ a: 'v' }) + expect(r.find('GET', '/img/pic.png').params).to.deep.equal({ name: 'pic' }) + expect(r.find('GET', '/img/a.b.png').params).to.deep.equal({ name: 'a.b' }) + expect(r.find('GET', '/img/x.tar.gz').params).to.deep.equal({ name: 'x' }) + expect(names(r, 'GET', '/img/.png')).to.deep.equal([]) + expect(names(r, 'GET', '/img/pic.jpg')).to.deep.equal([]) + expect(r.find('GET', '/files/a/b/c').params).to.deep.equal({ '*': 'a/b/c' }) + expect(r.find('GET', '/files/a/b/').params).to.deep.equal({ '*': 'a/b/' }) + expect(r.find('GET', '/files/').params).to.deep.equal({ '*': '' }) + expect(r.find('GET', '/files').params).to.deep.equal({ '*': '' }) + }) + + it('matches the root route for "/" and for an empty url', () => { + const r = trie().get('/', h('root')) + expect(names(r, 'GET', '/')).to.deep.equal(['root']) + expect(names(r, 'GET', '')).to.deep.equal(['root']) + expect(names(r, 'GET', '/x')).to.deep.equal([]) + }) + + it('treats static segments literally (no regex meaning)', () => { + const r = trie().get('/a+b', h(1)).get('/file.json', h(2)).get('/(x)', h(3)) + expect(names(r, 'GET', '/a+b')).to.deep.equal([1]) + expect(names(r, 'GET', '/aab')).to.deep.equal([]) + expect(names(r, 'GET', '/file.json')).to.deep.equal([2]) + expect(names(r, 'GET', '/filexjson')).to.deep.equal([]) + expect(names(r, 'GET', '/(x)')).to.deep.equal([3]) + }) + + it('is case-sensitive by default and case-insensitive on request', () => { + const r = trie().get('/Users/:id', h(1)) + expect(names(r, 'GET', '/Users/AbC')).to.deep.equal([1]) + expect(names(r, 'GET', '/users/AbC')).to.deep.equal([]) + const ci = trie({ caseSensitive: false }).get('/Users/:id', h(1)).get('/img/:n.PNG', h(2)) + expect(names(ci, 'GET', '/USERS/AbC')).to.deep.equal([1]) + expect(ci.find('GET', '/USERS/AbC').params).to.deep.equal({ id: 'AbC' }) + expect(ci.find('GET', '/IMG/Pic.png').params).to.deep.equal({ n: 'Pic' }) + // ASCII-only folding: non-ASCII letters are neither folded nor altered. + expect(ci.find('GET', '/users/\u0130x').params.id).to.equal('\u0130x') + expect(names(ci, 'GET', '/\u212Aeys')).to.deep.equal([]) // Kelvin sign is not 'k' + }) + + it('matches nothing for a path without a leading slash', () => { + const r = trie().use(h('mw')).get('/x', h(1)).get('/:p', h(2)).options('/:p', h(3)) + expect(names(r, 'GET', 'x')).to.deep.equal([]) + expect(names(r, 'OPTIONS', '*')).to.deep.equal([]) + expect(names(r, 'GET', '')).to.deep.equal(['mw']) + }) + + it('runs a route with optional params exactly once, preferring present params', () => { + const r = trie() + r.use('/a/:b?', h('mw')).get('/s/:category?/:page?', h('s')).get('/:a?/*', h('w')).get('/:x?/:y?/:z?', h('xyz')) + expect(names(r, 'GET', '/a/x/y')).to.deep.equal(['mw', 'w', 'xyz']) + expect(r.find('GET', '/a/x/y').params).to.deep.equal({ b: 'x', a: 'a', '*': 'x/y', x: 'a', y: 'x', z: 'y' }) + const one = trie().get('/s/:category?/:page?', h('s')) + expect(names(one, 'GET', '/s/books')).to.deep.equal(['s']) + expect(one.find('GET', '/s/books').params).to.deep.equal({ category: 'books' }) + expect(one.find('GET', '/s').params).to.deep.equal({}) + const sixteen = trie().get('/' + Array.from({ length: 16 }, (_, i) => `:p${i}?`).join('/'), h('p')) + expect(names(sixteen, 'GET', '/a/b')).to.deep.equal(['p']) + expect(sixteen.find('GET', '/a/b').params).to.deep.equal({ p0: 'a', p1: 'b' }) + const child = trie({ id: 'c' }) + const seen = [] + child.use((req, res, next) => { seen.push(req.url); next() }) + const parent = trie().use('/m/:v?', child) + parent.lookup({ method: 'GET', url: '/m/1/z' }, fakeRes()) + expect(seen).to.deep.equal(['/z']) + }) + + it('rejects a non-trailing "*" and a "?" that is not last in a param segment', () => { + expect(() => trie().get('/a/*/b', h(1))).to.throw(TypeError) + expect(() => trie().get('/i/:name?.png', h(1))).to.throw(TypeError) + const r = trie().get('/i/:name.png?', h(1)) + expect(r.find('GET', '/i/x.png').params).to.deep.equal({ name: 'x' }) + expect(names(r, 'GET', '/i')).to.deep.equal([1]) + }) + + it('registers thousands of routes in linear time', () => { + const r = trie() + const started = process.hrtime.bigint() + for (let i = 0; i < 10000; i++) r.get(`/r/${i}`, h(i)) + expect(Number(process.hrtime.bigint() - started) / 1e6).to.be.below(1000) + expect(names(r, 'GET', '/r/9999')).to.deep.equal([9999]) + }) + + it('ignores a trailing slash by default and honours ignoreTrailingSlash: false', () => { + const r = trie().get('/a', h(1)).get('/b/:id', h(2)) + expect(names(r, 'GET', '/a/')).to.deep.equal([1]) + expect(names(r, 'GET', '/b/1/')).to.deep.equal([2]) + const strict = trie({ ignoreTrailingSlash: false }).get('/a', h(1)).use('/p', h(2)).get('/w/*', h(3)).get('/dir/', h(4)).get('/', h(5)) + expect(names(strict, 'GET', '/a')).to.deep.equal([1]) + expect(names(strict, 'GET', '/a/')).to.deep.equal([]) + expect(names(strict, 'GET', '/p/')).to.deep.equal([2]) + expect(strict.find('GET', '/w/').params).to.deep.equal({ '*': '' }) + expect(names(strict, 'GET', '/dir/')).to.deep.equal([4]) + expect(names(strict, 'GET', '/dir')).to.deep.equal([]) + expect(names(strict, 'GET', '/')).to.deep.equal([5]) + // the static table honours the registered form in strict mode + const sres = { end () {}, statusCode: 200 } + strict.lookup({ method: 'GET', url: '/dir' }, sres) + expect(sres.statusCode).to.equal(404) + const hits = [] + strict.get('/dir/', (req, res) => { hits.push(req.path); res.end() }) + strict.lookup({ method: 'GET', url: '/dir/' }, { end () {}, statusCode: 200 }) + expect(hits).to.deep.equal(['/dir/']) + }) + + it('strips a RegExp mount prefix and supports one router mounted at two prefixes', () => { + const child = trie({ id: 'c' }) + const seen = [] + child.get('/x', (req, res) => { seen.push(req.url); res.end() }) + const r = trie().use(/^\/re/, child) + r.lookup({ method: 'GET', url: '/re/x?q=1' }, fakeRes()) + const twice = trie({ id: 't' }).get('/x', (req, res) => { seen.push(req.url); res.end() }) + const parent = trie().use('/legacy-api', twice).use('/v1', twice) + parent.lookup({ method: 'GET', url: '/v1/x' }, fakeRes()) + parent.lookup({ method: 'GET', url: '/legacy-api/x' }, fakeRes()) + expect(seen).to.deep.equal(['/x?q=1', '/x', '/x']) + }) + + it('does not share request-specific captures through the static table in case-insensitive mode', () => { + const ci = trie({ caseSensitive: false }).get('/users/me', h('me')).get('/users/:id', h('id')) + const seen = [] + ci.get('/users/me', (req, res) => { seen.push(req.params.id); res.end() }) + for (const u of ['/users/ME', '/users/me', '/users/Me']) ci.lookup({ method: 'GET', url: u }, fakeRes()) + expect(seen).to.deep.equal(['ME', 'me', 'Me']) + }) + + it('supports RegExp routes with named groups and normalizes g/y flags', () => { + const r = trie().get(/^\/re\/(?[^/]+)$/g, h(1)).all(/^\/any/y, h(2)) + for (let i = 0; i < 3; i++) { + expect(r.find('GET', '/re/abc').params).to.deep.equal({ slug: 'abc' }) + expect(names(r, 'POST', '/anything')).to.deep.equal([2]) + } + expect(r.routes.every(x => !x.pattern.global && !x.pattern.sticky)).to.equal(true) + }) + + it('ignores empty route segments and rejects non-string patterns', () => { + const r = trie().get('/a//b/', h(1)) + expect(names(r, 'GET', '/a/b')).to.deep.equal([1]) + expect(() => r.get(42, h(2))).to.throw(TypeError) + }) + + it('never recurses deeper than the registered routes on long paths', () => { + const r = trie().use(h('mw')).get('/a/:b/c', h(1)) + const deep = '/a' + '/x'.repeat(20000) + expect(names(r, 'GET', deep)).to.deep.equal(['mw']) + expect(names(r, 'GET', '/'.repeat(20000))).to.deep.equal(['mw']) + }) + }) + + describe('params safety', () => { + it('gives every request a fresh params object without Object.prototype', () => { + const r = trie() + const seen = [] + r.get('/u/:id', (req, res) => { seen.push(req.params); req.params.extra = 1; res.end() }) + r.lookup({ method: 'GET', url: '/u/1' }, fakeRes()) + r.lookup({ method: 'GET', url: '/u/1' }, fakeRes()) + expect(seen[0]).to.not.equal(seen[1]) + expect(seen[1].extra).to.equal(1) + expect(Object.keys(seen[1])).to.deep.equal(['id', 'extra']) + expect('constructor' in seen[1]).to.equal(false) + expect(Object.getPrototypeOf(Object.getPrototypeOf(seen[1]))).to.equal(null) + }) + + it('cannot pollute Object.prototype through a __proto__ route param', () => { + const r = trie().get('/p/:__proto__', (req, res) => res.end()) + r.lookup({ method: 'GET', url: '/p/polluted' }, fakeRes()) + expect(({}).polluted).to.equal(undefined) + }) + + it('merges inherited params without mutating the parent object', () => { + const r = trie().get('/u/:id', (req, res) => { res.content = req.params; res.end() }) + const parent = { parent: 'p' } + const res = fakeRes() + r.lookup({ method: 'GET', url: '/u/9', params: parent }, res) + expect(res.content).to.deep.equal({ parent: 'p', id: '9' }) + expect(res.content).to.not.equal(parent) + expect(parent).to.deep.equal({ parent: 'p' }) + }) + }) + + describe('static table', () => { + it('is compiled on demand and invalidated when routes change', () => { + const r = trie().get('/s', h('s')) + expect(names(r, 'GET', '/s')).to.deep.equal(['s']) + const seen = [] + const probe = (req, res) => { seen.push(names(r, 'GET', req.path)); res.end() } + r.get('/s', probe) + r.lookup({ method: 'GET', url: '/s' }, fakeRes()) + r.use(h('late')) + r.lookup({ method: 'GET', url: '/s' }, fakeRes()) + expect(seen[0]).to.deep.equal(['s', undefined]) + expect(seen[1]).to.deep.equal(['s', undefined, 'late']) + }) + + it('does not grow with unknown paths or unknown method tokens', function () { + if (!global.gc) return this.skip() + const r = trie().use((req, res) => res.end()).get('/static', (req, res) => res.end()) + global.gc() + const before = process.memoryUsage().heapUsed + for (let i = 0; i < 50000; i++) { + r.lookup({ method: 'GET', url: `/junk/${i}/` + 'x'.repeat(200) }, fakeRes()) + r.lookup({ method: 'M' + i, url: '/static' }, fakeRes()) + } + global.gc() + expect(process.memoryUsage().heapUsed - before).to.be.below(8 * 1024 * 1024) + }) + }) + + describe('end to end with nested routers and errors', () => { + let server, base + const log = [] + before((done) => { + const app = cero({ router: trie({ errorHandler: (err, req, res) => { res.statusCode = 500; res.end('handled:' + err.message) } }) }) + const { router } = app + router.use((req, res, next) => { log.push('pre ' + req.url); return next() }) + router.get('/hello/:name', (req, res) => res.end(`hi ${req.params.name} ${JSON.stringify(req.query)}`)) + router.get('/sync', () => { throw new Error('sync') }) + router.get('/async', async () => { throw new Error('async') }) + router.get('/next', (req, res, next) => next(new Error('next'))) + router.get('/late', (req, res) => { res.write('partial'); throw new Error('late') }) + + const api = trie({ id: 'api' }) + const v1 = trie({ id: 'v1' }) + v1.get('/', (req, res) => res.end(JSON.stringify({ url: req.url, params: req.params }))) + v1.get('/items/:id', (req, res) => res.end(JSON.stringify({ url: req.url, path: req.path, params: req.params, original: req.originalUrl }))) + v1.get('/boom', () => { throw new Error('nested') }) + v1.get('/through', (req, res, next) => next()) + api.use('/v1', v1) + router.use('/api/:tenant', api) + router.use('/api/:tenant', (req, res) => res.end(JSON.stringify({ url: req.url, params: req.params }))) + + const strictChild = trie({ id: 'child' }) + strictChild.get('/x', (req, res) => res.end('child-x')) + router.use('/mount', strictChild) + + server = app.server + server.listen(0, () => { base = `http://127.0.0.1:${server.address().port}`; done() }) + }) + after(() => { server.closeAllConnections?.(); server.close() }) + + it('routes params and query end to end', async () => { + const res = await request(base).get('/hello/bob?x=1&x=2') + expect(res.status).to.equal(200) + expect(res.text).to.equal('hi bob {"x":["1","2"]}') + }) + + it('rewrites and restores the url through two nested levels', async () => { + const res = await request(base).get('/api/acme/v1/items/7?q=1') + expect(res.status).to.equal(200) + expect(JSON.parse(res.text)).to.deep.equal({ url: '/items/7?q=1', path: '/items/7', params: { tenant: 'acme', id: '7' }, original: '/api/acme/v1/items/7?q=1' }) + }) + + it('restores url and params for the parent chain after a nested fall-through', async () => { + const res = await request(base).get('/api/acme/v1/through') + expect(res.status).to.equal(200) + expect(JSON.parse(res.text)).to.deep.equal({ url: '/api/acme/v1/through', params: { tenant: 'acme' } }) + }) + + it('mounts static prefixes', async () => { + expect((await request(base).get('/mount/x')).text).to.equal('child-x') + expect((await request(base).get('/mount/x/')).text).to.equal('child-x') + expect((await request(base).get('/mountx/x')).status).to.equal(404) + }) + + it('bubbles nested errors to the parent error handler', async () => { + const res = await request(base).get('/api/acme/v1/boom') + expect(res.status).to.equal(500) + expect(res.text).to.equal('handled:nested') + }) + + it('routes sync throws, async rejections and next(err) to the error handler', async () => { + for (const p of ['sync', 'async', 'next']) { + const res = await request(base).get('/' + p) + expect(res.status).to.equal(500) + expect(res.text).to.equal('handled:' + p) + } + }) + + it('survives an error thrown after headers were sent', async () => { + const crashes = [] + const onCrash = (e) => crashes.push(e) + process.on('uncaughtException', onCrash) + try { + const res = await request(base).get('/late') + expect(res.status).to.equal(200) + } finally { process.off('uncaughtException', onCrash) } + expect(crashes).to.deep.equal([]) + expect((await request(base).get('/hello/again')).status).to.equal(200) + }) + + it('returns 404 from the default route and keeps middleware order', async () => { + log.length = 0 + const res = await request(base).get('/nothing/here') + expect(res.status).to.equal(404) + expect(log).to.deep.equal(['pre /nothing/here']) + }) + + it('never lets a query string become the nested router path', async () => { + const res = await request(base).get('/api/acme/v1?x=/boom') + expect(res.status).to.equal(200) + expect(JSON.parse(res.text)).to.deep.equal({ url: '/?x=/boom', params: { tenant: 'acme' } }) + const res2 = await request(base).get('/api/acme/v1/items/7?x=/boom') + expect(JSON.parse(res2.text).url).to.equal('/items/7?x=/boom') + }) + + it('contains a user error handler that writes after the response ended', async () => { + const app = cero({ router: trie({ errorHandler: (err, req, res) => { res.statusCode = 500; res.end('handled:' + err.message) } }) }) + app.router.get('/end-then-throw', (req, res) => { res.end('done'); throw new Error('late') }) + app.router.get('/end-then-next', (req, res, next) => { res.end('done'); next(new Error('late')) }) + await new Promise(resolve => app.server.listen(0, resolve)) + const b = `http://127.0.0.1:${app.server.address().port}` + const crashes = [] + const onCrash = (e) => crashes.push(e) + process.on('uncaughtException', onCrash) + try { + for (const p of ['/end-then-throw', '/end-then-next']) { + const res = await request(b).get(p) + expect(res.text).to.equal('done') + } + await new Promise(resolve => setTimeout(resolve, 20)) + } finally { + process.off('uncaughtException', onCrash) + app.server.closeAllConnections?.(); app.server.close() + } + expect(crashes).to.deep.equal([]) + }) + + it('contains a throwing or rejecting defaultRoute', async () => { + const bodies = [] + const mk = () => ({ statusCode: 200, headersSent: false, setHeader () {}, end (b) { bodies.push(b) } }) + const r = trie({ defaultRoute: () => { throw new Error('d404') }, errorHandler: (e, req, res) => res.end('EH:' + e.message) }) + r.lookup({ method: 'GET', url: '/nothing' }, mk()) + const ra = trie({ defaultRoute: async () => { throw new Error('a404') }, errorHandler: (e, req, res) => res.end('EH:' + e.message) }) + await ra.lookup({ method: 'GET', url: '/nothing' }, mk()) + const chain = trie({ defaultRoute: () => { throw new Error('c404') }, errorHandler: (e, req, res) => res.end('EH:' + e.message) }) + chain.use((req, res, next) => next()) + chain.lookup({ method: 'GET', url: '/nothing' }, mk()) + expect(bodies).to.deep.equal(['EH:d404', 'EH:a404', 'EH:c404']) + }) + + it('survives a throwing custom error handler with a bare 500', () => { + const r = trie({ errorHandler: () => { throw new Error('broken handler') } }) + r.get('/x', () => { throw new Error('x') }) + const res = { headersSent: false, statusCode: 0, body: '', setHeader () {}, end (b) { this.body = b } } + expect(() => r.lookup({ method: 'GET', url: '/x' }, res)).to.not.throw() + expect(res.statusCode).to.equal(500) + expect(res.body).to.equal('Internal Server Error') + }) + }) +})