Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b21c685
feat: add _mapRoutes method to list registered routes
bjohansebas Jul 28, 2025
73fae83
fix: don't create double slashes when mounting at root
bjohansebas Jul 28, 2025
f491e42
[WIP] include route options
bjohansebas Jul 29, 2025
9b19b4d
include keys for parameter routes
bjohansebas Jul 29, 2025
440a46e
rename function
bjohansebas Jul 29, 2025
faac557
Let the routes be repeated so that consumers can decide what to do, i…
bjohansebas Jul 29, 2025
16b6c77
docs: add docs for this new api
bjohansebas Jul 29, 2025
856dff1
update route collection structure in getRoutes
bjohansebas Nov 1, 2025
02c672d
test: unskip more test
bjohansebas Nov 1, 2025
2b19160
test: unskip more test
bjohansebas Nov 2, 2025
45f5da5
test: unskip more test
bjohansebas Nov 2, 2025
4f611f4
feat: add 'end' option to route options
bjohansebas Nov 2, 2025
376f83d
no normalice paths
bjohansebas Nov 2, 2025
62f14bb
fix: remove 'end' option from route options in collectRoutes function
bjohansebas Nov 2, 2025
306eefd
feat: add route name to collected routes
bjohansebas Nov 8, 2025
e75bcb7
feat: enhance route key extraction for regex and dynamic routes
bjohansebas Nov 9, 2025
6c071e7
docs: update jsdocs and readmed
bjohansebas Nov 9, 2025
5e71b5a
feat: simplify to minimal listRoutes() API
bjohansebas Jul 15, 2026
99d3a49
fix: make listRoutes methods output match dispatch behavior
bjohansebas Jul 15, 2026
84a6cf2
fix: harden mounted-router detection and path snapshotting in listRoutes
bjohansebas Jul 15, 2026
f51173f
docs: document empty methods and prefix matching in listRoutes
bjohansebas Jul 15, 2026
25376b8
fix: flatten nested path arrays and move _all filtering into Route
bjohansebas Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,54 @@ router.param('user_id', function (req, res, next, id) {
})
```

### router.listRoutes()

Returns an array with one `{ path, methods, router }` object per path
registered on this router, in registration order.

- `path` is the path the route was registered with (a string or a `RegExp`).
Routes registered with an array of paths produce one entry per path.
- `methods` is an array of the uppercase HTTP method names the route responds
to, including the automatic `HEAD` for `GET` routes. It is `undefined` when
the entry matches all methods (routes registered only with `.all()`, and
mounted routers). Routes that combine `.all()` with specific methods list
those methods. An empty array means the route was created with
`router.route(path)` but has no handlers yet, so it matches no method.
- `router` is the mounted router instance for `.use(path, router)` entries,
otherwise `undefined`. Nested routes are not resolved recursively; consumers
can recurse themselves by calling `router.listRoutes()` when the mounted
router implements it. Note that `.use()`
entries match their path as a prefix, while route entries match the full
path.

Plain middleware functions registered with `.use()` are not listed.

```js
const router = new Router()
const admin = new Router()

admin.get('/', (req, res) => {
res.end('Hello')
})

router.use('/admin', admin)

router.all('/:id', function (req, res) {
res.end('Hello')
})

console.log(router.listRoutes())
// [
// { path: '/admin', methods: undefined, router: admin },
// { path: '/:id', methods: undefined, router: undefined }
// ]

console.log(admin.listRoutes())
// [
// { path: '/', methods: ['GET', 'HEAD'], router: undefined }
// ]
```

### router.route(path)

Creates an instance of a single `Route` for the given `path`.
Expand Down
48 changes: 48 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,54 @@ Router.prototype.route = function route (path) {
return route
}

/**
* List the routes and mounted routers registered on this router.
*
* Returns one `{ path, methods, router }` object per registered path.
* `methods` lists the methods the route responds to, including the
* automatic `HEAD` for `GET` routes, and is `undefined` when the layer
* matches all methods (routes registered only with `.all()`, and
* mounted routers). Plain middleware is not listed. `router` is the
* mounted router instance for
* `.use(path, router)` layers, so consumers can recurse by calling
* `router.listRoutes()` themselves when it is available.
*
* @return {Array<{path: string|RegExp, methods: Array<string>|undefined, router: Router|undefined}>}
* @public
*/
Router.prototype.listRoutes = function listRoutes () {
const routes = []

for (const layer of this.stack) {
const route = layer.route
const router = !route && Array.isArray(layer.handle.stack)
? layer.handle
: undefined

if (!route && !router) {
continue
}

let methods
if (route) {
methods = route._methods()
if (methods.length === 0 && route.methods._all) {
methods = undefined
}
}

if (Array.isArray(layer.rawPath)) {
for (const path of layer.rawPath) {
routes.push({ path, methods: methods && methods.slice(), router })
}
} else {
routes.push({ path: layer.rawPath, methods, router })
}
}

return routes
}

// create Router#VERB functions
methods.concat('all').forEach(function (method) {
Router.prototype[method] = function (path) {
Expand Down
2 changes: 2 additions & 0 deletions lib/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function Layer (path, options, fn) {
this.name = fn.name || '<anonymous>'
this.params = undefined
this.path = undefined
// flat unnests nested path arrays and snapshots the caller's array
this.rawPath = Array.isArray(path) ? path.flat(Infinity) : path
this.slash = path === '/' && opts.end === false

function matcher (_path) {
Expand Down
2 changes: 1 addition & 1 deletion lib/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Route.prototype._handlesMethod = function _handlesMethod (method) {
*/

Route.prototype._methods = function _methods () {
const methods = Object.keys(this.methods)
const methods = Object.keys(this.methods).filter((method) => method !== '_all')

// append automatic head
if (this.methods.get && !this.methods.head) {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
},
"scripts": {
"lint": "standard",
"test": "mocha --reporter spec --bail --check-leaks test/",
"test:debug": "mocha --reporter spec --bail --check-leaks test/ --inspect --inspect-brk",
"test": "mocha --reporter spec --check-leaks test/",
"test:debug": "mocha --reporter spec --check-leaks test/ --inspect --inspect-brk",
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
"test-cov": "nyc --reporter=text npm test",
"version": "node scripts/version-history.js && git add HISTORY.md"
Expand Down
249 changes: 249 additions & 0 deletions test/listRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
const { it, describe } = require('mocha')
const Router = require('..')
const utils = require('./support/utils')

const assert = utils.assert

describe('listRoutes', function () {
it('should return an empty array when no routes are registered', function () {
const router = new Router()

assert.deepStrictEqual(router.listRoutes(), [])
})

it('should list routes for strings, regexps, arrays, and parameterized paths', function () {
const router = new Router()

router.get('/foo', noop)
router.post('/:id/setting/:thing', noop)
router.all(/^\/[a-z]oo$/, noop)
router.get(['/bar', '/baz'], noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/foo', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/:id/setting/:thing', methods: ['POST'], router: undefined },
{ path: /^\/[a-z]oo$/, methods: undefined, router: undefined },
{ path: '/bar', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/baz', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should include the automatic HEAD method for GET routes, but not duplicate an explicit one', function () {
const router = new Router()

router.get('/implicit', noop)
router.route('/explicit').get(noop).head(noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/implicit', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/explicit', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should list all methods registered on a route', function () {
const router = new Router()

router.route('/test')
.get(noop)
.post(noop)
.put(noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/test', methods: ['GET', 'POST', 'PUT', 'HEAD'], router: undefined }
])
})

it('should return empty methods for routes created without handlers', function () {
const router = new Router()

router.route('/draft')

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/draft', methods: [], router: undefined }
])
})

it('should return undefined methods for .all() routes', function () {
const router = new Router()

router.all('/test', noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/test', methods: undefined, router: undefined }
])
})

it('should keep the specific methods of routes that combine .all() with verbs', function () {
const router = new Router()

router.route('/users')
.all(noop)
.get(noop)
.post(noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/users', methods: ['GET', 'POST', 'HEAD'], router: undefined }
])
})

it('should repeat routes registered multiple times', function () {
const router = new Router()

router.get('/test', noop)
router.get('/test', noop)
router.post('/test', noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/test', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/test', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/test', methods: ['POST'], router: undefined }
])
})

it('should not share the methods array between entries of an array path', function () {
const router = new Router()

router.get(['/bar', '/baz'], noop)

const routes = router.listRoutes()

routes[0].methods.push('POST')

assert.deepStrictEqual(routes[1].methods, ['GET', 'HEAD'])
})

it('should not list plain middleware', function () {
const router = new Router()

router.use(noop)
router.use('/admin', noop)
router.get('/test', noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/test', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should flatten nested arrays of paths', function () {
const router = new Router()
const nested = ['/b']

router.get(['/a', nested], noop)
nested.push('/c')

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/a', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/b', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should not reflect later mutations of a registered path array', function () {
const router = new Router()
const paths = ['/a']

router.get(paths, noop)
paths.push('/b')

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/a', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should not list middleware that merely has a listRoutes property', function () {
const router = new Router()

function metrics (req, res, next) { next() }
metrics.listRoutes = () => 'not routes'

router.use('/status', metrics)

assert.deepStrictEqual(router.listRoutes(), [])
})

it('should list mounted routers that do not implement listRoutes', function () {
const router = new Router()

function legacy (req, res, next) { next() }
legacy.stack = []

router.use('/legacy', legacy)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/legacy', methods: undefined, router: legacy }
])
})

it('should expose mounted routers without recursing', function () {
const router = new Router()
const inner = new Router()

inner.get('/api', noop)
router.use('/inner', inner)
router.get('/test', noop)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/inner', methods: undefined, router: inner },
{ path: '/test', methods: ['GET', 'HEAD'], router: undefined }
])

assert.deepStrictEqual(inner.listRoutes(), [
{ path: '/api', methods: ['GET', 'HEAD'], router: undefined }
])
})

it('should use the default path when mounting a router without a path', function () {
const router = new Router()
const inner = new Router()

inner.get('/api', noop)
router.use(inner)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/', methods: undefined, router: inner }
])
})

it('should list routers mounted at a RegExp path', function () {
const router = new Router()
const inner = new Router()

inner.get('/api', noop)
router.use(/^\/page_([0-9]+)/, inner)

assert.deepStrictEqual(router.listRoutes(), [
{ path: /^\/page_([0-9]+)/, methods: undefined, router: inner }
])
})

it('should list a mounted router once per path in an array', function () {
const router = new Router()
const inner = new Router()

router.use(['/foo', '/bar'], inner)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/foo', methods: undefined, router: inner },
{ path: '/bar', methods: undefined, router: inner }
])
})

it('should allow consumers to recurse into cyclic routers without blowing the stack', function () {
const router = new Router()
const inner = new Router()

inner.get('/api', noop)
router.use('/inner', inner)
inner.use('/loop', router)

assert.deepStrictEqual(router.listRoutes(), [
{ path: '/inner', methods: undefined, router: inner }
])

assert.deepStrictEqual(inner.listRoutes(), [
{ path: '/api', methods: ['GET', 'HEAD'], router: undefined },
{ path: '/loop', methods: undefined, router }
])
})
})

function noop () {}
Loading