├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── fixtures │ ├── catchall_route │ │ └── routes │ │ │ ├── [...catchall].ts │ │ │ └── dashboard.ts │ ├── complex_routes │ │ └── routes │ │ │ ├── announcements.ts │ │ │ ├── index.ts │ │ │ └── posts │ │ │ ├── [slug].ts │ │ │ ├── [slug] │ │ │ ├── comments.ts │ │ │ └── comments │ │ │ │ └── [id] │ │ │ │ └── reactions.ts │ │ │ └── index.ts │ ├── conflicting_index_routes │ │ └── routes │ │ │ ├── indexed.ts │ │ │ └── search │ │ │ ├── index.mod.ts │ │ │ └── indexed.ts │ ├── index_route │ │ └── routes │ │ │ └── index.ts │ ├── multi_nested_routes │ │ └── routes │ │ │ ├── announcements.ts │ │ │ ├── index.ts │ │ │ └── users │ │ │ ├── admins │ │ │ └── all.ts │ │ │ └── index.ts │ └── nested_index_route │ │ └── routes │ │ └── users │ │ └── index.ts ├── lib.test.ts └── utilities.test.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── postbuild.sh ├── rollup.config.js ├── src ├── config.ts ├── index.ts ├── lib.ts ├── router.ts ├── types.ts └── utils.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "error" 4 | }, 5 | "ignorePatterns": [ 6 | "node_modules/", 7 | "dist/", 8 | "examples/", 9 | "rollup.config.js" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # build 12 | /dist 13 | 14 | # env 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matthias Halfmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-file-routing 2 | 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/matthiaaas/express-file-routing?color=brightgreen&label=latest) 4 | 5 | Flexible file-based routing for Express with `0` dependencies. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install express-file-routing 11 | ``` 12 | 13 | **Note:** If you prefer `yarn` instead of `npm`, just use `yarn add express-file-routing`. 14 | 15 | ## How to use 16 | 17 | Fundamentally, there are two ways of adding this library to your codebase: **either** as a middleware with `app.use("/", await router())`, which will add a separate [sub-router](http://expressjs.com/en/5x/api.html#router) to your app, **or** by wrapping your whole Express instance with `await createRouter(app)`, which will bind the routes directly to your app instance. In most cases, it doesn't matter on what option you decide, even though one or the other might perform better in some scenarios. 18 | 19 | - app.ts (main) 20 | 21 | ```ts 22 | import express from "express" 23 | import createRouter, { router } from "express-file-routing" 24 | 25 | const app = express() 26 | 27 | // Option 1 28 | app.use("/", await router()) // as router middleware or 29 | 30 | // Option 2 31 | await createRouter(app) // as wrapper function 32 | 33 | app.listen(2000) 34 | ``` 35 | 36 | **Note:** By default, routes are expected to be located in your project's `/routes` directory. 37 | 38 | - routes/index.ts 39 | 40 | ```ts 41 | export const get = async (req, res) => { 42 | if (req.method !== "GET") return res.status(405) 43 | 44 | return res.json({ hello: "world" }) 45 | } 46 | ``` 47 | 48 | #### Directory Structure 49 | 50 | Files inside your project's `/routes` directory will get matched an url path automatically. 51 | 52 | ```php 53 | ├── app.ts 54 | ├── routes 55 | ├── index.ts // index routes 56 | ├── posts 57 | ├── index.ts 58 | └── [id].ts or :id.ts // dynamic params 59 | └── users.ts 60 | └── package.json 61 | ``` 62 | 63 | - `/routes/index.ts` → / 64 | - `/routes/posts/index.ts` → /posts 65 | - `/routes/posts/[id].ts` → /posts/:id 66 | - `/routes/users.ts` → /users 67 | 68 | **Note:** Files prefixed with an underscore or ending with `.d.ts` are excluded from route generation. 69 | 70 | ## API 71 | 72 | ```ts 73 | await createRouter(app, { 74 | directory: path.join(__dirname, "routes"), 75 | additionalMethods: ["ws", ...] 76 | }) 77 | // or 78 | app.use("/", await router({ 79 | directory: path.join(__dirname, "routes"), 80 | additionalMethods: ["ws", ...], 81 | routerOptions: express.RouterOptions 82 | })) 83 | ``` 84 | 85 | ### Options 86 | 87 | - `directory`: The path to the routes directory (defaults to `/routes`) 88 | - `additionalMethods`: Additional methods that match an export from a route like `ws` (e.g. `ws` for express-ws) 89 | - `routerOptions`: Native Express [Router Options](https://expressjs.com/de/api.html#express.router) objects forwarded as-is to the underlying router 90 | 91 | ## Examples 92 | 93 | ### HTTP Method Matching 94 | 95 | If you export functions named e.g. `get`, `post`, `put`, `patch`, `delete`/`del` [etc.](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) from a route file, those will get matched their corresponding http method automatically. 96 | 97 | ```ts 98 | export const get = async (req, res) => { ... } 99 | 100 | export const post = async (req, res) => { ... } 101 | 102 | // since it's not allowed to name constants 'delete', try 'del' instead 103 | export const del = async (req, res) => { ... } 104 | 105 | // you can still use a wildcard default export in addition 106 | export default async (req, res) => { ... } 107 | ``` 108 | 109 | **Note:** Named method exports gain priority over wildcard exports (= default exports). 110 | 111 | ### Middlewares 112 | 113 | You can add isolated, route specific middlewares by exporting an array of Express request handlers from your route file. 114 | 115 | ```ts 116 | // routes/dashboard 117 | import { rateLimit, bearerToken, userAuth } from "../middlewares" 118 | 119 | export const get = [ 120 | rateLimit(), bearerToken(), userAuth(), 121 | async (req, res) => { ... } 122 | ] 123 | ``` 124 | 125 | A middleware function might look like the following: 126 | 127 | ```ts 128 | // middlewares/userAuth.ts 129 | export default (options) => async (req, res, next) => { 130 | if (req.authenticated) next() 131 | ... 132 | } 133 | ``` 134 | 135 | ### Custom Methods Exports 136 | 137 | You can add support for other method exports to your route files. This means that if your root app instance accepts non built-in handler invocations like `app.ws(route, handler)`, you can make them being recognized as valid handlers. 138 | 139 | ```ts 140 | // app.ts 141 | import ws from "express-ws" 142 | 143 | const { app } = ws(express()) 144 | 145 | await createRouter(app, { 146 | additionalMethods: ["ws"] 147 | }) 148 | 149 | // routes/index.ts 150 | export const ws = async (ws, req) => { 151 | ws.send("hello world") 152 | } 153 | ``` 154 | 155 | ### Usage with TypeScript 156 | 157 | The library itself comes with built-in type definitions. Adding support for route & method handler type definitions is as straightforward as including Express' native `Handler` type from [@types/express](https://www.npmjs.com/package/@types/express). 158 | 159 | ```ts 160 | // routes/posts.ts 161 | import type { Handler } from "express" 162 | 163 | export const get: Handler = async (req, res, next) => { ... } 164 | ``` 165 | 166 | ### Error Handling 167 | 168 | It is essential to catch potential errors (500s, 404s etc.) within your route handlers and forward them through `next(err)` if necessary, as treated in the Express' docs on [error handling](https://expressjs.com/en/guide/error-handling.html). 169 | 170 | Defining custom error-handling middleware functions should happen _after_ applying your file-system routes. 171 | 172 | ```ts 173 | app.use("/", await router()) // or 'await createRouter(app)' 174 | 175 | app.use(async (err, req, res, next) => { 176 | ... 177 | }) 178 | ``` 179 | 180 | ### Catch-All (unstable) 181 | 182 | This library lets you extend dynamic routes to catch-all routes by prefixing them with three dots `...` inside the brackets. This will make that dynamic route match itself but also all subsequent routes within that route. 183 | 184 | **Note:** Since this feature got added recently, it might be unstable. Feedback is welcome. 185 | 186 | ```ts 187 | // routes/users/[...catchall].js 188 | export const get = async (req, res) => { 189 | return res.json({ path: req.params[0] }) 190 | } 191 | ``` 192 | 193 | - `routes/users/[...catchall].js` matches /users/a, /users/a/b and so on, but **not** /users. 194 | 195 | ## Migrating from v2 196 | 197 | The latest version v3 fixed stable support for ESM & CJS compatibility, but also **introduced a breaking change** in the library's API. To upgrade, first install the latest version from npm. 198 | 199 | ### Upgrade version 200 | 201 | ``` 202 | npm install express-file-routing@latest 203 | ``` 204 | 205 | ### Await the router 206 | 207 | Registering the file-router in v2 was synchronous. Newer versions require to await the router. So the only change in your codebase will be to await the router instead of calling it synchronously: 208 | 209 | ```diff 210 | const app = express() 211 | 212 | - app.use("/", router()) 213 | + app.use("/", await router()) 214 | 215 | app.listen(2000) 216 | ``` 217 | 218 | Or if you were using `createRouter()`: 219 | 220 | ```diff 221 | const app = express() 222 | 223 | - createRouter(app) 224 | + await createRouter(app) 225 | 226 | app.listen(2000) 227 | ``` 228 | 229 | **Note:** If your environment does not support top-level await, you might need to wrap you code in an async function. 230 | -------------------------------------------------------------------------------- /__tests__/fixtures/catchall_route/routes/[...catchall].ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/catchall_route/routes/[...catchall].ts -------------------------------------------------------------------------------- /__tests__/fixtures/catchall_route/routes/dashboard.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/catchall_route/routes/dashboard.ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/announcements.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/announcements.ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/index.ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/posts/[slug].ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/posts/[slug].ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/posts/[slug]/comments.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/posts/[slug]/comments.ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/posts/[slug]/comments/[id]/reactions.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/posts/[slug]/comments/[id]/reactions.ts -------------------------------------------------------------------------------- /__tests__/fixtures/complex_routes/routes/posts/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/complex_routes/routes/posts/index.ts -------------------------------------------------------------------------------- /__tests__/fixtures/conflicting_index_routes/routes/indexed.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/conflicting_index_routes/routes/indexed.ts -------------------------------------------------------------------------------- /__tests__/fixtures/conflicting_index_routes/routes/search/index.mod.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/conflicting_index_routes/routes/search/index.mod.ts -------------------------------------------------------------------------------- /__tests__/fixtures/conflicting_index_routes/routes/search/indexed.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiaaas/express-file-routing/45664eff37e7c1a210b9b3034ff8ce190a2dfec5/__tests__/fixtures/conflicting_index_routes/routes/search/indexed.ts -------------------------------------------------------------------------------- /__tests__/fixtures/index_route/routes/index.ts: -------------------------------------------------------------------------------- 1 | export const get = async (_, res) => res.send("GET /") 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/multi_nested_routes/routes/announcements.ts: -------------------------------------------------------------------------------- 1 | export const get = async (_, res) => res.send("GET /announcements") 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/multi_nested_routes/routes/index.ts: -------------------------------------------------------------------------------- 1 | export const get = async (_, res) => res.send("GET /") 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/multi_nested_routes/routes/users/admins/all.ts: -------------------------------------------------------------------------------- 1 | export const get = async (_, res) => res.send("GET /users/admins/all") 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/multi_nested_routes/routes/users/index.ts: -------------------------------------------------------------------------------- 1 | export const get = async (_, res) => res.send("GET /users") 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/nested_index_route/routes/users/index.ts: -------------------------------------------------------------------------------- 1 | export const get = (_, res) => res.send("GET /users") 2 | -------------------------------------------------------------------------------- /__tests__/lib.test.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { walkTree, generateRoutes } from "../src/lib" 3 | 4 | describe("route generation & directory traversal", () => { 5 | test("index route", async () => { 6 | const routes = await walkTreeAndGenerateRoutes("index_route") 7 | 8 | expect(routes).toHaveLength(1) 9 | expect(routes[0].url).toBe("/") 10 | expect(routes[0].priority).toBe(0) 11 | expect(routes[0].exports).toHaveProperty("get") 12 | }) 13 | 14 | test("nested index route", async () => { 15 | const routes = await walkTreeAndGenerateRoutes("nested_index_route") 16 | 17 | expect(routes).toHaveLength(1) 18 | expect(routes[0].url).toBe("/users") 19 | expect(routes[0].exports).toHaveProperty("get") 20 | }) 21 | 22 | test("multi nested routes", async () => { 23 | const routes = await walkTreeAndGenerateRoutes("multi_nested_routes") 24 | 25 | expect(routes).toHaveLength(4) 26 | expect(routes[0].url).toBe("/") 27 | expect(routes[1].url).toBe("/announcements") 28 | expect(routes[2].url).toBe("/users") 29 | expect(routes[3].url).toBe("/users/admins/all") 30 | }) 31 | 32 | test("complex routes", async () => { 33 | const routes = await walkTreeAndGenerateRoutes("complex_routes") 34 | 35 | expect(routes).toHaveLength(6) 36 | expect(routes[0].url).toBe("/") 37 | expect(routes[1].url).toBe("/announcements") 38 | expect(routes[2].url).toBe("/posts") 39 | expect(routes[3].url).toBe("/posts/:slug") 40 | expect(routes[4].url).toBe("/posts/:slug/comments") 41 | expect(routes[5].url).toBe("/posts/:slug/comments/:id/reactions") 42 | }) 43 | 44 | test("conflicting index routes", async () => { 45 | const routes = await walkTreeAndGenerateRoutes("conflicting_index_routes") 46 | 47 | expect(routes).toHaveLength(3) 48 | expect(routes[0].url).toBe("/indexed") 49 | expect(routes[1].url).toBe("/search") 50 | expect(routes[2].url).toBe("/search/indexed") 51 | }) 52 | 53 | test("catchall route", async () => { 54 | const routes = await walkTreeAndGenerateRoutes("catchall_route") 55 | 56 | expect(routes).toHaveLength(2) 57 | expect(routes[0].url).toBe("/dashboard") 58 | expect(routes[1].url).toBe("/*") 59 | }) 60 | }) 61 | 62 | const getFixture = (name: string) => path.join(__dirname, "fixtures", name) 63 | 64 | const walkTreeAndGenerateRoutes = async (name: string) => { 65 | const fixture = getFixture(name) 66 | 67 | const files = walkTree(path.join(fixture, "routes")) 68 | 69 | return await generateRoutes(files) 70 | } 71 | -------------------------------------------------------------------------------- /__tests__/utilities.test.ts: -------------------------------------------------------------------------------- 1 | import { buildRouteUrl, mergePaths } from "../src/utils" 2 | 3 | describe("buildRouteUrl", () => { 4 | test("index route", () => { 5 | expect(buildRouteUrl("/")).toBe("/") 6 | }) 7 | 8 | test("explicit route", () => { 9 | expect(buildRouteUrl("/posts")).toBe("/posts") 10 | }) 11 | 12 | test("nested explicit route", () => { 13 | expect(buildRouteUrl("/auth/signin")).toBe("/auth/signin") 14 | }) 15 | 16 | test("dynamic parameter", () => { 17 | expect(buildRouteUrl("/[user]")).toBe("/:user") 18 | }) 19 | 20 | test("nested dynamic parameter", () => { 21 | expect(buildRouteUrl("/posts/[id]")).toBe("/posts/:id") 22 | }) 23 | 24 | test("nested dynamic parameter with explicit subroute", () => { 25 | expect(buildRouteUrl("/posts/[id]/comments")).toBe("/posts/:id/comments") 26 | }) 27 | 28 | test("double nested dynamic parameter", () => { 29 | expect(buildRouteUrl("/posts/[id]/comments/[id]")).toBe( 30 | "/posts/:id/comments/:id" 31 | ) 32 | }) 33 | 34 | test("catchall route", () => { 35 | expect(buildRouteUrl("/[...catchall]")).toBe("/*") 36 | }) 37 | 38 | test("nested catchall route", () => { 39 | expect(buildRouteUrl("/users/[...catchall]")).toBe("/users/*") 40 | }) 41 | }) 42 | 43 | describe("mergePaths", () => { 44 | test("index", () => { 45 | expect(mergePaths("/", "index.ts")).toBe("/index.ts") 46 | }) 47 | 48 | test("index with nested explicit route", () => { 49 | expect(mergePaths("/", "users.ts")).toBe("/users.ts") 50 | }) 51 | 52 | test("nested dynamic parameter index", () => { 53 | expect(mergePaths("/posts/[id]", "index.ts")).toBe("/posts/[id]/index.ts") 54 | }) 55 | 56 | test("multiple path fragments", () => { 57 | expect(mergePaths("/auth", "/signin", "token.ts")).toBe( 58 | "/auth/signin/token.ts" 59 | ) 60 | }) 61 | 62 | test("multiple path fragments with dynamic parameters", () => { 63 | expect( 64 | mergePaths("/posts", "/[userId]", "/comments", "[commentId].ts") 65 | ).toBe("/posts/[userId]/comments/[commentId].ts") 66 | }) 67 | 68 | test("multiple malformed path fragments", () => { 69 | expect(mergePaths("/", "/auth", "//", "signin.ts")).toBe("/auth/signin.ts") 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("ts-jest").JestConfigWithTsJest} 3 | */ 4 | module.exports = { 5 | preset: "ts-jest", 6 | testEnvironment: "node", 7 | testPathIgnorePatterns: ["/node_modules/", "/__tests__/fixtures/"] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-file-routing", 3 | "version": "3.1.0", 4 | "description": "Simple file-based routing for Express", 5 | "author": "Matthias Halfmann", 6 | "repository": "matthiaaas/express-file-routing", 7 | "main": "dist/cjs/index.js", 8 | "module": "dist/esm/index.js", 9 | "types": "dist/esm/index.d.ts", 10 | "scripts": { 11 | "test": "jest", 12 | "build": "npm run build:rollup && npm run postbuild", 13 | "build:rollup": "rollup -c", 14 | "build:tsc": "tsc && tsc --module commonjs --outDir dist/cjs", 15 | "postbuild": "sh ./postbuild.sh", 16 | "prepublish": "npm run build", 17 | "dev": "ts-node-dev --transpile-only --files --quiet ./examples/with-typescript/app.ts" 18 | }, 19 | "exports": { 20 | "import": "./dist/esm/index.js", 21 | "require": "./dist/cjs/index.js" 22 | }, 23 | "keywords": [ 24 | "express", 25 | "api", 26 | "file", 27 | "router", 28 | "routing", 29 | "typescript" 30 | ], 31 | "license": "MIT", 32 | "peerDependencies": { 33 | "express": ">4.1.2" 34 | }, 35 | "devDependencies": { 36 | "@types/express": "^4.17.17", 37 | "@types/jest": "^29.4.4", 38 | "babel-core": "^6.26.3", 39 | "babel-runtime": "^6.26.0", 40 | "eslint": "^8.39.0", 41 | "express": "^4.18.2", 42 | "jest": "^29.5.0", 43 | "rollup": "^2.79.1", 44 | "rollup-plugin-typescript2": "^0.31.2", 45 | "ts-jest": "^29.0.5", 46 | "ts-node-dev": "^1.1.8", 47 | "typescript": "^4.9.5" 48 | }, 49 | "files": [ 50 | "dist" 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /postbuild.sh: -------------------------------------------------------------------------------- 1 | cat >dist/cjs/package.json <dist/esm/package.json < { 23 | const routerOptions = options?.routerOptions || {} 24 | 25 | return await createRouter(Router(routerOptions), options) 26 | } 27 | 28 | export { Options } 29 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, statSync } from "fs" 2 | import path from "path" 3 | 4 | import type { File, Route } from "./types" 5 | 6 | import { 7 | buildRoutePath, 8 | buildRouteUrl, 9 | calculatePriority, 10 | isCjs, 11 | isFileIgnored, 12 | mergePaths, 13 | prioritizeRoutes 14 | } from "./utils" 15 | 16 | const IS_ESM = !isCjs() 17 | 18 | const MODULE_IMPORT_PREFIX = IS_ESM ? "file://" : "" 19 | 20 | /** 21 | * Recursively walk a directory and return all nested files. 22 | * 23 | * @param directory The directory path to walk recursively 24 | * @param tree The tree of directories leading to the current directory 25 | * 26 | * @returns An array of all nested files in the specified directory 27 | */ 28 | export const walkTree = (directory: string, tree: string[] = []) => { 29 | const results: File[] = [] 30 | 31 | for (const fileName of readdirSync(directory)) { 32 | const filePath = path.join(directory, fileName) 33 | const fileStats = statSync(filePath) 34 | 35 | if (fileStats.isDirectory()) { 36 | results.push(...walkTree(filePath, [...tree, fileName])) 37 | } else { 38 | results.push({ 39 | name: fileName, 40 | path: directory, 41 | rel: mergePaths(...tree, fileName) 42 | }) 43 | } 44 | } 45 | 46 | return results 47 | } 48 | 49 | /** 50 | * Generate routes from an array of files by loading them as modules. 51 | * 52 | * @param files An array of files to generate routes from 53 | * 54 | * @returns An array of routes 55 | */ 56 | export const generateRoutes = async (files: File[]) => { 57 | const routes: Route[] = [] 58 | 59 | for (const file of files) { 60 | const parsedFile = path.parse(file.rel) 61 | 62 | if (isFileIgnored(parsedFile)) continue 63 | 64 | const routePath = buildRoutePath(parsedFile) 65 | const url = buildRouteUrl(routePath) 66 | const priority = calculatePriority(url) 67 | const exports = await import( 68 | MODULE_IMPORT_PREFIX + path.join(file.path, file.name) 69 | ) 70 | 71 | routes.push({ 72 | url, 73 | priority, 74 | exports 75 | }) 76 | } 77 | 78 | return prioritizeRoutes(routes) 79 | } 80 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | 3 | import type { ExpressLike, Options } from "./types" 4 | 5 | import config from "./config" 6 | 7 | import { generateRoutes, walkTree } from "./lib" 8 | import { getHandlers, getMethodKey, isHandler } from "./utils" 9 | 10 | const CJS_MAIN_FILENAME = 11 | typeof require !== "undefined" && require.main?.filename 12 | 13 | const PROJECT_DIRECTORY = CJS_MAIN_FILENAME 14 | ? path.dirname(CJS_MAIN_FILENAME) 15 | : process.cwd() 16 | 17 | /** 18 | * Attach routes to an Express app or router instance 19 | * 20 | * ```ts 21 | * await createRouter(app) 22 | * ``` 23 | * 24 | * @param app An express app or router instance 25 | * @param options An options object (optional) 26 | */ 27 | const createRouter = async ( 28 | app: T, 29 | options: Options = {} 30 | ): Promise => { 31 | const files = walkTree( 32 | options.directory || path.join(PROJECT_DIRECTORY, "routes") 33 | ) 34 | 35 | const routes = await generateRoutes(files) 36 | 37 | for (const { url, exports } of routes) { 38 | const exportedMethods = Object.entries(exports) 39 | 40 | for (const [method, handler] of exportedMethods) { 41 | const methodKey = getMethodKey(method) 42 | const handlers = getHandlers(handler) 43 | 44 | if ( 45 | !options.additionalMethods?.includes(methodKey) && 46 | !config.DEFAULT_METHOD_EXPORTS.includes(methodKey) 47 | ) 48 | continue 49 | 50 | app[methodKey](url, ...handlers) 51 | } 52 | 53 | // wildcard default export route matching 54 | if (typeof exports.default !== "undefined") { 55 | if (isHandler(exports.default)) { 56 | app.all.apply(app, [url, ...getHandlers(exports.default)]) 57 | } else if ( 58 | typeof exports.default === "object" && 59 | isHandler(exports.default.default) 60 | ) { 61 | app.all.apply(app, [url, ...getHandlers(exports.default.default)]) 62 | } 63 | } 64 | } 65 | 66 | return app 67 | } 68 | 69 | export default createRouter 70 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Express, Router, Handler } from "express" 2 | 3 | export type ExpressLike = Express | Router 4 | 5 | export interface Options { 6 | /** 7 | * The routes entry directory (optional) 8 | * 9 | * ```ts 10 | * await createRouter(app, { 11 | * directory: path.join(__dirname, "pages") 12 | * }) 13 | * ``` 14 | */ 15 | directory?: string 16 | /** 17 | * Additional methods that match an export from a route like `ws` 18 | * 19 | * ```ts 20 | * // app.ts 21 | * import ws from "express-ws" 22 | * 23 | * const { app } = ws(express()) 24 | * 25 | * await createRouter(app, { 26 | * // without this the exported ws handler is ignored 27 | * additionalMethods: ["ws"] 28 | * }) 29 | * 30 | * // /routes/room.ts 31 | * export const ws = (ws, req) => { 32 | * ws.send("hello") 33 | * } 34 | * ``` 35 | */ 36 | additionalMethods?: string[] 37 | } 38 | 39 | export interface File { 40 | name: string 41 | path: string 42 | rel: string 43 | } 44 | 45 | type MethodExport = Handler | Handler[] 46 | 47 | interface MethodExports { 48 | get?: MethodExport 49 | post?: MethodExport 50 | put?: MethodExport 51 | patch?: MethodExport 52 | delete?: MethodExport 53 | head?: MethodExport 54 | connect?: MethodExport 55 | options?: MethodExport 56 | trace?: MethodExport 57 | 58 | [x: string]: MethodExport | undefined 59 | } 60 | 61 | type Exports = MethodExports & { 62 | default?: any 63 | } 64 | 65 | export interface Route { 66 | url: string 67 | priority: number 68 | exports: Exports 69 | } 70 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "express" 2 | import type { ParsedPath } from "path" 3 | 4 | import type { Route } from "./types" 5 | 6 | import config from "./config" 7 | 8 | export const isCjs = () => typeof module !== "undefined" && !!module?.exports 9 | 10 | /** 11 | * @param parsedFile 12 | * 13 | * @returns Boolean Whether or not the file has to be excluded from route generation 14 | */ 15 | export const isFileIgnored = (parsedFile: ParsedPath) => 16 | !config.VALID_FILE_EXTENSIONS.includes(parsedFile.ext.toLowerCase()) || 17 | config.INVALID_NAME_SUFFIXES.some(suffix => 18 | parsedFile.base.toLowerCase().endsWith(suffix) 19 | ) || 20 | parsedFile.name.startsWith(config.IGNORE_PREFIX_CHAR) || 21 | parsedFile.dir.startsWith(`/${config.IGNORE_PREFIX_CHAR}`) 22 | 23 | export const isHandler = (handler: unknown): handler is Handler | Handler[] => 24 | typeof handler === "function" || Array.isArray(handler) 25 | 26 | /** 27 | * @param routes 28 | * 29 | * @returns An array of sorted routes based on their priority 30 | */ 31 | export const prioritizeRoutes = (routes: Route[]) => 32 | routes.sort((a, b) => a.priority - b.priority) 33 | 34 | /** 35 | * ```ts 36 | * mergePaths("/posts/[id]", "index.ts") -> "/posts/[id]/index.ts" 37 | * ``` 38 | * 39 | * @param paths An array of mergeable paths 40 | * 41 | * @returns A unification of all paths provided 42 | */ 43 | export const mergePaths = (...paths: string[]) => 44 | "/" + 45 | paths 46 | .map(path => path.replace(/^\/|\/$/g, "")) 47 | .filter(path => path !== "") 48 | .join("/") 49 | 50 | const regBackets = /\[([^}]*)\]/g 51 | 52 | const transformBrackets = (value: string) => 53 | regBackets.test(value) ? value.replace(regBackets, (_, s) => `:${s}`) : value 54 | 55 | /** 56 | * @param path 57 | * 58 | * @returns A new path with all wrapping `[]` replaced by prefixed `:` 59 | */ 60 | export const convertParamSyntax = (path: string) => { 61 | const subpaths: string[] = [] 62 | 63 | for (const subpath of path.split("/")) { 64 | subpaths.push(transformBrackets(subpath)) 65 | } 66 | 67 | return mergePaths(...subpaths) 68 | } 69 | 70 | /** 71 | * ```ts 72 | * convertCatchallSyntax("/posts/:...catchall") -> "/posts/*" 73 | * ``` 74 | * 75 | * @param url 76 | * 77 | * @returns A new url with all `:...` replaced by `*` 78 | */ 79 | export const convertCatchallSyntax = (url: string) => 80 | url.replace(/:\.\.\.\w+/g, "*") 81 | 82 | export const buildRoutePath = (parsedFile: ParsedPath): string => { 83 | // Normalize the directory path 84 | const normalizedDir = parsedFile.dir === parsedFile.root ? '/' : parsedFile.dir.startsWith('/') ? parsedFile.dir : `/${parsedFile.dir}`; 85 | 86 | // Handle index files specially 87 | if (parsedFile.name === 'index') { 88 | return normalizedDir === '/' ? '/' : normalizedDir; 89 | } 90 | 91 | // Handle index.something files (like index.mod) 92 | if (parsedFile.name.startsWith('index.')) { 93 | return normalizedDir === '/' ? '/' : normalizedDir; 94 | } 95 | 96 | // For regular files 97 | return `${normalizedDir === '/' ? '' : normalizedDir}/${parsedFile.name}`; 98 | } 99 | 100 | /** 101 | * @param path 102 | * 103 | * @returns A new path with all wrapping `[]` replaced by prefixed `:` and all `:...` replaced by `*` 104 | */ 105 | export const buildRouteUrl = (path: string) => { 106 | const url = convertParamSyntax(path) 107 | return convertCatchallSyntax(url) 108 | } 109 | 110 | /** 111 | * The smaller the number the higher the priority with zero indicating highest priority 112 | * 113 | * @param url 114 | * 115 | * @returns An integer ranging from 0 to Infinity 116 | */ 117 | export const calculatePriority = (url: string) => { 118 | const depth = url.match(/\/.+?/g)?.length || 0 119 | const specifity = url.match(/\/:.+?/g)?.length || 0 120 | const catchall = url.match(/\/\*/g)?.length > 0 ? Infinity : 0 121 | 122 | return depth + specifity + catchall 123 | } 124 | 125 | export const getHandlers = (handler: Handler | Handler[]): Handler[] => { 126 | if (!Array.isArray(handler)) return [handler] 127 | return handler 128 | } 129 | 130 | export const getMethodKey = (method: string) => { 131 | let methodKey = method.toLowerCase() 132 | 133 | if (methodKey === "del") return "delete" 134 | 135 | return methodKey 136 | } 137 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "dist/esm", 9 | "resolveJsonModule": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "skipLibCheck": true, 13 | "allowJs": false, 14 | "declaration": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "dist", 27 | "example", 28 | "rollup.config.js" 29 | ], 30 | "lib": [ 31 | "es2015" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------