Skip to content

Feature Request: Ability to list routes with method + middleware, one entry per HTTP method #6481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
infuzz opened this issue Apr 23, 2025 · 4 comments

Comments

@infuzz
Copy link

infuzz commented Apr 23, 2025

Feature Request: Ability to list routes with method + middleware, one entry per HTTP method

Use case & goal

In medium to large applications, it's often difficult to inspect the full list of declared routes, especially when dealing with multiple routers, dynamic parameters, and complex middleware stacks.

This feature would help developers:

  • Debug route registration and conflicts.
  • Generate API documentation.
  • Inspect which middleware are applied to each route.

Currently, developers have to dig into app._router.stack, which is undocumented and subject to change, or use third-party packages like express-list-endpoints. A native utility would make this more reliable and portable.

Example usage

const routes = app.getRoutes();

console.log(routes);
/*
[
  {
    method: 'GET',
    path: '/users',
    middlewares: ['authMiddleware']
  },
  {
    method: 'POST',
    path: '/users',
    middlewares: ['authMiddleware', 'validateUser']
  },
  {
    method: 'GET',
    path: '/posts/:id',
    middlewares: ['anonymous']
  }
]
*/

Each route-method combination would be a separate entry for clarity and ease of processing.

Expected behavior

  • List all declared routes in the application, including those inside routers (express.Router()).
  • Return one object per method/path combination.
  • Include the route method, full path, and the list of middleware names.
  • Should rely on public APIs if possible (or expose one).
  • Could be provided as app.getRoutes() or express.listRoutes(app).

Thanks for the amazing framework — would love to help improve the developer experience!

@destroyer22719
Copy link

you kind of can already do something similar but...
#3308 (comment)

@infuzz
Copy link
Author

infuzz commented Apr 24, 2025

I quickly (and not necessarily in an elegant way) adapted the script from the excellent package express-list-endpoints package, and it seems that this version work with Express 5. However, I’m not sure if the regular expressions are fully compatible with the new path-to-regexp@8.

The script currently returns a string list of all methods, routes, and their associated middlewares — not yet an array of objects, which would be more structured and convenient to work with.

This kind of functionality could be a great built-in feature in Express 5, helping developers map out routes and even build documentation or Swagger-like outputs more easily.

/*** a quick modification  (and not necessarily in an elegant way) from https://www.npmjs.com/package/express-list-endpoints ***/
/** 
 * @typedef {Object} Route
 * @property {Object} methods
 * @property {string | string[]} path
 * @property {any[]} stack
 *
 * @typedef {Object} Endpoint
 * @property {string} path Path name
 * @property {string[]} methods Methods handled
 * @property {string[]} middlewares Mounted middlewares
 */

const regExpToParseExpressPathRegExp = /^\/\^\\?\/?(?:(:?[\w\\.-]*(?:\\\/:?[\w\\.-]*)*)|(\(\?:\\?\/?\([^)]+\)\)))\\\/.*/
const regExpToReplaceExpressPathRegExpParams = /\(\?:\\?\/?\([^)]+\)\)/
const regexpExpressParamRegexp = /\(\?:\\?\\?\/?\([^)]+\)\)/g
const regexpExpressPathParamRegexp = /(:[^)]+)\([^)]+\)/g

const EXPRESS_ROOT_PATH_REGEXP_VALUE = '/^\\/?(?=\\/|$)/i'
const STACK_ITEM_VALID_NAMES = [
  'router',
  'bound dispatch',
  'mounted_app',
  '<anonymous>'
]

/**
 * Returns all the verbs detected for the passed route
 * @param {Route} route
 */
const getRouteMethods = function (route) {
    let methods = Object.keys(route.methods);
  
    methods = methods.filter((method) => method !== "_all");
    methods = methods.map((method) => method.toUpperCase());
  
    return methods[0] ? methods : ["ALL"];
  };

/**
 * Returns the names (or anonymous) of all the middlewares attached to the
 * passed route
 * @param {Route} route
 * @returns {string[]}
 */
const getRouteMiddlewares = function (route) {
  return route.stack.map((item) => {
    return item.name || item.handle.name || 'anonymous'
  })
}

/**
 * Returns true if found regexp related with express params
 * @param {string} expressPathRegExp
 * @returns {boolean}
 */
const hasParams = function (expressPathRegExp) {
  return regexpExpressParamRegexp.test(expressPathRegExp)
}

/**
 * @param {Route} route Express route object to be parsed
 * @param {string} basePath The basePath the route is on
 * @return {Endpoint[]} Endpoints info
 */
const parseExpressRoute = function (route, basePath) {
  const paths = []

  if (Array.isArray(route.path)) {
    paths.push(...route.path)
  } else {
    paths.push(route.path)
  }

  /** @type {Endpoint[]} */
  const endpoints = paths.map((path) => {
    const completePath = basePath && path === '/'
      ? basePath
      : `${basePath}${path}`

    /** @type {Endpoint} */
    const endpoint = {
      path: completePath.replace(regexpExpressPathParamRegexp, '$1'),
      methods: getRouteMethods(route),
      middlewares: getRouteMiddlewares(route)
    }

    return endpoint
  })

  return endpoints
}

/**
 * @param {RegExp} expressPathRegExp
 * @param {any[]} params
 * @returns {string}
 */
const parseExpressPath = function (expressPathRegExp, params) {
  let parsedRegExp = expressPathRegExp.toString()
  let expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp)
  let paramIndex = 0

  while (hasParams(parsedRegExp)) {
    const paramName = params[paramIndex].name
    const paramId = `:${paramName}`

    parsedRegExp = parsedRegExp
      .replace(regExpToReplaceExpressPathRegExpParams, (str) => {
        // Express >= 4.20.0 uses a different RegExp for parameters: it
        // captures the slash as part of the parameter. We need to check
        // for this case and add the slash to the value that will replace
        // the parameter in the path.
        if (str.startsWith('(?:\\/')) {
          return `\\/${paramId}`
        }

        return paramId
      })

    paramIndex++
  }

  if (parsedRegExp !== expressPathRegExp.toString()) {
    expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp)
  }

  const parsedPath = expressPathRegExpExec[1].replace(/\\\//g, '/')

  return parsedPath
}

/**
 * @param {import('express').Express | import('express').Router | any} app
 * @param {string} [basePath]
 * @param {Endpoint[]} [endpoints]
 * @returns {Endpoint[]}
 */
const parseEndpoints = function (app, basePath, endpoints) {
  const stack = app.stack || (app._router && app._router.stack)

  endpoints = endpoints || []
  basePath = basePath || ''

  if (!stack) {
    if (endpoints.length) {
      endpoints = addEndpoints(endpoints, [{
        path: basePath,
        methods: [],
        middlewares: []
      }])
    }
  } else {
    endpoints = parseStack(stack, basePath, endpoints)
  }

  return endpoints
}

/**
 * Ensures the path of the new endpoints isn't yet in the array.
 * If the path is already in the array merges the endpoints with the existing
 * one, if not, it adds them to the array.
 *
 * @param {Endpoint[]} currentEndpoints Array of current endpoints
 * @param {Endpoint[]} endpointsToAdd New endpoints to be added to the array
 * @returns {Endpoint[]} Updated endpoints array
 */
const addEndpoints = function (currentEndpoints, endpointsToAdd) {
    endpointsToAdd.forEach((endpoint) => {
      currentEndpoints.push({
        "#": currentEndpoints.length + 1,
        method: endpoint.methods[0],
        path: endpoint.path,
        middlewares: endpoint.middlewares
      });
    });
    return currentEndpoints;
  };

/**
 * @param {any[]} stack
 * @param {string} basePath
 * @param {Endpoint[]} endpoints
 * @returns {Endpoint[]}
 */
const parseStack = function (stack, basePath, endpoints) {
    stack.forEach((stackItem) => {
      if (stackItem.route) {
        const newEndpoints = parseExpressRoute(stackItem.route, basePath);
  
        endpoints = addEndpoints(endpoints, newEndpoints);
      } else {
        const isExpressPathRegexp = regExpToParseExpressPathRegExp.test(stackItem.regexp);
  
        let newBasePath = basePath;
  
        if (isExpressPathRegexp) {
          const parsedPath = parseExpressPath(stackItem.regexp, stackItem.keys);
  
          newBasePath += `/${parsedPath}`;
        } else if (!stackItem.path && stackItem.regexp && stackItem.regexp.toString() !== EXPRESS_ROOT_PATH_REGEXP_VALUE) {
          const regExpPath = ` RegExp(${stackItem.regexp}) `;
  
          newBasePath += `/${regExpPath}`;
        }
        if (STACK_ITEM_VALID_NAMES.includes(stackItem.name)) endpoints = parseEndpoints(stackItem.handle, newBasePath, endpoints);
        else {
          if (!useMiddlewares[newBasePath]) useMiddlewares[newBasePath] = [];
          useMiddlewares[newBasePath].push(stackItem.name);
        }
      }
    });
    return endpoints;
  };

/**
 * Returns an array of strings with all the detected endpoints
 * @param {import('express').Express | import('express').Router | any} app The express/router instance to get the endpoints from
 * @returns {Endpoint[]}
 */
let useMiddlewares = {};
const expressListEndpoints = function (app) {
    const endpoints = parseEndpoints(app);
    let ret = [];
    endpoints.forEach((endpoint, i) => {
      for (const path in useMiddlewares) {
        if (path && endpoint.path.startsWith(path)) endpoint.middlewares.splice(0, 0, ...(useMiddlewares[path] || []));
      }
      ret.push(`${String(i + 1).padEnd(4, ' ')} - ${(endpoint?.method || "").padEnd(6, ' ')} - ${endpoint.path.padEnd(30, ' ')} - ${endpoint.middlewares.join(", ")}`);
    });
    return ret;
  };
 
export default expressListEndpoints

It seems that many developers have tried to achieve the same goal using different approaches (stackoverflow).
All the solutions rely on some kind of reverse engineering to figure out how to navigate the Express stack. That’s why I believe the best approach would be to have a built-in function — to avoid misunderstandings and misinterpretations of how the Express stack works.

@infuzz
Copy link
Author

infuzz commented Apr 28, 2025

With the help of my AI friend 😉, I completely rewrote the listRoutes function, and overall everything is running really well! The only challenge left is detecting sub-router routes. I've explored many different approaches, but it seems very difficult — if not impossible — to retrieve the sub-route paths passed with statements like app.use("/subpath", router). It looks like these paths simply aren't available in the stack.

Any insights, tips, or ideas would be truly appreciated! It's an exciting problem to work on, and it really highlights how valuable it would be for Express 5 to offer a built-in way to list all registered routes, instead of having to dive deep and reverse-engineer the framework.

Thanks a lot in advance for any help!

/**
   * Extracts all routes from an Express 5.1.0 app or Router.
   * If options.format = true, returns an array of formatted strings;
   * otherwise returns an array of { method, route, middlewares }.
   *
   * @param {import('express').Application|import('express').Router} appOrRouter
   * @param {{ format?: boolean }} options
   * @returns {Array<{ method: string, route: string, middlewares: string[] }> | string[]}
   */
  listRoutes(appOrRouter, options = {}) {
    const routes = [];
    const root = appOrRouter._router || appOrRouter;
    const stack = root.stack || [];

    // Try to recover a real function name, even from anonymous/factory fns
    function getMiddlewareName(fn) {
      if (fn.name) return fn.name;
      const m = /^function\s*([\w$]+)/.exec(fn.toString());
      return m ? m[1] : "<anonymous>";
    }

    // Join two URL segments, ensuring exactly one "/" between them
    function joinPaths(a, b) {
      if (!b) return a;
      const A = a.endsWith("/") ? a.slice(0, -1) : a;
      const B = b.startsWith("/") ? b : "/" + b;
      return A + B;
    }

    // Given any Layer, figure out its "mount path" portion:
    //  • layer.path (Express 5 sets this on .use)
    //  • layer.route.path (for route layers)
    //  • fallback: parse layer.regexp.toString()
    function getLayerPath(layer) {
      if (layer.path != null) {
        return layer.path === "/" ? "" : layer.path;
      }
      if (layer.route && layer.route.path != null) {
        return layer.route.path === "/" ? "" : layer.route.path;
      }
      if (layer.regexp) {
        // fast_slash means "/"
        if (layer.regexp.fast_slash) return "";
        // strip off the /^  \/foo  \/?(?=\/|$) /i wrapper
        let str = layer.regexp
          .toString()
          .replace(/^\/\^/, "")
          .replace(/\\\/\?\(\?=\\\/\|\$\)\/i$/, "")
          .replace(/\\\//g, "/")
          .replace(/\\\./g, ".");
        // replace capture groups with :param
        const keys = (layer.keys || []).map((k) => `:${k.name}`);
        str = str.replace(/\((?:\?:)?[^\)]+\)/g, () => keys.shift() || "");
        return str.startsWith("/") ? str : "/" + str;
      }
      return "";
    }

    /**
     * Recursively walk a stack of Layers, accumulating:
     *  - `prefix` the URL so far ("/v7", then "/v7/webhooks", etc.)
     *  - `parentMW` the array of middleware names mounted above this point
     */
    function traverse(stack, prefix = "", parentMW = []) {
      stack.forEach((layer, idx) => {
        // ─── Case 1: a direct route (app.get / router.post / etc) ───
        if (layer.route) {
          const routePath = layer.route.path || "";
          const fullPath = joinPaths(prefix, routePath);
          const methods = Object.keys(layer.route.methods).map((m) => m.toUpperCase());
          const routeMW = layer.route.stack.map((l) => getMiddlewareName(l.handle));
          const allMW = parentMW.concat(routeMW);

          methods.forEach((method) => {
            routes.push({ method, route: fullPath, middlewares: allMW });
          });

          // ─── Case 2: a mounted router or .use(path, fn, ...) block ───
        } else if (layer.handle && layer.handle.stack) {
          const mountPath = getLayerPath(layer);
          const fullPref = joinPaths(prefix, mountPath);

          // gather any plain middleware functions mounted at this same mountPath
          const mountMW = stack
            .slice(0, idx)
            .filter(
              (prev) =>
                typeof prev.handle === "function" &&
                !prev.handle.stack && // not a router
                getLayerPath(prev) === mountPath // same mount path
            )
            .map((prev) => getMiddlewareName(prev.handle));

          // recurse into child stack, carrying forward middleware names
          traverse(layer.handle.stack, fullPref, parentMW.concat(mountMW));
        }
        // else: a logger, error handler, or global middleware—skip it
      });
    }

    traverse(stack);

    // If asked for formatted strings, pad each column to align the dashes
    if (options.format) {
      const idxW = String(routes.length).length;
      const mthW = Math.max(...routes.map((r) => r.method.length), 6);
      const rteW = Math.max(...routes.map((r) => r.route.length), 5);

      return routes.map((r, i) => {
        const idx = String(i + 1).padEnd(idxW, " ");
        const mtd = r.method.padEnd(mthW, " ");
        const rte = r.route.padEnd(rteW, " ");
        const mws = r.middlewares.join(", ");
        return `${idx} - ${mtd} - ${rte} - ${mws}`;
      });
    }

    return routes;
  }

@JoshuaHintze
Copy link

I would love for this to become part of the framework as well. In 4.X that I was using I too used the app._router._stack approach but just switching to 5.X those internal variables are gone which makes sense. Having a framework capability would be great. We use this for auditing purposes to see if any routes we have created have never been called.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants