npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chaeco/auto-router

v0.0.9

Published

File-based automatic router plugin for Node.js frameworks (Hoa, Express, Koa, Fastify, etc.)

Readme

@chaeco/auto-router

npm version codecov License: MIT Node.js Version

File-based automatic router plugin for Node.js frameworks (Hoa, Koa, Fastify, Express, etc.). Map your filesystem to HTTP routes — no manual route registration.

Features

  • 🚀 Zero-config automatic routing from file structure
  • 📁 Nested directory support with automatic path building
  • ⚡ Dynamic parameter [param] syntax — single and multi-parameter routes
  • 🔒 Built-in auth metadata (requiresAuth) with fine-grained forcePublic / forceProtected overrides
  • 🔍 Built-in validation for file naming, parameters, and duplicate routes
  • 📝 Full TypeScript support — RouteHandler<TCtx, TRes> generic, framework-agnostic
  • 🛡️ Cross-instance duplicate route detection via shared app.$registeredRoutes
  • 🎛️ Prefix array support — same controllers under multiple prefixes
  • 🧩 Merged multi-configuration support — one call, many directories
  • 📢 Custom logging via onLog callback
  • 🌐 staticAutoRouter for runtimes without filesystem access (edge functions, bundled apps)

Installation

npm install @chaeco/auto-router

AI Tool Skills

This package includes AI agent skills for Claude Code and OpenAI Codex. After installation, run this once:

npx auto-router-init-skills

This copies skill files into your project's .claude/skills/auto-router/ and .codex/skills/auto-router/. AI tools will then enforce file naming rules, export conventions, auth patterns, and best practices when creating routes.


Table of Contents


Quick Start

import { autoRouter } from '@chaeco/auto-router'

const app = new YourFramework()

app.extend(
  autoRouter({
    dir: './controllers',
    prefix: '/api',
  })
)

app.listen(3000)

Given this filesystem:

controllers/
  get-users.ts                                      → GET /api/users
  get-[id].ts                                       → GET /api/:id
  post-login.ts                                     → POST /api/login
  get-[userId]-posts.ts                             → GET /api/:userId/posts
  get-[userId]-[postId].ts                          → GET /api/:userId/:postId
  users/
    [userId]/
      posts/
        get.ts                                      → GET /api/users/:userId/posts
        get-[id].ts                                 → GET /api/users/:userId/posts/:id
      settings/
        get.ts                                      → GET /api/users/:userId/settings
  admin/
    get-dashboard.ts                                → GET /api/admin/dashboard

No manual app.get(...) calls needed. Every .ts file becomes a route.


File Naming Convention

Every route file name encodes the HTTP method and the URL structure. The auto-router parses the file name and converts it into a framework route registration.

Basic formats

| File name | Registers | |-----------|-----------| | get.ts | GET /api (at root directory — route is the directory path) | | post.ts | POST /api | | admin/get.ts | GET /api/admin | | users/post.ts | POST /api/users | | get-users.ts | GET /api/users | | post-login.ts | POST /api/login |

Rule: A file named exactly {method}.ts uses the directory path as the route. A file named {method}-{name}.ts appends name to the directory path.

Single parameter

Wrap the parameter name in square brackets []. It becomes an Express-style :param path segment.

| File name | Registers | |-----------|-----------| | get-[id].ts | GET /api/:id | | delete-[id].ts | DELETE /api/:id | | get-[userId].ts | GET /api/:userId | | post-[type].ts | POST /api/:type |

Multiple parameters

Multiple [param] segments in the file name are separated by -. Each - between two segments becomes a / in the URL.

| File name | Registers | Pattern | |-----------|-----------|---------| | get-[userId]-posts.ts | GET /api/:userId/posts | param + static | | get-users-[id].ts | GET /api/users/:id | static + param | | get-[userId]-[postId].ts | GET /api/:userId/:postId | param + param | | put-[userId]-profile.ts | PUT /api/:userId/profile | param + static | | get-[org]-settings-[key].ts | GET /api/:org/settings/:key | param + static + param | | get-[a]-[b]-[c].ts | GET /api/:a/:b/:c | three consecutive params |

Dynamic directory names

Directory names can also contain [param] brackets. This is the recommended way to express resource hierarchies with more than two levels of nesting.

| File path | Registers | |-----------|-----------| | users/[userId]/get.ts | GET /api/users/:userId | | users/[userId]/posts/get.ts | GET /api/users/:userId/posts | | users/[userId]/posts/[postId]/get.ts | GET /api/users/:userId/posts/:postId | | users/[userId]/posts/get-[id].ts | GET /api/users/:userId/posts/:id |

Dynamic directories and file-level params compose naturally — directory params are processed first during recursive scanning, then file name params are appended.

Choosing flat files vs nested directories

A route like GET /api/users/:userId/posts/:postId/comments/:commentId can be expressed two ways:

| Approach | File path | Verdict | |----------|-----------|---------| | Flat file | get-users-[userId]-posts-[postId]-comments-[commentId].ts | ❌ 60+ chars, unreadable | | Nested directories | users/[userId]/posts/[postId]/comments/get-[commentId].ts | ✅ Each segment is short and clear |

Rule of thumb:

  • ≤ 3 path segments (method + 2 hyphens): flat file is fine — get-[userId]-posts.ts
  • > 3 path segments: use nested directories — users/[userId]/posts/get-[id].ts
  • Resource hierarchies naturally map to directories — users/, posts/, comments/ are directory trees, not flat file name prefixes

Route conversion rules (reference)

The file name routeName (everything after method-) goes through three regex passes:

1. [param]       → :param        (bracket → colon prefix)
2. -:/           → /:            (dash before colon → slash before colon)
3. :param-/      → :param/       (colon segment before dash → slash after)

Important: The - character is always interpreted as a path separator. You cannot express a literal hyphen in a route path via file naming. If your route must contain a literal - (e.g., /api/user-settings), use a directory structure or configure the route manually.


Export Methods

Method 1: Pure function

The simplest form. The route inherits the global defaultRequiresAuth setting.

// controllers/get-users.ts
export default async (ctx) => {
  ctx.res.body = { users: [] }
}

Method 2: createHandler wrapper

Use when you need per-route metadata (auth, description, custom fields).

import { createHandler } from '@chaeco/auto-router'

// Protected route
export default createHandler(
  async (ctx) => {
    ctx.res.body = { success: true, data: { userId: ctx.currentUser?.id } }
  },
  { requiresAuth: true, description: 'Get current user info' }
)

// Public route (overrides a global defaultRequiresAuth: true)
export default createHandler(
  async (ctx) => {
    ctx.res.body = { success: true }
  },
  { requiresAuth: false }
)

The meta object accepts requiresAuth, description, and any custom [key: string]: any fields.

Strict mode

| Setting | Pure function | createHandler() | Plain { handler, meta } | |---------|:---:|:---:|:---:| | strict: true (default) | ✅ | ✅ | ❌ Rejected | | strict: false | ✅ | ✅ | ⚠️ Accepted with warning |

Strict mode is on by default. It enforces consistent export style across the codebase. Use non-strict mode only for gradual migration or backward compatibility.

// Strict mode (default) — plain objects are rejected
autoRouter({ dir: './controllers', strict: true })

// Non-strict mode — plain objects accepted with a warning
autoRouter({ dir: './controllers', strict: false })

Auth & Permissions

Configuration modes

Blacklist mode (public-by-default): mark only protected routes.

autoRouter({ dir: './controllers', defaultRequiresAuth: false })
// Only this route needs an explicit auth marker
export default createHandler(async (ctx) => { ... }, { requiresAuth: true })

Whitelist mode (protected-by-default): mark only public routes.

autoRouter({ dir: './controllers', defaultRequiresAuth: true })
// Only this route needs an explicit public marker
export default createHandler(async (ctx) => { ... }, { requiresAuth: false })

forcePublic / forceProtected

Bulk auth overrides that apply across many routes at once, independent of defaultRequiresAuth.

autoRouter({
  dir: './controllers',
  prefix: '/api',
  defaultRequiresAuth: true,         // protected by default
  forcePublic: [
    '/api/auth/login',               // login is always public
    '/api/auth/register',            // register is always public
    '/api/public/*',                 // everything under /api/public/ is public
  ],
  forceProtected: [
    '/api/admin/*',                  // everything under /api/admin/ is protected
    'POST /api/users',               // only POST /api/users is protected; GET stays public
  ],
})

Pattern formats

| Format | Example | Matches | |--------|---------|---------| | Exact path (with prefix) | '/api/users' | All methods on /api/users exactly | | Exact path (without prefix) | '/users' | Same as above when prefix is /api | | Wildcard | '/api/admin/*' | All methods on /api/admin/foo, /api/admin/foo/bar, etc. — NOT /api/admin itself | | Method + exact path | 'POST /api/users' | Only POST on /api/users; GET is unaffected | | Method + wildcard | 'DELETE /api/admin/*' | Only DELETE under /api/admin/ |

Wildcard note: /* intentionally matches sub-paths only, not the base path. Use an additional exact-pattern entry if you need the base path covered too:

forceProtected: [
  '/api/admin',       // covers /api/admin itself
  '/api/admin/*',     // covers /api/admin/users, /api/admin/settings, etc.
]

Priority chain

createHandler explicit meta  >  forceProtected / forcePublic  >  defaultRequiresAuth
  1. Explicit meta wins. If a route uses createHandler(fn, { requiresAuth: true }), no forcePublic pattern can override it.
  2. force rules override global default. A forceProtected pattern promotes a route to protected even when defaultRequiresAuth: false.
  3. Default is the fallback. When no explicit meta and no force pattern matches, defaultRequiresAuth applies.

Conflict resolution

When the same route matches both forcePublic and forceProtected:

  • forceProtected wins (safer default)
  • A warning is logged identifying the conflict

When a forcePublic or forceProtected pattern matches a route that has explicit createHandler meta:

  • The explicit meta wins
  • A warning is logged: the force pattern "has no effect"

Warnings for unused patterns

After all routes are loaded, the auto-router checks whether every forcePublic and forceProtected pattern matched at least one registered route. Unmatched patterns get a warning — this catches typos and stale config entries:

⚠️  forcePublic pattern "/api/nonexistent-route" did not match any registered route
   (check for typos or outdated config)

Configuration

Single configuration

app.extend(
  autoRouter({
    dir: './controllers',
    prefix: '/api',
    defaultRequiresAuth: false,
  })
)

Prefix array

Register the same controllers under multiple prefixes — useful for API versioning or locale prefixes.

app.extend(
  autoRouter({
    dir: './controllers',
    prefix: ['/api', '/v1', '/v2'],
  })
)

// Each route file is registered 3 times:
// get-users.ts → GET /api/users, GET /v1/users, GET /v2/users

Merged configuration (array)

Pass an array of config objects to autoRouter() to configure multiple directories in a single call. Each entry can have its own dir, prefix, defaultRequiresAuth, forcePublic, forceProtected, strict, logging, and onLog.

app.extend(
  autoRouter([
    {
      dir: './controllers/admin',
      prefix: '/api/admin',
      defaultRequiresAuth: true,
    },
    {
      dir: './controllers/public',
      prefix: '/api/public',
      defaultRequiresAuth: false,
    },
    {
      dir: './controllers/v2',
      prefix: ['/api/v2', '/v2'],
    },
  ])
)

Multiple calls

Alternatively, call autoRouter multiple times. Routes accumulate in app.$routes and duplicate detection works across all calls via app.$registeredRoutes.

app.extend(autoRouter({ dir: './controllers/admin', prefix: '/api/admin' }))
app.extend(autoRouter({ dir: './controllers/client', prefix: '/api/client' }))

No prefix

Pass '' (empty string) to register routes without a prefix:

autoRouter({ dir: './controllers', prefix: '' })
// get-users.ts → GET /users
// get-[id].ts  → GET /:id

Route Registry

After loading, app.$routes contains all registered route metadata:

app.$routes.all             // RouteInfo[]         — every registered route
app.$routes.publicRoutes    // { method, path }[]  — public routes
app.$routes.protectedRoutes // { method, path }[]  — protected routes

RouteInfo contains method, path, requiresAuth, and meta (the RouteMeta passed to createHandler, if any).

This is designed for integrating with auth middleware:

app.use(async (ctx, next) => {
  const isProtected = app.$routes.protectedRoutes.some(
    r => r.method === ctx.method && r.path === ctx.path
  )
  if (isProtected) {
    // verify JWT token, session, etc.
  }
  await next()
})

API Documentation Generation

app.$routes.all provides a complete route manifest ideal for generating OpenAPI specs, Postman collections, or any other API documentation format. Iterate over the registry and map each RouteInfo to your target format.

OpenAPI / Swagger

import { autoRouter } from '@chaeco/auto-router'

app.extend(autoRouter({ dir: './controllers', prefix: '/api' }))

// After app.listen() or await app.ready()
const spec = {
  openapi: '3.0.0',
  info: { title: 'My API', version: '1.0.0' },
  paths: {},
}

for (const route of app.$routes.all) {
  const path = route.path.replace(/:/g, '{').replace(/\{([^}]+)\}/g, '{$1}')
  if (!spec.paths[path]) spec.paths[path] = {}

  const operation: Record<string, any> = {
    summary: route.meta?.description ?? route.path,
    responses: { default: { description: 'Default response' } },
  }
  if (route.requiresAuth === true) operation.security = [{ bearerAuth: [] }]
  if (route.meta?.tags) operation.tags = route.meta.tags

  spec.paths[path][route.method.toLowerCase()] = operation
}

Postman Collection

const collection = {
  info: { name: 'My API', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' },
  item: [],
}

for (const route of app.$routes.all) {
  collection.item.push({
    name: route.meta?.description ?? route.path,
    request: {
      method: route.method.toUpperCase(),
      url: { raw: `{{baseUrl}}${route.path}`, path: route.path.split('/').filter(Boolean) },
      auth: route.requiresAuth ? { type: 'bearer', bearer: [{ key: 'token', value: '{{token}}', type: 'string' }] } : undefined,
    },
  })
}

Tags / Grouping

Add tags via createHandler meta for logical grouping in generated docs:

export default createHandler(
  async (ctx) => { ctx.body = { users: [] } },
  { description: 'List all users', tags: ['Users', 'CRUD'] }
)

Type Safety

RouteHandler<TCtx, TRes> is a conditional generic that adapts to your framework:

Single-context frameworks (Hoa, Koa, Fastify)

import { createHandler } from '@chaeco/auto-router'
import type { RouteHandler } from '@chaeco/auto-router'

type MyContext = { body: any; params: Record<string, string> }

export default createHandler<MyContext>(
  async (ctx) => {
    ctx.body = { success: true }   // ctx is typed as MyContext
  },
  { requiresAuth: true }
)

Dual-parameter frameworks (Express)

import type { Request, Response } from 'express'

export default createHandler<Request, Response>(
  async (req, res) => {
    res.json({ success: true })    // req: Request, res: Response
  },
  { requiresAuth: true }
)

RouteMeta supports custom extensions:

export default createHandler(
  async (ctx) => { ... },
  {
    requiresAuth: true,
    description: 'Get user profile',
    rateLimit: 100,            // custom field
    roles: ['admin', 'user'],  // custom field
  }
)

Logging

Default: all levels to console

autoRouter({ dir: './controllers' })
// info → console.log, warn → console.warn, error → console.error

Custom log sink

When onLog is provided, console output is fully replaced — your callback handles all log levels.

autoRouter({
  dir: './controllers',
  onLog: (level, message) => {
    myLogger[level](message)
  },
})

Silent mode

autoRouter({ dir: './controllers', logging: false })
// No console output (info, warn, and error are all suppressed)

Silent but capture errors

autoRouter({
  dir: './controllers',
  logging: false,
  onLog: (level, message) => {
    if (level === 'error') errorTracker.capture(message)
  },
})

Validation Rules

The auto-router validates every file during scanning and skips invalid files (logging an error) without aborting. The remaining files in the directory continue to be scanned.

| Rule | Valid | Invalid | |------|-------|---------| | File name starts with HTTP method | get-users.ts | users-get.ts | | Bracket syntax for params | [userId] | :userId | | No empty brackets | [id] | [] | | Only default export | export default async ... | export const foo = 1 + default | | Export is a function or createHandler() result | async (ctx) => {} | export default 42 | | Directory names not HTTP methods | controllers/users/ | controllers/get/ ⚠️ | | No duplicate routes across instances | — | Two files mapping to GET /api/users | | .d.ts files ignored | — | types.d.ts is silently skipped |


Best Practices

Do ✅

  • Use nested directories for resource hierarchies — users/[userId]/posts/get.ts instead of get-users-[userId]-posts.ts
  • Use [dirName] dynamic directories for parent resource params — keeps file names short
  • Use createHandler() when a route's auth differs from the global default
  • Use forceProtected for admin/sensitive areas — one pattern covers all routes
  • Use forcePublic for auth/login/docs areas — explicit and auditable
  • Choose blacklist mode (defaultRequiresAuth: false) for public-facing APIs
  • Choose whitelist mode (defaultRequiresAuth: true) for internal/admin APIs
  • Keep handler logic thin — delegate to service layers
  • Use strict: true (the default) — enforces consistent export style

Don't ❌

  • Don't flatten deep resource trees into long file names — 3+ hyphens → use directories
  • Don't put complex logic in route files — they're entry points, not business logic
  • Don't mix export styles in the same directory — pick one and use strict: true
  • Don't forget to update forcePublic/forceProtected patterns when restructuring controllers
  • Don't create controller files outside the configured dir — they won't be scanned

Runtimes Without Filesystem Access

The standard autoRouter relies on fs + dynamic import(), which are unavailable in edge runtimes (Cloudflare Workers, Deno Deploy, etc.) and bundled Node.js apps. Use staticAutoRouter for these environments — you statically import handlers yourself and declare routes as data.

import { staticAutoRouter } from '@chaeco/auto-router'

// Static imports — works in any bundler (esbuild, webpack, rolldown, etc.)
import getUsers from './controllers/get-users'
import getUserById from './controllers/get-[id]'
import postLogin from './controllers/post-login'

app.extend(
  staticAutoRouter({
    routes: [
      { method: 'get',  path: '/api/users',  handler: getUsers },
      { method: 'get',  path: '/api/:id',    handler: getUserById },
      { method: 'post', path: '/api/login',  handler: postLogin },
    ],
    defaultRequiresAuth: false,
    forcePublic: ['/api/login'],
  })
)

Cloudflare Workers + Hono

staticAutoRouter is the bridge: it handles route registration and auth metadata. The routing, middleware, and request dispatch belong to the framework — Hono is the standard choice for Workers.

import { Hono } from 'hono'
import { staticAutoRouter } from '@chaeco/auto-router'
import getUsers from './controllers/get-users'
import getUserById from './controllers/get-[id]'

const app = new Hono()

// staticAutoRouter registers routes on the Hono app just like any framework
app.extend(
  staticAutoRouter({
    routes: [
      { method: 'get', path: '/api/users', handler: getUsers },
      { method: 'get', path: '/api/:id',   handler: getUserById },
    ],
  })
)

export default app

staticAutoRouter supports the same auth options as autoRouter: defaultRequiresAuth, forcePublic, forceProtected, logging, and onLog. Route validation (duplicate detection, auth resolution, registry population) works identically.


API Reference

autoRouter(options)

Factory function. Returns an async plugin function (app) => Promise<void> for use with app.extend().

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | dir | string | './controllers' | Path to controller directory | | prefix | string \| string[] | '/api' | Route prefix; '' for no prefix | | defaultRequiresAuth | boolean | false | Global auth default | | forcePublic | string[] | — | Patterns for always-public routes | | forceProtected | string[] | — | Patterns for always-protected routes | | strict | boolean | true | Strict export validation | | logging | boolean | true | Console log output | | onLog | (level, message) => void | — | Custom log sink |

options can also be an array of the above for merged multi-configuration.

staticAutoRouter(options)

For runtimes without filesystem access. Accepts statically imported routes instead of scanning a directory.

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | routes | StaticRoute[] | required | Array of { method, path, handler } | | defaultRequiresAuth | boolean | false | Global auth default | | forcePublic | string[] | — | Patterns for always-public routes | | forceProtected | string[] | — | Patterns for always-protected routes | | logging | boolean | true | Console log output | | onLog | (level, message) => void | — | Custom log sink |

StaticRoute:

interface StaticRoute {
  method: string    // 'get', 'post', 'put', 'delete', 'patch'
  path: string      // '/api/users', '/api/:id'
  handler: any      // async function or createHandler() result
}

createHandler(handler, meta?)

createHandler<TCtx = any, TRes = void>(
  handler: RouteHandler<TCtx, TRes>,
  meta?: RouteMeta
): RouteConfig<TCtx, TRes>

Wraps a handler function with metadata. An empty {} meta is normalized to undefined.

RouteMeta fields:

| Field | Type | Description | |-------|------|-------------| | requiresAuth | boolean | Whether auth is required | | description | string | Human-readable route description | | [key: string] | any | Any custom metadata |

isRouteConfig(obj)

isRouteConfig(obj: any): obj is RouteConfig

Returns true if obj was created by createHandler(). Useful for type-narrowing.

Exported types

export type { RouteHandler, RouteMeta, RouteConfig, RouteInfo, AppRoutesRegistry } from '@chaeco/auto-router'
export type { StaticRoute, StaticAutoRouterOptions } from '@chaeco/auto-router'

License

MIT — see LICENSE.