├── .nvmrc ├── .gitattributes ├── .npmrc ├── .commitlintrc.json ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc.json ├── src ├── utils │ ├── debug.ts │ ├── http-methods.ts │ ├── safe-decode-uri-components.ts │ ├── path-helpers.ts │ ├── parameter-helpers.ts │ └── path-to-regexp-wrapper.ts ├── index.ts ├── types.ts └── layer.ts ├── .lintstagedrc.json ├── tsconfig.test.json ├── .prettierignore ├── tsconfig.typecheck.json ├── .gitignore ├── recipes ├── router-module-loader.ts ├── restful-api-structure │ ├── restful-api-structure.ts │ └── restful-api-structure.test.ts ├── api-versioning │ ├── api-versioning.ts │ └── api-versioning.test.ts ├── health-checks │ ├── health-checks.ts │ └── health-checks.test.ts ├── request-validation │ ├── request-validation.ts │ └── request-validation.test.ts ├── authentication-authorization │ ├── authentication-authorization.test.ts │ └── authentication-authorization.ts ├── pagination │ ├── pagination.test.ts │ └── pagination.ts ├── parameter-validation │ ├── parameter-validation.ts │ └── parameter-validation.test.ts ├── typescript-recipe │ ├── typescript-recipe.test.ts │ └── typescript-recipe.ts ├── common.ts ├── error-handling │ ├── error-handling.test.ts │ └── error-handling.ts ├── README.md └── nested-routes │ ├── nested-routes.ts │ └── nested-routes.test.ts ├── tsconfig.ts-node.json ├── tsconfig.bench.json ├── tsconfig.recipes.json ├── tsup.config.ts ├── LICENSE ├── eslint.config.js ├── tsconfig.json ├── bench ├── util.ts ├── REQUIREMENTS.md ├── server.ts ├── make.ts ├── run-bench.ts └── run.ts ├── test ├── utils │ ├── http-methods.test.ts │ ├── path-helpers.test.ts │ ├── parameter-helpers.test.ts │ └── path-to-regexp-wrapper.test.ts ├── index.test.ts └── layer.test.ts ├── package.json └── .github └── workflows └── ci.yml /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.6.0 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged && npm run test:all 5 | -------------------------------------------------------------------------------- /src/utils/debug.ts: -------------------------------------------------------------------------------- 1 | import debugModule from 'debug'; 2 | 3 | const debug = debugModule('koa-router'); 4 | 5 | export { debug }; 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts}": ["prettier --write", "eslint --fix"], 3 | "*.{js}": ["prettier --write", "eslint --fix"], 4 | "*.{json,md,yml,yaml}": ["prettier --write"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "skipLibCheck": true, 6 | "rootDir": ".", 7 | "noUnusedLocals": false, 8 | "noUnusedParameters": false 9 | }, 10 | "include": ["src/**/*.ts", "test/**/*.ts"], 11 | "exclude": ["node_modules", "dist"] 12 | } 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build outputs 5 | dist/ 6 | coverage/ 7 | 8 | # Lock files 9 | yarn.lock 10 | package-lock.json 11 | 12 | # Logs 13 | *.log 14 | 15 | # OS files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # IDE 20 | .vscode/ 21 | .idea/ 22 | 23 | # Git 24 | .git/ 25 | 26 | # Temporary files 27 | *.tmp 28 | *.temp 29 | 30 | -------------------------------------------------------------------------------- /tsconfig.typecheck.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "skipLibCheck": true, 6 | "rootDir": "." 7 | }, 8 | "include": [ 9 | "src/**/*.ts", 10 | "recipes/**/*.ts", 11 | "bench/**/*.ts", 12 | "test/**/*.ts" 13 | ], 14 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS # 2 | ################### 3 | .DS_Store 4 | .idea 5 | Thumbs.db 6 | tmp/ 7 | temp/ 8 | bench-result.txt 9 | docs/ 10 | 11 | # Node.js # 12 | ################### 13 | node_modules 14 | 15 | 16 | # Build # 17 | ################### 18 | dist 19 | build 20 | 21 | # NYC # 22 | ################### 23 | coverage 24 | *.lcov 25 | .nyc_output 26 | -------------------------------------------------------------------------------- /recipes/router-module-loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Router Module Loader 3 | * 4 | * Centralized import point for router modules. 5 | * This allows easy switching between router versions for testing. 6 | * 7 | * To switch versions, update the import path below: 8 | * - For latest and local development: '../src/index' 9 | * - For dist build: '../dist/index' 10 | * - For published package: '@koa/router' 11 | */ 12 | export { default, default as Router } from '../src/index'; 13 | export type * from '../src/index'; 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @koa/router - RESTful resource routing middleware for Koa 3 | * 4 | * @module @koa/router 5 | */ 6 | 7 | export type { 8 | RouterOptions, 9 | RouterOptionsWithMethods, 10 | LayerOptions, 11 | UrlOptions, 12 | RouterParameterMiddleware, 13 | RouterMiddleware, 14 | RouterMethodFunction, 15 | RouterContext, 16 | MatchResult, 17 | AllowedMethodsOptions, 18 | Layer, 19 | HttpMethod, 20 | RouterWithMethods 21 | } from './types'; 22 | 23 | export { default, Router, type RouterInstance } from './router'; 24 | -------------------------------------------------------------------------------- /tsconfig.ts-node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "noImplicitAny": false, 7 | "noUnusedLocals": false, 8 | "noUnusedParameters": false, 9 | "strictNullChecks": false, 10 | "strictPropertyInitialization": false, 11 | "strictFunctionTypes": false 12 | }, 13 | "ts-node": { 14 | "transpileOnly": true, 15 | "files": true, 16 | "include": ["recipes/**/*.ts", "src/**/*.ts"] 17 | }, 18 | "include": ["recipes/**/*.ts", "src/**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/http-methods.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP methods utilities 3 | */ 4 | 5 | import http from 'node:http'; 6 | 7 | /** 8 | * Get all HTTP methods in lowercase 9 | * @returns Array of HTTP method names in lowercase 10 | */ 11 | export function getAllHttpMethods(): string[] { 12 | return http.METHODS.map((method) => method.toLowerCase()); 13 | } 14 | 15 | /** 16 | * Common HTTP methods that are explicitly defined on the Router class 17 | */ 18 | export const COMMON_HTTP_METHODS: string[] = [ 19 | 'get', 20 | 'post', 21 | 'put', 22 | 'patch', 23 | 'delete', 24 | 'del', 25 | 'head', 26 | 'options' 27 | ]; 28 | -------------------------------------------------------------------------------- /tsconfig.bench.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "rootDir": ".", 9 | "outDir": "./dist-bench" 10 | }, 11 | "include": ["bench/**/*.ts", "src/**/*.ts"], 12 | "ts-node": { 13 | "transpileOnly": true, 14 | "files": true, 15 | "compilerOptions": { 16 | "module": "CommonJS", 17 | "moduleResolution": "node", 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.recipes.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "skipLibCheck": true, 9 | "rootDir": "." 10 | }, 11 | "include": ["recipes/**/*.ts"], 12 | "exclude": ["node_modules", "dist", "test"], 13 | "ts-node": { 14 | "transpileOnly": true, 15 | "files": true, 16 | "compilerOptions": { 17 | "module": "CommonJS", 18 | "moduleResolution": "node", 19 | "esModuleInterop": true, 20 | "allowSyntheticDefaultImports": true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/safe-decode-uri-components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safe decodeURIComponent, won't throw any error. 3 | * If `decodeURIComponent` error happen, just return the original value. 4 | * 5 | * Note: This function is used only for route/path parameters, not query parameters. 6 | * In URL path segments, `+` is a literal character (not a space), so we don't 7 | * replace `+` with spaces. For query parameters, use a different decoder that 8 | * handles `application/x-www-form-urlencoded` format. 9 | * 10 | * @param text - Text to decode 11 | * @returns URL decoded string 12 | * @private 13 | */ 14 | export function safeDecodeURIComponent(text: string): string { 15 | try { 16 | return decodeURIComponent(text); 17 | } catch { 18 | return text; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | const tsupConfig = defineConfig({ 4 | name: '@koa/router', 5 | entry: ['src/index.ts'], 6 | target: 'esnext', 7 | format: ['cjs', 'esm'], 8 | dts: true, 9 | splitting: false, 10 | sourcemap: false, 11 | clean: true, 12 | platform: 'node', 13 | footer: ({ format }) => { 14 | // Ensure CommonJS default export works as expected for backwards compatibility 15 | // This allows `const Router = require('@koa/router')` to work 16 | if (format === 'cjs') { 17 | return { 18 | js: `if (module.exports.default) { 19 | Object.assign(module.exports.default, module.exports); 20 | module.exports = module.exports.default; 21 | }` 22 | }; 23 | } 24 | return {}; 25 | } 26 | }); 27 | 28 | export default tsupConfig; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 @koajs maintainers and contributors 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const js = require('@eslint/js'); 2 | const unicorn = require('eslint-plugin-unicorn'); 3 | const tsPlugin = require('@typescript-eslint/eslint-plugin'); 4 | const tsParser = require('@typescript-eslint/parser'); 5 | 6 | const unicornPlugin = unicorn.default || unicorn; 7 | 8 | module.exports = [ 9 | { 10 | ignores: [ 11 | 'node_modules/**', 12 | 'coverage/**', 13 | 'dist/**', 14 | 'bench/**', 15 | 'examples/**', 16 | 'recipes/*.ts' 17 | ] 18 | }, 19 | // JavaScript files 20 | { 21 | files: ['**/*.{js,cjs,mjs}'], 22 | ...js.configs.recommended, 23 | plugins: { unicorn: unicornPlugin }, 24 | rules: unicornPlugin.configs.recommended.rules 25 | }, 26 | // TypeScript source files 27 | { 28 | files: ['src/**/*.ts'], 29 | languageOptions: { 30 | parser: tsParser, 31 | parserOptions: { project: './tsconfig.json' } 32 | }, 33 | plugins: { '@typescript-eslint': tsPlugin, unicorn: unicornPlugin }, 34 | rules: unicornPlugin.configs.recommended.rules 35 | }, 36 | // TypeScript test files (relaxed) 37 | { 38 | files: ['test/**/*.ts', 'recipes/**/*.test.ts', '*.config.ts'], 39 | languageOptions: { parser: tsParser }, 40 | plugins: { '@typescript-eslint': tsPlugin } 41 | } 42 | ]; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "nodenext", 5 | "lib": ["ES2023"], 6 | "moduleResolution": "nodenext", 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "removeComments": false, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "strict": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "strictFunctionTypes": true, 21 | "strictBindCallApply": true, 22 | "strictPropertyInitialization": true, 23 | "noImplicitThis": true, 24 | "alwaysStrict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noImplicitReturns": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "noUncheckedIndexedAccess": false, 30 | "noImplicitOverride": true, 31 | "noPropertyAccessFromIndexSignature": false, 32 | "skipLibCheck": true, 33 | "forceConsistentCasingInFileNames": true, 34 | "experimentalDecorators": true, 35 | "emitDecoratorMetadata": true, 36 | "baseUrl": ".", 37 | "paths": { 38 | "@/*": ["src/*"] 39 | }, 40 | "types": ["node"] 41 | }, 42 | "include": ["src/**/*.ts"], 43 | "exclude": [ 44 | "node_modules", 45 | "dist", 46 | "test", 47 | "recipes/**/*.ts", 48 | "**/*.test.ts", 49 | "**/*.spec.ts" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /recipes/restful-api-structure/restful-api-structure.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RESTful API Structure Recipe 3 | * 4 | * Organize your API with nested routers for clean separation. 5 | * 6 | * Note: User, Post, and other model references are placeholders. 7 | * Replace them with your actual database models or service layer. 8 | */ 9 | import Koa from 'koa'; 10 | import Router from '../router-module-loader'; 11 | import { User, ContextWithBody } from '../common'; 12 | import type { RouterContext } from '../router-module-loader'; 13 | 14 | const app = new Koa(); 15 | 16 | const usersRouter = new Router({ prefix: '/users' }); 17 | usersRouter.get('/', async (ctx: RouterContext) => { 18 | ctx.body = await User.findAll(); 19 | }); 20 | usersRouter.post('/', async (ctx: ContextWithBody) => { 21 | const body = ctx.request.body as 22 | | { email?: string; name?: string } 23 | | undefined; 24 | ctx.body = await User.create(body || {}); 25 | }); 26 | usersRouter.get('/:id', async (ctx: RouterContext) => { 27 | ctx.body = await User.findById(ctx.params.id); 28 | }); 29 | usersRouter.put('/:id', async (ctx: ContextWithBody) => { 30 | const body = ctx.request.body as 31 | | { email?: string; name?: string } 32 | | undefined; 33 | ctx.body = await User.update(ctx.params.id, body || {}); 34 | }); 35 | usersRouter.delete('/:id', async (ctx: RouterContext) => { 36 | await User.delete(ctx.params.id); 37 | ctx.status = 204; 38 | }); 39 | 40 | const apiRouter = new Router({ prefix: '/api/v1' }); 41 | apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods()); 42 | 43 | app.use(apiRouter.routes()); 44 | app.use(apiRouter.allowedMethods()); 45 | -------------------------------------------------------------------------------- /recipes/api-versioning/api-versioning.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Versioning Recipe 3 | * 4 | * Implement API versioning with multiple routers. 5 | * 6 | * This example shows how to maintain multiple API versions 7 | * simultaneously with separate routers. 8 | */ 9 | import Koa from 'koa'; 10 | 11 | import Router from '../router-module-loader'; 12 | import type { RouterContext } from '../router-module-loader'; 13 | 14 | const app = new Koa(); 15 | 16 | const getUsersV1 = async (ctx: RouterContext) => { 17 | ctx.body = { users: [], version: 'v1' }; 18 | }; 19 | 20 | const getUserV1 = async (ctx: RouterContext) => { 21 | ctx.body = { user: { id: ctx.params.id }, version: 'v1' }; 22 | }; 23 | 24 | const getUsersV2 = async (ctx: RouterContext) => { 25 | ctx.body = { 26 | users: [], 27 | version: 'v2', 28 | metadata: { count: 0, timestamp: new Date() } 29 | }; 30 | }; 31 | 32 | const getUserV2 = async (ctx: RouterContext) => { 33 | ctx.body = { 34 | user: { id: ctx.params.id }, 35 | version: 'v2', 36 | links: { self: `/api/v2/users/${ctx.params.id}` } 37 | }; 38 | }; 39 | 40 | const v1Router = new Router({ prefix: '/api/v1' }); 41 | v1Router.get('/users', getUsersV1); 42 | v1Router.get('/users/:id', getUserV1); 43 | 44 | const v2Router = new Router({ prefix: '/api/v2' }); 45 | v2Router.get('/users', getUsersV2); 46 | v2Router.get('/users/:id', getUserV2); 47 | 48 | const apiRouter = new Router({ prefix: '/api' }); 49 | apiRouter.use(v1Router.routes(), v1Router.allowedMethods()); 50 | apiRouter.use(v2Router.routes(), v2Router.allowedMethods()); 51 | 52 | app.use(apiRouter.routes()); 53 | app.use(apiRouter.allowedMethods()); 54 | -------------------------------------------------------------------------------- /bench/util.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import chalk from 'chalk'; 3 | 4 | export const operations = 1_000_000; 5 | 6 | /** 7 | * Get current high-resolution time in milliseconds 8 | * Uses process.hrtime.bigint() for better precision 9 | */ 10 | export function now(): number { 11 | return Number(process.hrtime.bigint()) / 1e6; 12 | } 13 | 14 | /** 15 | * Calculate operations per second 16 | */ 17 | export function getOpsSec(ms: number): number { 18 | return Math.round((operations * 1000) / ms); 19 | } 20 | 21 | /** 22 | * Print benchmark result 23 | */ 24 | export function print(name: string, time: number): number { 25 | const opsSec = getOpsSec(now() - time); 26 | console.log( 27 | chalk.yellow(name.padEnd(30)), 28 | opsSec.toLocaleString().padStart(12), 29 | 'ops/sec' 30 | ); 31 | return opsSec; 32 | } 33 | 34 | /** 35 | * Print section title 36 | */ 37 | export function title(name: string): void { 38 | console.log( 39 | chalk.green(` 40 | ${'='.repeat(name.length + 4)} 41 | ${name} 42 | ${'='.repeat(name.length + 4)}`) 43 | ); 44 | } 45 | 46 | /** 47 | * Warmup function - runs the benchmark once to warm up JIT 48 | */ 49 | export function warmup(fn: () => void, iterations = 10_000): void { 50 | for (let i = 0; i < iterations; i++) { 51 | fn(); 52 | } 53 | } 54 | 55 | export class Queue { 56 | private q: Array<(callback: () => void) => void> = []; 57 | private running = false; 58 | 59 | add(job: (callback: () => void) => void): void { 60 | this.q.push(job); 61 | if (!this.running) this.run(); 62 | } 63 | 64 | private run(): void { 65 | this.running = true; 66 | const job = this.q.shift(); 67 | if (job) { 68 | job(() => { 69 | if (this.q.length > 0) { 70 | this.run(); 71 | } else { 72 | this.running = false; 73 | } 74 | }); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /recipes/health-checks/health-checks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Health Checks Recipe 3 | * 4 | * Add health check endpoints for monitoring and orchestration. 5 | * 6 | * Note: db and redis are placeholders. Replace with your actual 7 | * database and cache clients. 8 | */ 9 | import { db, redis } from '../common'; 10 | import Router from '../router-module-loader'; 11 | import type { RouterContext } from '../router-module-loader'; 12 | 13 | const router = new Router(); 14 | 15 | type HealthStatus = { 16 | status: 'ok' | 'degraded'; 17 | timestamp: string; 18 | uptime: number; 19 | checks: Record; 20 | }; 21 | 22 | router.get('/health', async (ctx: RouterContext) => { 23 | const health: HealthStatus = { 24 | status: 'ok', 25 | timestamp: new Date().toISOString(), 26 | uptime: process.uptime(), 27 | checks: {} 28 | }; 29 | 30 | try { 31 | await db.authenticate(); 32 | health.checks.database = 'ok'; 33 | } catch (err) { 34 | health.checks.database = 'error'; 35 | health.status = 'degraded'; 36 | } 37 | 38 | try { 39 | await redis.ping(); 40 | health.checks.redis = 'ok'; 41 | } catch (err) { 42 | health.checks.redis = 'error'; 43 | health.status = 'degraded'; 44 | } 45 | 46 | ctx.status = health.status === 'ok' ? 200 : 503; 47 | ctx.body = health; 48 | }); 49 | 50 | router.get('/ready', async (ctx: RouterContext) => { 51 | const isReady = await checkReadiness(); 52 | ctx.status = isReady ? 200 : 503; 53 | ctx.body = { ready: isReady }; 54 | }); 55 | 56 | router.get('/live', async (ctx: RouterContext) => { 57 | ctx.body = { alive: true }; 58 | }); 59 | 60 | async function checkReadiness(): Promise { 61 | try { 62 | await db.authenticate(); 63 | 64 | // Check other critical services 65 | // await redis.ping(); 66 | 67 | return true; 68 | } catch (err) { 69 | return false; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /bench/REQUIREMENTS.md: -------------------------------------------------------------------------------- 1 | # Benchmark Requirements 2 | 3 | This folder contains benchmark scripts that require the `wrk` HTTP benchmarking tool. 4 | 5 | ## Installation Instructions 6 | 7 | ### macOS 8 | 9 | ```bash 10 | brew install wrk 11 | ``` 12 | 13 | ### Linux 14 | 15 | ```bash 16 | sudo apt-get install wrk 17 | # or use your package manager (yum, dnf, pacman, etc.) 18 | ``` 19 | 20 | ### Windows 21 | 22 | `wrk` does not natively support Windows. You have the following options: 23 | 24 | #### Option 1: Use WSL (Windows Subsystem for Linux) - Recommended 25 | 26 | 1. Install WSL: 27 | 28 | ```powershell 29 | wsl --install 30 | ``` 31 | 32 | 2. Open WSL terminal and install wrk: 33 | 34 | ```bash 35 | sudo apt-get update 36 | sudo apt-get install wrk 37 | ``` 38 | 39 | 3. Run benchmarks from WSL or set `WRK_PATH` environment variable: 40 | ```powershell 41 | set WRK_PATH=wsl wrk 42 | ``` 43 | 44 | #### Option 2: Use Alternative Tools 45 | 46 | **autocannon** (Node.js-based, cross-platform): 47 | 48 | ```bash 49 | npm install -g autocannon 50 | ``` 51 | 52 | Then set `WRK_PATH`: 53 | 54 | ```powershell 55 | set WRK_PATH=autocannon 56 | ``` 57 | 58 | **Apache Bench (ab)**: 59 | 60 | - Install Apache HTTP Server which includes `ab` tool 61 | - Set `WRK_PATH` to point to the `ab` executable 62 | 63 | ## Environment Variables 64 | 65 | You can customize the benchmark tool using environment variables: 66 | 67 | - `WRK_PATH`: Path to the wrk executable (default: `wrk`) 68 | - `PORT`: Server port for benchmarks (default: `3000`) 69 | 70 | Example: 71 | 72 | ```bash 73 | export WRK_PATH=/usr/local/bin/wrk 74 | export PORT=3000 75 | npm run bench 10 false 76 | ``` 77 | 78 | ## Usage 79 | 80 | After installing `wrk`, you can run benchmarks: 81 | 82 | ```bash 83 | # Single benchmark 84 | npm run bench 85 | npm run bench 10 false 86 | 87 | # Run all benchmarks 88 | npm run bench:all 89 | ``` 90 | -------------------------------------------------------------------------------- /src/utils/path-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Path handling utilities 3 | */ 4 | 5 | import { compilePathToRegexp } from './path-to-regexp-wrapper'; 6 | import type { LayerOptions } from '../types'; 7 | 8 | /** 9 | * Check if a path has parameters (like :id, :name, etc.) 10 | * @param path - Path to check 11 | * @param options - path-to-regexp options 12 | * @returns True if path contains parameters 13 | */ 14 | export function hasPathParameters( 15 | path: string, 16 | options: LayerOptions = {} 17 | ): boolean { 18 | if (!path) { 19 | return false; 20 | } 21 | 22 | const { keys } = compilePathToRegexp(path, options); 23 | return keys.length > 0; 24 | } 25 | 26 | /** 27 | * Determine the appropriate middleware path based on router configuration 28 | * @param explicitPath - Explicitly provided path (if any) 29 | * @param hasPrefixParameters - Whether the router prefix has parameters 30 | * @returns Object with path and pathAsRegExp flag 31 | */ 32 | export function determineMiddlewarePath( 33 | explicitPath: string | RegExp | undefined, 34 | hasPrefixParameters: boolean 35 | ): { path: string | RegExp; pathAsRegExp: boolean } { 36 | if (explicitPath !== undefined) { 37 | if (typeof explicitPath === 'string') { 38 | if (explicitPath === '') { 39 | return { 40 | path: '{/*rest}', 41 | pathAsRegExp: false 42 | }; 43 | } 44 | 45 | if (explicitPath === '/') { 46 | return { 47 | path: '/', 48 | pathAsRegExp: false 49 | }; 50 | } 51 | 52 | return { 53 | path: explicitPath, 54 | pathAsRegExp: false 55 | }; 56 | } 57 | 58 | return { 59 | path: explicitPath, 60 | pathAsRegExp: true 61 | }; 62 | } 63 | 64 | if (hasPrefixParameters) { 65 | return { 66 | path: '{/*rest}', 67 | pathAsRegExp: false 68 | }; 69 | } 70 | 71 | return { 72 | path: String.raw`(?:\/|$)`, 73 | pathAsRegExp: true 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /bench/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benchmark server 3 | * 4 | * Creates a Koa server with routes for benchmarking. 5 | * Configured via environment variables: 6 | * - FACTOR: Number of routes to create (default: 10) 7 | * - USE_MIDDLEWARE: Whether to use middleware (default: false) 8 | * - PORT: Server port (default: 3000) 9 | */ 10 | 11 | import process from 'node:process'; 12 | import Koa from 'koa'; 13 | 14 | import Router, { RouterContext, RouterMiddleware } from '../src'; 15 | 16 | const app = new Koa(); 17 | const router = new Router(); 18 | 19 | const ok: RouterMiddleware = (ctx: RouterContext): void => { 20 | ctx.status = 200; 21 | }; 22 | 23 | const passthrough: RouterMiddleware = (_ctx, next) => next(); 24 | 25 | const n = Number.parseInt(process.env.FACTOR || '10', 10); 26 | const useMiddleware = process.env.USE_MIDDLEWARE === 'true'; 27 | 28 | router.get('/_health', ok); 29 | 30 | for (let i = n; i > 0; i--) { 31 | if (useMiddleware) router.use(passthrough); 32 | router.get(`/${i}/one`, ok); 33 | router.get(`/${i}/one/two`, ok); 34 | router.get(`/${i}/one/two/:three`, ok); 35 | router.get(`/${i}/one/two/:three/:four`, ok); 36 | router.get(`/${i}/one/two/:three/:four/five`, ok); 37 | router.get(`/${i}/one/two/:three/:four/five/six`, ok); 38 | } 39 | 40 | const grandchild = new Router(); 41 | 42 | if (useMiddleware) grandchild.use(passthrough); 43 | grandchild.get('/', ok); 44 | grandchild.get('/:id', ok); 45 | grandchild.get('/:id/seven', ok); 46 | grandchild.get('/:id/seven', ok); 47 | grandchild.get('/:id/seven/eight', ok); 48 | 49 | for (let i = n; i > 0; i--) { 50 | const child = new Router(); 51 | if (useMiddleware) child.use(passthrough); 52 | child.get(`/:${''.padStart(i, 'a')}`, ok); 53 | child.use('/grandchild', grandchild.routes(), grandchild.allowedMethods()); 54 | router.use(`/${i}/child`, child.routes(), child.allowedMethods()); 55 | } 56 | 57 | if (process.env.DEBUG) { 58 | // eslint-disable-next-line no-console 59 | console.log('Router debug info:', router); 60 | } 61 | 62 | app.use(router.routes()); 63 | 64 | process.stdout.write(`mw: ${useMiddleware} factor: ${n} requests/sec`); 65 | 66 | const port = Number.parseInt(process.env.PORT || '3000', 10); 67 | app.listen(port); 68 | -------------------------------------------------------------------------------- /bench/make.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benchmark Makefile equivalent in TypeScript 3 | * 4 | * Runs all benchmark tests with different factors and middleware configurations. 5 | * This replaces the Makefile for cross-platform compatibility. 6 | */ 7 | 8 | import { spawn } from 'node:child_process'; 9 | import { join } from 'node:path'; 10 | 11 | const projectRoot = join( 12 | typeof __dirname !== 'undefined' ? __dirname : process.cwd(), 13 | '..' 14 | ); 15 | 16 | const factors = [1, 5, 10, 20, 50, 100, 200, 500, 1000]; 17 | const middlewareOptions = [false, true]; 18 | 19 | async function runBenchmark( 20 | factor: number, 21 | useMiddleware: boolean 22 | ): Promise { 23 | return new Promise((resolve, reject) => { 24 | const childProcess = spawn( 25 | 'node', 26 | [ 27 | '--require', 28 | 'ts-node/register', 29 | 'bench/run.ts', 30 | String(factor), 31 | String(useMiddleware) 32 | ], 33 | { 34 | env: { 35 | ...process.env, 36 | TS_NODE_PROJECT: 'tsconfig.bench.json' 37 | }, 38 | stdio: 'inherit', 39 | cwd: projectRoot 40 | } 41 | ); 42 | 43 | childProcess.on('close', (code) => { 44 | if (code === 0) { 45 | resolve(); 46 | } else { 47 | reject(new Error(`Benchmark failed with code ${code}`)); 48 | } 49 | }); 50 | 51 | childProcess.on('error', (error) => { 52 | reject(error); 53 | }); 54 | }); 55 | } 56 | 57 | async function runAllBenchmarks(): Promise { 58 | console.log('Running all benchmarks...\n'); 59 | 60 | for (const useMiddleware of middlewareOptions) { 61 | console.log(`\nMiddleware: ${useMiddleware}\n`); 62 | 63 | for (const factor of factors) { 64 | try { 65 | await runBenchmark(factor, useMiddleware); 66 | await new Promise((resolve) => setTimeout(resolve, 100)); 67 | } catch (error) { 68 | console.error( 69 | `Error running benchmark with factor ${factor}, middleware ${useMiddleware}:`, 70 | error 71 | ); 72 | process.exit(1); 73 | } 74 | } 75 | } 76 | 77 | console.log('\nAll benchmarks completed!'); 78 | } 79 | 80 | runAllBenchmarks().catch((error) => { 81 | console.error('Fatal error:', error); 82 | process.exit(1); 83 | }); 84 | -------------------------------------------------------------------------------- /recipes/api-versioning/api-versioning.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for API Versioning Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | 12 | describe('API Versioning', () => { 13 | it('should support multiple API versions', async () => { 14 | const app = new Koa(); 15 | 16 | const getUsersV1 = async (ctx: RouterContext) => { 17 | ctx.body = { users: [{ id: 1 }], version: 'v1' }; 18 | }; 19 | 20 | const getUserV1 = async (ctx: RouterContext) => { 21 | ctx.body = { user: { id: ctx.params.id }, version: 'v1' }; 22 | }; 23 | 24 | const getUsersV2 = async (ctx: RouterContext) => { 25 | ctx.body = { 26 | users: [{ id: 1 }], 27 | version: 'v2', 28 | metadata: { count: 1, timestamp: new Date().toISOString() } 29 | }; 30 | }; 31 | 32 | const getUserV2 = async (ctx: RouterContext) => { 33 | ctx.body = { 34 | user: { id: ctx.params.id }, 35 | version: 'v2', 36 | links: { self: `/api/v2/users/${ctx.params.id}` } 37 | }; 38 | }; 39 | 40 | const v1Router = new Router({ prefix: '/v1' }); 41 | v1Router.get('/users', getUsersV1); 42 | v1Router.get('/users/:id', getUserV1); 43 | 44 | const v2Router = new Router({ prefix: '/v2' }); 45 | v2Router.get('/users', getUsersV2); 46 | v2Router.get('/users/:id', getUserV2); 47 | 48 | const apiRouter = new Router({ prefix: '/api' }); 49 | apiRouter.use(v1Router.routes(), v1Router.allowedMethods()); 50 | apiRouter.use(v2Router.routes(), v2Router.allowedMethods()); 51 | 52 | app.use(apiRouter.routes()); 53 | app.use(apiRouter.allowedMethods()); 54 | 55 | const res1 = await request(http.createServer(app.callback())) 56 | .get('/api/v1/users') 57 | .expect(200); 58 | 59 | assert.strictEqual(res1.body.version, 'v1'); 60 | assert.strictEqual(Array.isArray(res1.body.users), true); 61 | 62 | const res2 = await request(http.createServer(app.callback())) 63 | .get('/api/v1/users/123') 64 | .expect(200); 65 | 66 | assert.strictEqual(res2.body.version, 'v1'); 67 | assert.strictEqual(res2.body.user.id, '123'); 68 | 69 | const res3 = await request(http.createServer(app.callback())) 70 | .get('/api/v2/users') 71 | .expect(200); 72 | 73 | assert.strictEqual(res3.body.version, 'v2'); 74 | assert.strictEqual(res3.body.metadata.count, 1); 75 | 76 | const res4 = await request(http.createServer(app.callback())) 77 | .get('/api/v2/users/456') 78 | .expect(200); 79 | 80 | assert.strictEqual(res4.body.version, 'v2'); 81 | assert.strictEqual(res4.body.links.self, '/api/v2/users/456'); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /recipes/request-validation/request-validation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Request Validation Recipe 3 | * 4 | * Validate request data with middleware. 5 | * Demonstrates: 6 | * - Joi schema validation 7 | * - Type-safe validation middleware using generics 8 | * - Proper body parsing with @koa/bodyparser 9 | * 10 | * Requires: yarn add joi @koa/bodyparser 11 | */ 12 | import Router from '../router-module-loader'; 13 | import type { RouterMiddleware } from '../router-module-loader'; 14 | import * as Joi from 'joi'; 15 | import { createUser, updateUser } from '../common'; 16 | 17 | // Import bodyparser types 18 | import '@koa/bodyparser'; 19 | 20 | const router = new Router(); 21 | 22 | /** 23 | * Generic validation middleware factory 24 | * Validates ctx.request.body against a Joi schema 25 | * After validation, body is guaranteed to match the schema 26 | */ 27 | const validate = (schema: Joi.ObjectSchema): RouterMiddleware => { 28 | return async (ctx, next) => { 29 | const { error, value } = schema.validate(ctx.request.body, { 30 | abortEarly: false, 31 | stripUnknown: true 32 | }); 33 | 34 | if (error) { 35 | ctx.status = 400; 36 | ctx.body = { 37 | error: 'Validation failed', 38 | details: error.details.map((d) => ({ 39 | field: d.path.join('.'), 40 | message: d.message 41 | })) 42 | }; 43 | return; 44 | } 45 | 46 | // Replace body with validated value 47 | ctx.request.body = value; 48 | await next(); 49 | }; 50 | }; 51 | 52 | // =========================================== 53 | // Validation Schemas 54 | // =========================================== 55 | 56 | type CreateUserInput = { 57 | email: string; 58 | password: string; 59 | name: string; 60 | }; 61 | 62 | type UpdateUserInput = { 63 | email?: string; 64 | name?: string; 65 | }; 66 | 67 | const createUserSchema = Joi.object({ 68 | email: Joi.string().email().required(), 69 | password: Joi.string().min(8).required(), 70 | name: Joi.string().min(2).required() 71 | }); 72 | 73 | const updateUserSchema = Joi.object({ 74 | email: Joi.string().email().optional(), 75 | name: Joi.string().min(2).optional() 76 | }); 77 | 78 | // =========================================== 79 | // Routes 80 | // =========================================== 81 | 82 | router.post('/users', validate(createUserSchema), async (ctx) => { 83 | // After validation, body is guaranteed to match CreateUserInput 84 | const body = ctx.request.body as CreateUserInput; 85 | ctx.body = await createUser(body); 86 | }); 87 | 88 | router.put('/users/:id', validate(updateUserSchema), async (ctx) => { 89 | // After validation, body is guaranteed to match UpdateUserInput 90 | const body = ctx.request.body as UpdateUserInput; 91 | ctx.body = await updateUser(ctx.params.id, body); 92 | }); 93 | 94 | export { router, validate, createUserSchema, updateUserSchema }; 95 | export type { CreateUserInput, UpdateUserInput }; 96 | -------------------------------------------------------------------------------- /src/utils/parameter-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parameter handling utilities for router.param() functionality 3 | */ 4 | 5 | import type { RouterParameterMiddleware } from '../types'; 6 | import type Layer from '../layer'; 7 | 8 | /** 9 | * Type for objects that have a param() method (Layer or Router) 10 | * Uses generic types to maintain type safety 11 | */ 12 | type ParameterCapable = { 13 | param( 14 | parameterName: string, 15 | middleware: RouterParameterMiddleware 16 | ): unknown; 17 | }; 18 | 19 | /** 20 | * Normalize param middleware to always be an array 21 | * @param paramMiddleware - Single middleware or array 22 | * @returns Array of middleware functions 23 | */ 24 | export function normalizeParameterMiddleware< 25 | StateT = unknown, 26 | ContextT = unknown, 27 | BodyT = unknown 28 | >( 29 | parameterMiddleware: 30 | | RouterParameterMiddleware 31 | | RouterParameterMiddleware[] 32 | | undefined 33 | ): RouterParameterMiddleware[] { 34 | if (!parameterMiddleware) { 35 | return []; 36 | } 37 | 38 | if (Array.isArray(parameterMiddleware)) { 39 | return parameterMiddleware; 40 | } 41 | 42 | return [parameterMiddleware]; 43 | } 44 | 45 | /** 46 | * Apply param middleware to a route 47 | * @param route - Route layer or router to apply middleware to 48 | * @param paramName - Name of the parameter 49 | * @param paramMiddleware - Middleware to apply 50 | */ 51 | export function applyParameterMiddlewareToRoute< 52 | StateT = unknown, 53 | ContextT = unknown, 54 | BodyT = unknown 55 | >( 56 | route: ParameterCapable, 57 | parameterName: string, 58 | parameterMiddleware: 59 | | RouterParameterMiddleware 60 | | RouterParameterMiddleware[] 61 | ): void { 62 | const middlewareList = normalizeParameterMiddleware( 63 | parameterMiddleware 64 | ); 65 | 66 | for (const middleware of middlewareList) { 67 | route.param(parameterName, middleware); 68 | } 69 | } 70 | 71 | /** 72 | * Apply all param middleware from params object to a route 73 | * @param route - Route layer 74 | * @param paramsObject - Object mapping param names to middleware 75 | */ 76 | export function applyAllParameterMiddleware< 77 | StateT = unknown, 78 | ContextT = unknown, 79 | BodyT = unknown 80 | >( 81 | route: Layer, 82 | parametersObject: Record< 83 | string, 84 | | RouterParameterMiddleware 85 | | RouterParameterMiddleware[] 86 | > 87 | ): void { 88 | const parameterNames = Object.keys(parametersObject); 89 | 90 | for (const parameterName of parameterNames) { 91 | const parameterMiddleware = parametersObject[parameterName]; 92 | applyParameterMiddlewareToRoute( 93 | route, 94 | parameterName, 95 | parameterMiddleware 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /recipes/authentication-authorization/authentication-authorization.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Authentication & Authorization Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | describe('Authentication & Authorization', () => { 14 | it('should authenticate requests with JWT token', async () => { 15 | const app = new Koa(); 16 | const router = new Router(); 17 | 18 | const User = { 19 | findById: async (id: string) => ({ id, name: 'John', role: 'user' }) 20 | }; 21 | 22 | const jwt = { 23 | verify: (token: string, _secret: string) => { 24 | if (token === 'valid-token') { 25 | return { userId: '123' }; 26 | } 27 | throw new Error('Invalid token'); 28 | } 29 | }; 30 | 31 | const authenticate = async (ctx: RouterContext, next: Next) => { 32 | const authHeader = ctx.headers.authorization || ctx.headers.Authorization; 33 | const token = 34 | typeof authHeader === 'string' 35 | ? authHeader.replace('Bearer ', '') 36 | : undefined; 37 | 38 | if (!token) { 39 | ctx.throw(401, 'Authentication required'); 40 | return; 41 | } 42 | 43 | try { 44 | const decoded = jwt.verify(token, 'secret') as { userId: string }; 45 | ctx.state.user = await User.findById(decoded.userId); 46 | return next(); 47 | } catch (err) { 48 | ctx.throw(401, 'Invalid token'); 49 | return; 50 | } 51 | }; 52 | 53 | const requireRole = 54 | (role: string) => async (ctx: RouterContext, next: Next) => { 55 | if (!ctx.state.user) { 56 | ctx.throw(401, 'Authentication required'); 57 | } 58 | 59 | if (ctx.state.user.role !== role) { 60 | ctx.throw(403, 'Insufficient permissions'); 61 | } 62 | 63 | await next(); 64 | }; 65 | 66 | router.get('/profile', authenticate, async (ctx: RouterContext) => { 67 | ctx.body = ctx.state.user; 68 | }); 69 | 70 | router.get( 71 | '/admin', 72 | authenticate, 73 | requireRole('admin'), 74 | async (ctx: RouterContext) => { 75 | ctx.body = { message: 'Admin access granted' }; 76 | } 77 | ); 78 | 79 | app.use(router.routes()); 80 | app.use(router.allowedMethods()); 81 | 82 | const res1 = await request(http.createServer(app.callback())) 83 | .get('/profile') 84 | .set('Authorization', 'Bearer valid-token') 85 | .expect(200); 86 | 87 | assert.strictEqual(res1.body.id, '123'); 88 | assert.strictEqual(res1.body.name, 'John'); 89 | 90 | await request(http.createServer(app.callback())) 91 | .get('/profile') 92 | .expect(401); 93 | 94 | await request(http.createServer(app.callback())) 95 | .get('/admin') 96 | .set('Authorization', 'Bearer valid-token') 97 | .expect(403); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /recipes/health-checks/health-checks.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Health Checks Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | 12 | describe('Health Checks', () => { 13 | it('should provide health check endpoint', async () => { 14 | const app = new Koa(); 15 | const router = new Router(); 16 | 17 | const db = { 18 | authenticate: async () => Promise.resolve() 19 | }; 20 | 21 | const redis = { 22 | ping: async () => Promise.resolve('PONG') 23 | }; 24 | 25 | router.get('/health', async (ctx: RouterContext) => { 26 | const health = { 27 | status: 'ok', 28 | timestamp: new Date().toISOString(), 29 | uptime: process.uptime(), 30 | checks: {} as Record 31 | }; 32 | 33 | try { 34 | await db.authenticate(); 35 | health.checks.database = 'ok'; 36 | } catch (err) { 37 | health.checks.database = 'error'; 38 | health.status = 'degraded'; 39 | } 40 | 41 | try { 42 | await redis.ping(); 43 | health.checks.redis = 'ok'; 44 | } catch (err) { 45 | health.checks.redis = 'error'; 46 | health.status = 'degraded'; 47 | } 48 | 49 | ctx.status = health.status === 'ok' ? 200 : 503; 50 | ctx.body = health; 51 | }); 52 | 53 | app.use(router.routes()); 54 | 55 | const res = await request(http.createServer(app.callback())) 56 | .get('/health') 57 | .expect(200); 58 | 59 | assert.strictEqual(res.body.status, 'ok'); 60 | assert.strictEqual(res.body.checks.database, 'ok'); 61 | assert.strictEqual(res.body.checks.redis, 'ok'); 62 | assert.strictEqual(typeof res.body.uptime, 'number'); 63 | assert.strictEqual(typeof res.body.timestamp, 'string'); 64 | }); 65 | 66 | it('should provide readiness probe', async () => { 67 | const app = new Koa(); 68 | const router = new Router(); 69 | 70 | let isReady = true; 71 | 72 | const checkReadiness = async (): Promise => { 73 | return isReady; 74 | }; 75 | 76 | router.get('/ready', async (ctx: RouterContext) => { 77 | const ready = await checkReadiness(); 78 | ctx.status = ready ? 200 : 503; 79 | ctx.body = { ready }; 80 | }); 81 | 82 | app.use(router.routes()); 83 | 84 | const res1 = await request(http.createServer(app.callback())) 85 | .get('/ready') 86 | .expect(200); 87 | 88 | assert.strictEqual(res1.body.ready, true); 89 | 90 | isReady = false; 91 | const res2 = await request(http.createServer(app.callback())) 92 | .get('/ready') 93 | .expect(503); 94 | 95 | assert.strictEqual(res2.body.ready, false); 96 | }); 97 | 98 | it('should provide liveness probe', async () => { 99 | const app = new Koa(); 100 | const router = new Router(); 101 | 102 | router.get('/live', async (ctx: RouterContext) => { 103 | ctx.body = { alive: true }; 104 | }); 105 | 106 | app.use(router.routes()); 107 | 108 | const res = await request(http.createServer(app.callback())) 109 | .get('/live') 110 | .expect(200); 111 | 112 | assert.strictEqual(res.body.alive, true); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /recipes/pagination/pagination.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Pagination Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | describe('Pagination', () => { 14 | it('should paginate list endpoints', async () => { 15 | const app = new Koa(); 16 | const router = new Router(); 17 | 18 | interface PaginationState { 19 | page: number; 20 | limit: number; 21 | offset: number; 22 | } 23 | 24 | const User = { 25 | findAndCountAll: async (options: { limit: number; offset: number }) => { 26 | const allUsers = [ 27 | { id: 1, name: 'User 1' }, 28 | { id: 2, name: 'User 2' }, 29 | { id: 3, name: 'User 3' }, 30 | { id: 4, name: 'User 4' }, 31 | { id: 5, name: 'User 5' } 32 | ]; 33 | 34 | const start = options.offset; 35 | const end = start + options.limit; 36 | const rows = allUsers.slice(start, end); 37 | 38 | return { 39 | count: allUsers.length, 40 | rows 41 | }; 42 | } 43 | }; 44 | 45 | const paginate = async (ctx: RouterContext, next: Next) => { 46 | const page = parseInt(ctx.query.page as string) || 1; 47 | const limit = parseInt(ctx.query.limit as string) || 10; 48 | const offset = (page - 1) * limit; 49 | 50 | ctx.state.pagination = { page, limit, offset } as PaginationState; 51 | await next(); 52 | }; 53 | 54 | router.get('/users', paginate, async (ctx: RouterContext) => { 55 | const { limit, offset } = ctx.state.pagination as PaginationState; 56 | const { count, rows } = await User.findAndCountAll({ limit, offset }); 57 | 58 | ctx.set('X-Total-Count', count.toString()); 59 | ctx.set('X-Page-Count', Math.ceil(count / limit).toString()); 60 | ctx.body = { 61 | data: rows, 62 | pagination: { 63 | page: ctx.state.pagination.page, 64 | limit, 65 | total: count, 66 | pages: Math.ceil(count / limit) 67 | } 68 | }; 69 | }); 70 | 71 | app.use(router.routes()); 72 | 73 | const res1 = await request(http.createServer(app.callback())) 74 | .get('/users?page=1&limit=2') 75 | .expect(200); 76 | 77 | assert.strictEqual(res1.body.data.length, 2); 78 | assert.strictEqual(res1.body.pagination.page, 1); 79 | assert.strictEqual(res1.body.pagination.limit, 2); 80 | assert.strictEqual(res1.body.pagination.total, 5); 81 | assert.strictEqual(res1.body.pagination.pages, 3); 82 | assert.strictEqual(res1.headers['x-total-count'], '5'); 83 | assert.strictEqual(res1.headers['x-page-count'], '3'); 84 | 85 | const res2 = await request(http.createServer(app.callback())) 86 | .get('/users?page=2&limit=2') 87 | .expect(200); 88 | 89 | assert.strictEqual(res2.body.data.length, 2); 90 | assert.strictEqual(res2.body.pagination.page, 2); 91 | 92 | const res3 = await request(http.createServer(app.callback())) 93 | .get('/users') 94 | .expect(200); 95 | 96 | assert.strictEqual(res3.body.pagination.page, 1); 97 | assert.strictEqual(res3.body.pagination.limit, 10); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /recipes/parameter-validation/parameter-validation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parameter Validation with router.param() Recipe 3 | * 4 | * Validate and transform parameters using router.param(). 5 | * Demonstrates: 6 | * - UUID validation 7 | * - Loading resources from database 8 | * - Chaining multiple param handlers 9 | * - Using generics to avoid type casting 10 | * 11 | * Note: User, Post, Resource models are placeholders. 12 | * Replace with your actual models/services. 13 | */ 14 | import Router from '../router-module-loader'; 15 | import { User, Post, Resource } from '../common'; 16 | import type { User as UserType, Resource as ResourceType } from '../common'; 17 | 18 | /** 19 | * State type for routes with user parameter 20 | * Using null | undefined to handle both database returns and initial state 21 | */ 22 | type UserState = { 23 | user?: UserType | null; 24 | }; 25 | 26 | /** 27 | * State type for routes with resource parameter 28 | */ 29 | type ResourceState = { 30 | resource?: ResourceType | null; 31 | }; 32 | 33 | // =========================================== 34 | // Router with User param validation 35 | // =========================================== 36 | 37 | const userRouter = new Router(); 38 | 39 | // Validate UUID format for :id parameter 40 | userRouter.param('id', (value, ctx, next) => { 41 | const uuidRegex = 42 | /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; 43 | 44 | if (!uuidRegex.test(value)) { 45 | ctx.throw(400, 'Invalid ID format'); 46 | } 47 | 48 | return next(); 49 | }); 50 | 51 | // Load user from database for :user parameter 52 | userRouter.param('user', async (id, ctx, next) => { 53 | const user = await User.findById(id); 54 | 55 | if (!user) { 56 | ctx.throw(404, 'User not found'); 57 | } 58 | 59 | ctx.state.user = user; 60 | return next(); 61 | }); 62 | 63 | // Routes using the :user parameter 64 | userRouter.get('/users/:user', (ctx) => { 65 | ctx.body = ctx.state.user; 66 | }); 67 | 68 | userRouter.get('/users/:user/posts', async (ctx) => { 69 | const user = ctx.state.user; 70 | if (user) { 71 | ctx.body = await Post.findByUserId(user.id); 72 | } 73 | }); 74 | 75 | // =========================================== 76 | // Router with Resource param validation 77 | // =========================================== 78 | 79 | const resourceRouter = new Router(); 80 | 81 | // Chain multiple param handlers for :id 82 | resourceRouter 83 | // First: validate format 84 | .param('id', (value, ctx, next) => { 85 | if (!/^\d+$/.test(value)) { 86 | ctx.throw(400, 'Invalid ID format'); 87 | } 88 | return next(); 89 | }) 90 | // Second: load from database 91 | .param('id', async (value, ctx, next) => { 92 | const resource = await Resource.findById(value); 93 | ctx.state.resource = resource; 94 | return next(); 95 | }) 96 | // Third: check existence 97 | .param('id', (_value, ctx, next) => { 98 | if (!ctx.state.resource) { 99 | ctx.throw(404, 'Resource not found'); 100 | } 101 | return next(); 102 | }) 103 | .get('/resource/:id', (ctx) => { 104 | ctx.body = ctx.state.resource; 105 | }); 106 | 107 | // Combined router for export 108 | const router = new Router(); 109 | router.use(userRouter.routes()); 110 | router.use(resourceRouter.routes()); 111 | 112 | export { router, userRouter, resourceRouter }; 113 | export type { UserState, ResourceState }; 114 | -------------------------------------------------------------------------------- /recipes/restful-api-structure/restful-api-structure.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for RESTful API Structure Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Koa from 'koa'; 9 | import Router, { RouterContext } from '../router-module-loader'; 10 | import request from 'supertest'; 11 | 12 | type RequestBody = Record; 13 | 14 | type ContextWithBody = RouterContext & { 15 | request: RouterContext['request'] & { 16 | body?: RequestBody; 17 | }; 18 | }; 19 | 20 | describe('RESTful API Structure', () => { 21 | it('should organize API with nested routers', async () => { 22 | const app = new Koa(); 23 | 24 | app.use(async (ctx, next) => { 25 | if (ctx.request.is('application/json')) { 26 | let body = ''; 27 | for await (const chunk of ctx.req) { 28 | body += chunk; 29 | } 30 | try { 31 | (ctx.request as { body?: RequestBody }).body = JSON.parse(body); 32 | } catch { 33 | (ctx.request as { body?: RequestBody }).body = {}; 34 | } 35 | } 36 | await next(); 37 | }); 38 | 39 | const User = { 40 | findAll: async () => [ 41 | { id: 1, name: 'John' }, 42 | { id: 2, name: 'Jane' } 43 | ], 44 | findById: async (id: string) => ({ id, name: 'John' }), 45 | create: async (data: RequestBody) => ({ id: 3, ...data }), 46 | update: async (id: string, data: RequestBody) => ({ id, ...data }), 47 | delete: async (_id: string) => true 48 | }; 49 | 50 | const usersRouter = new Router({ prefix: '/users' }); 51 | usersRouter.get('/', async (ctx: RouterContext) => { 52 | ctx.body = await User.findAll(); 53 | }); 54 | usersRouter.post('/', async (ctx: ContextWithBody) => { 55 | ctx.body = await User.create(ctx.request.body || {}); 56 | }); 57 | usersRouter.get('/:id', async (ctx: RouterContext) => { 58 | ctx.body = await User.findById(ctx.params.id); 59 | }); 60 | usersRouter.put('/:id', async (ctx: ContextWithBody) => { 61 | ctx.body = await User.update(ctx.params.id, ctx.request.body || {}); 62 | }); 63 | usersRouter.delete('/:id', async (ctx: RouterContext) => { 64 | await User.delete(ctx.params.id); 65 | ctx.status = 204; 66 | }); 67 | 68 | const apiRouter = new Router({ prefix: '/api/v1' }); 69 | apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods()); 70 | 71 | app.use(apiRouter.routes()); 72 | app.use(apiRouter.allowedMethods()); 73 | 74 | const res1 = await request(http.createServer(app.callback())) 75 | .get('/api/v1/users') 76 | .expect(200); 77 | 78 | assert.strictEqual(Array.isArray(res1.body), true); 79 | assert.strictEqual(res1.body.length, 2); 80 | 81 | const res2 = await request(http.createServer(app.callback())) 82 | .get('/api/v1/users/123') 83 | .expect(200); 84 | 85 | assert.strictEqual(res2.body.id, '123'); 86 | 87 | const res3 = await request(http.createServer(app.callback())) 88 | .post('/api/v1/users') 89 | .send({ name: 'Alice', email: 'alice@example.com' }) 90 | .expect(200); 91 | 92 | assert.strictEqual(res3.body.name, 'Alice'); 93 | 94 | const res4 = await request(http.createServer(app.callback())) 95 | .delete('/api/v1/users/123') 96 | .expect(204); 97 | 98 | assert.strictEqual(res4.status, 204); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /recipes/typescript-recipe/typescript-recipe.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for TypeScript Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | type RequestBody = Record; 14 | 15 | type ContextWithBody = RouterContext & { 16 | request: RouterContext['request'] & { 17 | body?: RequestBody; 18 | }; 19 | }; 20 | 21 | describe('TypeScript Recipe', () => { 22 | it('should work with typed route handlers', async () => { 23 | const app = new Koa(); 24 | const router = new Router(); 25 | 26 | app.use(async (ctx, next) => { 27 | if (ctx.request.is('application/json')) { 28 | let body = ''; 29 | for await (const chunk of ctx.req) { 30 | body += chunk; 31 | } 32 | try { 33 | (ctx.request as { body?: RequestBody }).body = JSON.parse(body); 34 | } catch { 35 | (ctx.request as { body?: RequestBody }).body = {}; 36 | } 37 | } 38 | await next(); 39 | }); 40 | 41 | type User = { 42 | id: number; 43 | name: string; 44 | email: string; 45 | }; 46 | 47 | const getUserById = async (id: number): Promise => { 48 | return { id, name: 'John Doe', email: 'john@example.com' }; 49 | }; 50 | 51 | const createUser = async (data: { 52 | name: string; 53 | email: string; 54 | }): Promise => { 55 | return { id: 1, ...data }; 56 | }; 57 | 58 | router.get('/users/:id', async (ctx: RouterContext) => { 59 | const userId = parseInt(ctx.params.id, 10); 60 | 61 | if (isNaN(userId)) { 62 | ctx.throw(400, 'Invalid user ID'); 63 | } 64 | 65 | const user: User = await getUserById(userId); 66 | ctx.body = user; 67 | }); 68 | 69 | type CreateUserBody = { 70 | name: string; 71 | email: string; 72 | }; 73 | 74 | router.post('/users', async (ctx: ContextWithBody) => { 75 | const body = 76 | (ctx.request.body as CreateUserBody) || ({} as CreateUserBody); 77 | const { name, email } = body; 78 | 79 | const user = await createUser({ name, email }); 80 | ctx.status = 201; 81 | ctx.body = user; 82 | }); 83 | 84 | router.param('id', (value: string, ctx: RouterContext, next: Next) => { 85 | if (!/^\d+$/.test(value)) { 86 | ctx.throw(400, 'Invalid ID'); 87 | } 88 | return next(); 89 | }); 90 | 91 | app.use(router.routes()); 92 | app.use(router.allowedMethods()); 93 | 94 | const res1 = await request(http.createServer(app.callback())) 95 | .get('/users/123') 96 | .expect(200); 97 | 98 | assert.strictEqual(res1.body.id, 123); 99 | assert.strictEqual(res1.body.name, 'John Doe'); 100 | assert.strictEqual(res1.body.email, 'john@example.com'); 101 | 102 | await request(http.createServer(app.callback())) 103 | .get('/users/abc') 104 | .expect(400); 105 | 106 | const res2 = await request(http.createServer(app.callback())) 107 | .post('/users') 108 | .send({ name: 'Jane Doe', email: 'jane@example.com' }) 109 | .expect(201); 110 | 111 | assert.strictEqual(res2.body.name, 'Jane Doe'); 112 | assert.strictEqual(res2.body.email, 'jane@example.com'); 113 | assert.strictEqual(res2.body.id, 1); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /recipes/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Common types and placeholder implementations for recipe examples 3 | * 4 | * These are example types that should be replaced with actual 5 | * implementations in real applications. 6 | * 7 | * NOTE: Instead of using extended context types with casting, 8 | * prefer using Router generics: new Router() 9 | * This provides better type safety without runtime casting. 10 | */ 11 | 12 | import type { RouterContext } from './router-module-loader'; 13 | 14 | // =========================================== 15 | // Domain Types 16 | // =========================================== 17 | 18 | export type User = { 19 | id: string; 20 | email: string; 21 | name: string; 22 | role?: string; 23 | }; 24 | 25 | export type Post = { 26 | id: string; 27 | userId: string; 28 | title: string; 29 | content: string; 30 | }; 31 | 32 | export type Resource = { 33 | id: string; 34 | name: string; 35 | }; 36 | 37 | // =========================================== 38 | // Extended Context Types (Legacy) 39 | // =========================================== 40 | 41 | /** 42 | * @deprecated Prefer using Router generics: new Router() 43 | * Context with request body (for POST/PUT/PATCH requests) 44 | */ 45 | export type ContextWithBody = RouterContext & { 46 | request: RouterContext['request'] & { 47 | body?: Record; 48 | }; 49 | }; 50 | 51 | /** 52 | * @deprecated Prefer using Router generics: new Router() 53 | * Context with authenticated user state 54 | */ 55 | export type ContextWithUser = RouterContext & { 56 | state: RouterContext['state'] & { 57 | user?: User; 58 | resource?: Resource; 59 | }; 60 | }; 61 | 62 | // Re-export Next type for convenience 63 | export type { Next } from 'koa'; 64 | 65 | export const db = { 66 | authenticate: async (): Promise => {} 67 | }; 68 | 69 | export const redis = { 70 | ping: async (): Promise => { 71 | return 'PONG'; 72 | } 73 | }; 74 | 75 | type UserCreateData = { email?: string; name?: string }; 76 | type UserUpdateData = { email?: string; name?: string }; 77 | 78 | export const User = { 79 | findById: async (_id: string): Promise => { 80 | return null; 81 | }, 82 | findAll: async (): Promise => { 83 | return []; 84 | }, 85 | findAndCountAll: async (_options: { 86 | limit: number; 87 | offset: number; 88 | }): Promise<{ count: number; rows: User[] }> => { 89 | return { count: 0, rows: [] }; 90 | }, 91 | create: async (data: UserCreateData): Promise => { 92 | return { id: '1', email: data.email || '', name: data.name || '' }; 93 | }, 94 | update: async (_id: string, data: UserUpdateData): Promise => { 95 | return { id: _id, email: data.email || '', name: data.name || '' }; 96 | }, 97 | delete: async (_id: string): Promise => {} 98 | }; 99 | 100 | export const Post = { 101 | findAll: async (_options: { 102 | limit: number; 103 | offset: number; 104 | }): Promise => { 105 | return []; 106 | }, 107 | findByUserId: async (_userId: string): Promise => { 108 | return []; 109 | } 110 | }; 111 | 112 | export const Resource = { 113 | findById: async (_id: string): Promise => { 114 | return null; 115 | } 116 | }; 117 | 118 | export const createUser = async (data: { 119 | email: string; 120 | password: string; 121 | name: string; 122 | }): Promise => { 123 | return { id: '1', email: data.email, name: data.name }; 124 | }; 125 | 126 | export const updateUser = async ( 127 | _id: string, 128 | data: { email?: string; name?: string } 129 | ): Promise => { 130 | return { id: _id, email: data.email || '', name: data.name || '' }; 131 | }; 132 | -------------------------------------------------------------------------------- /test/utils/http-methods.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for HTTP methods utilities 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import assert from 'node:assert'; 7 | import http from 'node:http'; 8 | import { 9 | getAllHttpMethods, 10 | COMMON_HTTP_METHODS 11 | } from '../../src/utils/http-methods'; 12 | 13 | describe('http-methods utilities', () => { 14 | describe('getAllHttpMethods()', () => { 15 | it('should return all HTTP methods in lowercase', () => { 16 | const methods = getAllHttpMethods(); 17 | 18 | assert.strictEqual(Array.isArray(methods), true); 19 | assert.strictEqual(methods.length > 0, true); 20 | 21 | for (const method of methods) { 22 | assert.strictEqual(typeof method, 'string'); 23 | assert.strictEqual( 24 | method, 25 | method.toLowerCase(), 26 | `Method ${method} should be lowercase` 27 | ); 28 | } 29 | }); 30 | 31 | it('should return methods from Node.js http.METHODS', () => { 32 | const methods = getAllHttpMethods(); 33 | const nodeMethods = http.METHODS.map((m) => m.toLowerCase()); 34 | 35 | assert.strictEqual(methods.length, nodeMethods.length); 36 | assert.deepStrictEqual(methods.sort(), nodeMethods.sort()); 37 | }); 38 | 39 | it('should include common HTTP methods', () => { 40 | const methods = getAllHttpMethods(); 41 | 42 | assert.strictEqual(methods.includes('get'), true); 43 | assert.strictEqual(methods.includes('post'), true); 44 | assert.strictEqual(methods.includes('put'), true); 45 | assert.strictEqual(methods.includes('patch'), true); 46 | assert.strictEqual(methods.includes('delete'), true); 47 | assert.strictEqual(methods.includes('head'), true); 48 | assert.strictEqual(methods.includes('options'), true); 49 | }); 50 | 51 | it('should include less common HTTP methods', () => { 52 | const methods = getAllHttpMethods(); 53 | 54 | assert.strictEqual(methods.includes('connect'), true); 55 | assert.strictEqual(methods.includes('trace'), true); 56 | assert.strictEqual(methods.includes('purge'), true); 57 | assert.strictEqual(methods.includes('copy'), true); 58 | }); 59 | }); 60 | 61 | describe('COMMON_HTTP_METHODS', () => { 62 | it('should be an array of strings', () => { 63 | assert.strictEqual(Array.isArray(COMMON_HTTP_METHODS), true); 64 | assert.strictEqual(COMMON_HTTP_METHODS.length > 0, true); 65 | 66 | for (const method of COMMON_HTTP_METHODS) { 67 | assert.strictEqual(typeof method, 'string'); 68 | } 69 | }); 70 | 71 | it('should contain expected common methods', () => { 72 | const expectedMethods = [ 73 | 'get', 74 | 'post', 75 | 'put', 76 | 'patch', 77 | 'delete', 78 | 'del', 79 | 'head', 80 | 'options' 81 | ]; 82 | 83 | for (const expected of expectedMethods) { 84 | assert.strictEqual( 85 | COMMON_HTTP_METHODS.includes(expected), 86 | true, 87 | `COMMON_HTTP_METHODS should include ${expected}` 88 | ); 89 | } 90 | }); 91 | 92 | it('should have exactly 8 common methods', () => { 93 | assert.strictEqual(COMMON_HTTP_METHODS.length, 8); 94 | }); 95 | 96 | it('should include both delete and del', () => { 97 | assert.strictEqual(COMMON_HTTP_METHODS.includes('delete'), true); 98 | assert.strictEqual(COMMON_HTTP_METHODS.includes('del'), true); 99 | }); 100 | 101 | it('should not include less common methods (they are dynamically added)', () => { 102 | assert.strictEqual(COMMON_HTTP_METHODS.includes('connect'), false); 103 | assert.strictEqual(COMMON_HTTP_METHODS.includes('trace'), false); 104 | assert.strictEqual(COMMON_HTTP_METHODS.includes('purge'), false); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@koa/router", 3 | "description": "Router middleware for koa. Maintained by Forward Email and Lad.", 4 | "version": "15.1.1", 5 | "author": "Alex Mingoia ", 6 | "bugs": { 7 | "url": "https://github.com/koajs/router/issues", 8 | "email": "niftylettuce@gmail.com" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "Alex Mingoia", 13 | "email": "talk@alexmingoia.com" 14 | }, 15 | { 16 | "name": "@koajs" 17 | }, 18 | { 19 | "name": "Imed Jaberi", 20 | "email": "imed-jaberi@outlook.com" 21 | } 22 | ], 23 | "peerDependencies": { 24 | "koa": "^2.0.0 || ^3.0.0" 25 | }, 26 | "peerDependenciesMeta": { 27 | "koa": { 28 | "optional": false 29 | } 30 | }, 31 | "dependencies": { 32 | "debug": "^4.4.3", 33 | "http-errors": "^2.0.1", 34 | "koa-compose": "^4.1.0", 35 | "path-to-regexp": "^8.3.0" 36 | }, 37 | "devDependencies": { 38 | "@commitlint/cli": "^20.2.0", 39 | "@commitlint/config-conventional": "^20.2.0", 40 | "@koa/bodyparser": "^6.0.0", 41 | "@types/debug": "^4.1.12", 42 | "@types/jsonwebtoken": "^9.0.7", 43 | "@types/koa": "^3.0.1", 44 | "@types/node": "^25.0.3", 45 | "@types/supertest": "^6.0.3", 46 | "@typescript-eslint/eslint-plugin": "^8.50.0", 47 | "@typescript-eslint/parser": "^8.50.0", 48 | "c8": "^10.1.3", 49 | "chalk": "^5.4.1", 50 | "eslint": "^9.39.2", 51 | "eslint-plugin-unicorn": "^62.0.0", 52 | "husky": "^9.1.7", 53 | "joi": "^18.0.2", 54 | "jsonwebtoken": "^9.0.3", 55 | "koa": "^3.1.1", 56 | "lint-staged": "^16.2.7", 57 | "prettier": "^3.7.4", 58 | "rimraf": "^6.1.2", 59 | "supertest": "^7.1.4", 60 | "ts-node": "^10.9.2", 61 | "tsup": "^8.5.1", 62 | "typescript": "^5.9.3" 63 | }, 64 | "engines": { 65 | "node": ">= 20" 66 | }, 67 | "main": "./dist/index.js", 68 | "module": "./dist/index.mjs", 69 | "types": "./dist/index.d.ts", 70 | "exports": { 71 | ".": { 72 | "import": { 73 | "types": "./dist/index.d.mts", 74 | "import": "./dist/index.mjs" 75 | }, 76 | "require": { 77 | "types": "./dist/index.d.ts", 78 | "require": "./dist/index.js" 79 | } 80 | } 81 | }, 82 | "files": [ 83 | "dist", 84 | "LICENSE", 85 | "README.md" 86 | ], 87 | "homepage": "https://github.com/koajs/router", 88 | "keywords": [ 89 | "koa", 90 | "middleware", 91 | "route", 92 | "router" 93 | ], 94 | "license": "MIT", 95 | "repository": { 96 | "type": "git", 97 | "url": "git+https://github.com/koajs/router.git" 98 | }, 99 | "scripts": { 100 | "bench": "TS_NODE_PROJECT=tsconfig.bench.json node --require ts-node/register bench/run.ts", 101 | "benchmark": "npm run bench", 102 | "bench:all": "TS_NODE_PROJECT=tsconfig.bench.json node --require ts-node/register bench/make.ts", 103 | "benchmark:all": "npm run bench:all", 104 | "prepare": "husky install", 105 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"", 106 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"", 107 | "lint:ts": "eslint src test --ext .ts,.tsx --fix", 108 | "lint": "npm run lint:ts", 109 | "test:core": "TS_NODE_PROJECT=tsconfig.ts-node.json node --require ts-node/register --test test/*.test.ts test/**/*.test.ts", 110 | "test:recipes": "TS_NODE_PROJECT=tsconfig.recipes.json node --require ts-node/register --test recipes/**/*.test.ts", 111 | "pretest:all": "npm run lint", 112 | "test:all": "TS_NODE_PROJECT=tsconfig.ts-node.json node --require ts-node/register --test test/*.test.ts test/**/*.test.ts recipes/**/*.test.ts", 113 | "test:coverage": "c8 npm run test:all", 114 | "ts:check": "tsc --noEmit --project tsconfig.typecheck.json", 115 | "ts:check:test": "tsc --noEmit --project tsconfig.test.json", 116 | "prebuild": "rimraf dist", 117 | "build": "tsup", 118 | "prepublishOnly": "npm run build" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /recipes/authentication-authorization/authentication-authorization.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication & Authorization Recipe 3 | * 4 | * Implement JWT-based authentication with middleware. 5 | * Demonstrates: 6 | * - JWT token verification middleware 7 | * - Role-based access control 8 | * - Chaining authentication middleware 9 | * - Using generics to avoid type casting 10 | * 11 | * Note: User model is a placeholder. Replace with your actual user model/service. 12 | * Requires: npm install jsonwebtoken @types/jsonwebtoken 13 | */ 14 | import * as jwt from 'jsonwebtoken'; 15 | 16 | import Router from '../router-module-loader'; 17 | import type { RouterMiddleware } from '../router-module-loader'; 18 | import { User } from '../common'; 19 | import type { User as UserType } from '../common'; 20 | 21 | /** 22 | * State type for authenticated routes 23 | */ 24 | type AuthState = { 25 | user?: UserType; 26 | }; 27 | 28 | /** 29 | * JWT payload structure 30 | */ 31 | type JwtPayload = { 32 | userId?: string; 33 | }; 34 | 35 | /** 36 | * Typed router with authentication state 37 | */ 38 | const router = new Router(); 39 | 40 | /** 41 | * Authentication middleware 42 | * Verifies JWT token and attaches user to ctx.state 43 | */ 44 | const authenticate: RouterMiddleware = async (ctx, next) => { 45 | const token = ctx.headers.authorization?.replace('Bearer ', ''); 46 | 47 | if (!token) { 48 | ctx.throw(401, 'Authentication required'); 49 | return; 50 | } 51 | 52 | try { 53 | const decoded = jwt.verify( 54 | token, 55 | process.env.JWT_SECRET || 'secret' 56 | ) as JwtPayload; 57 | const userId = decoded.userId; 58 | if (!userId) { 59 | ctx.throw(401, 'Invalid token payload'); 60 | return; 61 | } 62 | const user = await User.findById(userId); 63 | ctx.state.user = user || undefined; 64 | await next(); 65 | } catch { 66 | ctx.throw(401, 'Invalid token'); 67 | } 68 | }; 69 | 70 | /** 71 | * Role-based authorization middleware factory 72 | * Returns middleware that checks if user has required role 73 | */ 74 | const requireRole = (role: string): RouterMiddleware => { 75 | return async (ctx, next) => { 76 | const { user } = ctx.state; 77 | 78 | if (!user) { 79 | ctx.throw(401, 'Authentication required'); 80 | return; 81 | } 82 | 83 | if (user.role !== role) { 84 | ctx.throw(403, 'Insufficient permissions'); 85 | } 86 | 87 | await next(); 88 | }; 89 | }; 90 | 91 | /** 92 | * Multiple roles authorization middleware factory 93 | * Returns middleware that checks if user has any of the required roles 94 | */ 95 | const requireAnyRole = (...roles: string[]): RouterMiddleware => { 96 | return async (ctx, next) => { 97 | const { user } = ctx.state; 98 | 99 | if (!user) { 100 | ctx.throw(401, 'Authentication required'); 101 | return; 102 | } 103 | 104 | if (!user.role || !roles.includes(user.role)) { 105 | ctx.throw(403, `Requires one of: ${roles.join(', ')}`); 106 | } 107 | 108 | await next(); 109 | }; 110 | }; 111 | 112 | // Routes with authentication and authorization 113 | router 114 | // Public route - no authentication needed 115 | .get('/public', (ctx) => { 116 | ctx.body = { message: 'Public content' }; 117 | }) 118 | 119 | // Protected route - requires authentication 120 | .get('/profile', authenticate, (ctx) => { 121 | ctx.body = ctx.state.user; 122 | }) 123 | 124 | // Admin route - requires authentication + admin role 125 | .get('/admin', authenticate, requireRole('admin'), (ctx) => { 126 | ctx.body = { message: 'Admin access granted' }; 127 | }) 128 | 129 | // Moderator route - requires authentication + moderator or admin role 130 | .get( 131 | '/moderate', 132 | authenticate, 133 | requireAnyRole('admin', 'moderator'), 134 | (ctx) => { 135 | ctx.body = { message: 'Moderator access granted' }; 136 | } 137 | ); 138 | 139 | export { router, authenticate, requireRole, requireAnyRole }; 140 | export type { AuthState }; 141 | -------------------------------------------------------------------------------- /recipes/error-handling/error-handling.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Error Handling Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | type ErrorDetails = Record; 14 | 15 | type CaughtError = Error & { 16 | status?: number; 17 | code?: string; 18 | details?: ErrorDetails; 19 | }; 20 | 21 | describe('Error Handling', () => { 22 | it('should handle errors with custom error class', async () => { 23 | const app = new Koa(); 24 | const router = new Router(); 25 | 26 | class AppError extends Error { 27 | status: number; 28 | code: string; 29 | isOperational: boolean; 30 | details?: ErrorDetails; 31 | 32 | constructor( 33 | message: string, 34 | status = 500, 35 | code = 'INTERNAL_ERROR', 36 | details?: ErrorDetails 37 | ) { 38 | super(message); 39 | this.status = status; 40 | this.code = code; 41 | this.isOperational = true; 42 | this.details = details; 43 | } 44 | } 45 | 46 | const errorHandler = async (ctx: RouterContext, next: Next) => { 47 | try { 48 | await next(); 49 | } catch (err) { 50 | const error = err as CaughtError; 51 | ctx.status = error.status || 500; 52 | ctx.body = { 53 | error: { 54 | message: error.message, 55 | code: error.code || 'INTERNAL_ERROR', 56 | ...(process.env.NODE_ENV === 'development' && { 57 | stack: error.stack, 58 | details: error.details 59 | }) 60 | } 61 | }; 62 | 63 | ctx.app.emit('error', error, ctx); 64 | } 65 | }; 66 | 67 | const User = { 68 | findById: async (id: string) => { 69 | if (id === '123') { 70 | return { id: '123', name: 'John' }; 71 | } 72 | return null; 73 | } 74 | }; 75 | 76 | router.get('/users/:id', async (ctx: RouterContext) => { 77 | const user = await User.findById(ctx.params.id); 78 | if (!user) { 79 | throw new AppError('User not found', 404, 'USER_NOT_FOUND'); 80 | } 81 | ctx.body = user; 82 | }); 83 | 84 | app.use(errorHandler); 85 | app.use(router.routes()); 86 | app.use(router.allowedMethods({ throw: true })); 87 | 88 | const res1 = await request(http.createServer(app.callback())) 89 | .get('/users/123') 90 | .expect(200); 91 | 92 | assert.strictEqual(res1.body.id, '123'); 93 | assert.strictEqual(res1.body.name, 'John'); 94 | 95 | const res2 = await request(http.createServer(app.callback())) 96 | .get('/users/999') 97 | .expect(404); 98 | 99 | assert.strictEqual(res2.body.error.message, 'User not found'); 100 | assert.strictEqual(res2.body.error.code, 'USER_NOT_FOUND'); 101 | }); 102 | 103 | it('should handle generic errors', async () => { 104 | const app = new Koa(); 105 | const router = new Router(); 106 | 107 | const errorHandler = async (ctx: RouterContext, next: Next) => { 108 | try { 109 | await next(); 110 | } catch (err) { 111 | const error = err as CaughtError; 112 | ctx.status = error.status || 500; 113 | ctx.body = { 114 | error: { 115 | message: error.message, 116 | code: error.code || 'INTERNAL_ERROR' 117 | } 118 | }; 119 | } 120 | }; 121 | 122 | router.get('/error', async () => { 123 | throw new Error('Something went wrong'); 124 | }); 125 | 126 | app.use(errorHandler); 127 | app.use(router.routes()); 128 | 129 | const res = await request(http.createServer(app.callback())) 130 | .get('/error') 131 | .expect(500); 132 | 133 | assert.strictEqual(res.body.error.message, 'Something went wrong'); 134 | assert.strictEqual(res.body.error.code, 'INTERNAL_ERROR'); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/utils/path-to-regexp-wrapper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Path-to-regexp wrapper utility 3 | * 4 | * Centralizes all path-to-regexp imports and provides a clean interface. 5 | * This abstraction allows easier maintenance and potential future changes. 6 | */ 7 | 8 | import { pathToRegexp, compile, parse } from 'path-to-regexp'; 9 | import type { Key } from 'path-to-regexp'; 10 | import type { LayerOptions } from '../types'; 11 | 12 | /** 13 | * Options for path-to-regexp operations 14 | * Based on path-to-regexp v8 options 15 | * @internal 16 | */ 17 | type PathToRegexpOptions = { 18 | /** 19 | * Case sensitive matching 20 | */ 21 | sensitive?: boolean; 22 | 23 | /** 24 | * Whether trailing slashes are significant 25 | * Note: path-to-regexp v8 renamed 'strict' to 'trailing' 26 | */ 27 | strict?: boolean; 28 | trailing?: boolean; 29 | 30 | /** 31 | * Route path ends at this path 32 | */ 33 | end?: boolean; 34 | 35 | /** 36 | * Prefix for the route 37 | */ 38 | prefix?: string; 39 | 40 | /** 41 | * Ignore captures in route matching 42 | */ 43 | ignoreCaptures?: boolean; 44 | 45 | /** 46 | * Treat path as a regular expression 47 | */ 48 | pathAsRegExp?: boolean; 49 | 50 | /** 51 | * Additional options passed to path-to-regexp 52 | */ 53 | [key: string]: unknown; 54 | }; 55 | 56 | /** 57 | * Result of path-to-regexp compilation 58 | * @internal 59 | */ 60 | type PathToRegexpResult = { 61 | regexp: RegExp; 62 | keys: Key[]; 63 | }; 64 | 65 | /** 66 | * Compile a path pattern to a regular expression 67 | * 68 | * @param path - Path pattern string 69 | * @param options - Compilation options 70 | * @returns Object with regexp and parameter keys 71 | */ 72 | export function compilePathToRegexp( 73 | path: string, 74 | options: PathToRegexpOptions = {} 75 | ): PathToRegexpResult { 76 | const normalizedOptions: Record = { ...options }; 77 | 78 | if ('strict' in normalizedOptions && !('trailing' in normalizedOptions)) { 79 | normalizedOptions.trailing = normalizedOptions.strict !== true; 80 | delete normalizedOptions.strict; 81 | } 82 | 83 | delete normalizedOptions.pathAsRegExp; 84 | delete normalizedOptions.ignoreCaptures; 85 | delete normalizedOptions.prefix; 86 | 87 | const { regexp, keys } = pathToRegexp(path, normalizedOptions); 88 | return { regexp, keys }; 89 | } 90 | 91 | /** 92 | * Compile a path pattern to a URL generator function 93 | * 94 | * @param path - Path pattern string 95 | * @param options - Compilation options 96 | * @returns Function that generates URLs from parameters 97 | */ 98 | export function compilePath( 99 | path: string, 100 | options: Record = {} 101 | ): (parameters?: Record) => string { 102 | return compile(path, options); 103 | } 104 | 105 | /** 106 | * Parse a path pattern into tokens 107 | * 108 | * @param path - Path pattern string 109 | * @param options - Parse options 110 | * @returns Array of tokens 111 | */ 112 | export function parsePath( 113 | path: string, 114 | options?: Record 115 | ): ReturnType { 116 | return parse(path, options); 117 | } 118 | 119 | /** 120 | * Re-export Key type for convenience 121 | */ 122 | 123 | /** 124 | * Normalize LayerOptions to path-to-regexp options 125 | * Handles the strict/trailing option conversion 126 | * 127 | * @param options - Layer options 128 | * @returns Normalized path-to-regexp options 129 | */ 130 | export function normalizeLayerOptionsToPathToRegexp( 131 | options: LayerOptions = {} 132 | ): Record { 133 | const normalized: Record = { 134 | sensitive: options.sensitive, 135 | end: options.end, 136 | strict: options.strict, 137 | trailing: options.trailing 138 | }; 139 | 140 | if ('strict' in normalized && !('trailing' in normalized)) { 141 | normalized.trailing = normalized.strict !== true; 142 | delete normalized.strict; 143 | } 144 | 145 | for (const key of Object.keys(normalized)) { 146 | if (normalized[key] === undefined) { 147 | delete normalized[key]; 148 | } 149 | } 150 | 151 | return normalized; 152 | } 153 | 154 | export { type Key } from 'path-to-regexp'; 155 | -------------------------------------------------------------------------------- /bench/run-bench.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benchmark runner - runs router.match() benchmarks 3 | * 4 | * This script benchmarks the router matching performance. 5 | * Results are printed to console and saved to bench-result.txt 6 | */ 7 | 8 | import { writeFileSync } from 'node:fs'; 9 | import Router from '../src'; 10 | import { now, print, title, warmup, operations } from './util'; 11 | 12 | const router = new Router(); 13 | 14 | type Route = { 15 | method: 'GET' | 'POST' | 'PUT' | 'DELETE'; 16 | url: string; 17 | }; 18 | 19 | const routes: Route[] = [ 20 | { method: 'GET', url: '/user' }, 21 | { method: 'GET', url: '/user/comments' }, 22 | { method: 'GET', url: '/user/avatar' }, 23 | { method: 'GET', url: '/user/lookup/username/:username' }, 24 | { method: 'GET', url: '/user/lookup/email/:address' }, 25 | { method: 'GET', url: '/event/:id' }, 26 | { method: 'GET', url: '/event/:id/comments' }, 27 | { method: 'POST', url: '/event/:id/comment' }, 28 | { method: 'PUT', url: '/event/:id' }, 29 | { method: 'DELETE', url: '/event/:id' }, 30 | { method: 'GET', url: '/map/:location/events' }, 31 | { method: 'GET', url: '/status' }, 32 | { method: 'GET', url: '/very/deeply/nested/route/hello/there' }, 33 | { method: 'GET', url: '/static/{/*path}' } 34 | ]; 35 | 36 | function noop(): void {} 37 | 38 | // Register all routes 39 | for (const route of routes) { 40 | router[route.method.toLowerCase() as 'get' | 'post' | 'put' | 'delete']( 41 | route.url, 42 | noop 43 | ); 44 | } 45 | 46 | title('Router Match Benchmarks'); 47 | console.log(`Running ${operations.toLocaleString()} iterations per test\n`); 48 | 49 | // Warmup - runs each match once to warm up JIT compiler 50 | warmup(() => { 51 | router.match('/user', 'GET'); 52 | router.match('/user/comments', 'GET'); 53 | router.match('/user/lookup/username/john', 'GET'); 54 | router.match('/event/abcd1234/comments', 'GET'); 55 | router.match('/very/deeply/nested/route/hello/there', 'GET'); 56 | router.match('/static/index.html', 'GET'); 57 | }); 58 | 59 | const results: Record = {}; 60 | let time: number; 61 | 62 | // Short static route 63 | time = now(); 64 | for (let i = 0; i < operations; i++) { 65 | router.match('/user', 'GET'); 66 | } 67 | results['short static'] = print('short static', time); 68 | 69 | // Static with same radix 70 | time = now(); 71 | for (let i = 0; i < operations; i++) { 72 | router.match('/user/comments', 'GET'); 73 | } 74 | results['static with same radix'] = print('static with same radix', time); 75 | 76 | // Dynamic route 77 | time = now(); 78 | for (let i = 0; i < operations; i++) { 79 | router.match('/user/lookup/username/john', 'GET'); 80 | } 81 | results['dynamic route'] = print('dynamic route', time); 82 | 83 | // Mixed static and dynamic 84 | time = now(); 85 | for (let i = 0; i < operations; i++) { 86 | router.match('/event/abcd1234/comments', 'GET'); 87 | } 88 | results['mixed static dynamic'] = print('mixed static dynamic', time); 89 | 90 | // Long static route 91 | time = now(); 92 | for (let i = 0; i < operations; i++) { 93 | router.match('/very/deeply/nested/route/hello/there', 'GET'); 94 | } 95 | results['long static'] = print('long static', time); 96 | 97 | // Wildcard route 98 | time = now(); 99 | for (let i = 0; i < operations; i++) { 100 | router.match('/static/index.html', 'GET'); 101 | } 102 | results['wildcard'] = print('wildcard', time); 103 | 104 | // POST method 105 | time = now(); 106 | for (let i = 0; i < operations; i++) { 107 | router.match('/event/abcd1234/comment', 'POST'); 108 | } 109 | results['POST method'] = print('POST method', time); 110 | 111 | // All together (6 matches per iteration) 112 | time = now(); 113 | for (let i = 0; i < operations; i++) { 114 | router.match('/user', 'GET'); 115 | router.match('/user/comments', 'GET'); 116 | router.match('/user/lookup/username/john', 'GET'); 117 | router.match('/event/abcd1234/comments', 'GET'); 118 | router.match('/very/deeply/nested/route/hello/there', 'GET'); 119 | router.match('/static/index.html', 'GET'); 120 | } 121 | results['all together (6 matches)'] = print('all together (6 matches)', time); 122 | 123 | // Save results 124 | const output = JSON.stringify(results, null, 2); 125 | writeFileSync('bench-result.txt', output); 126 | console.log('\nResults saved to bench-result.txt'); 127 | -------------------------------------------------------------------------------- /recipes/parameter-validation/parameter-validation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Parameter Validation Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | describe('Parameter Validation', () => { 14 | it('should validate UUID format with router.param()', async () => { 15 | const app = new Koa(); 16 | const router = new Router(); 17 | 18 | router.param('id', (value: string, ctx: RouterContext, next: Next) => { 19 | const uuidRegex = 20 | /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; 21 | 22 | if (!uuidRegex.test(value)) { 23 | ctx.throw(400, 'Invalid ID format'); 24 | } 25 | 26 | return next(); 27 | }); 28 | 29 | router.get('/users/:id', (ctx: RouterContext) => { 30 | ctx.body = { id: ctx.params.id, valid: true }; 31 | }); 32 | 33 | app.use(router.routes()); 34 | 35 | const validUUID = '123e4567-e89b-12d3-a456-426614174000'; 36 | const res1 = await request(http.createServer(app.callback())) 37 | .get(`/users/${validUUID}`) 38 | .expect(200); 39 | 40 | assert.strictEqual(res1.body.id, validUUID); 41 | assert.strictEqual(res1.body.valid, true); 42 | 43 | await request(http.createServer(app.callback())) 44 | .get('/users/invalid-id') 45 | .expect(400); 46 | }); 47 | 48 | it('should load resource from database with router.param()', async () => { 49 | const app = new Koa(); 50 | const router = new Router(); 51 | 52 | const User = { 53 | findById: async (id: string) => { 54 | if (id === '123') { 55 | return { id: '123', name: 'John' }; 56 | } 57 | return null; 58 | } 59 | }; 60 | 61 | router.param('user', async (id: string, ctx: RouterContext, next: Next) => { 62 | const user = await User.findById(id); 63 | 64 | if (!user) { 65 | ctx.throw(404, 'User not found'); 66 | } 67 | 68 | ctx.state.user = user; 69 | return next(); 70 | }); 71 | 72 | router.get('/users/:user', (ctx: RouterContext) => { 73 | ctx.body = ctx.state.user; 74 | }); 75 | 76 | router.get('/users/:user/posts', async (ctx: RouterContext) => { 77 | ctx.body = { userId: ctx.state.user.id, posts: [] }; 78 | }); 79 | 80 | app.use(router.routes()); 81 | 82 | const res1 = await request(http.createServer(app.callback())) 83 | .get('/users/123') 84 | .expect(200); 85 | 86 | assert.strictEqual(res1.body.id, '123'); 87 | assert.strictEqual(res1.body.name, 'John'); 88 | 89 | const res2 = await request(http.createServer(app.callback())) 90 | .get('/users/123/posts') 91 | .expect(200); 92 | 93 | assert.strictEqual(res2.body.userId, '123'); 94 | 95 | await request(http.createServer(app.callback())) 96 | .get('/users/999') 97 | .expect(404); 98 | }); 99 | 100 | it('should support multiple param handlers', async () => { 101 | const app = new Koa(); 102 | const router = new Router(); 103 | 104 | let validationCalled = false; 105 | let loadCalled = false; 106 | 107 | router.param('id', (value: string, ctx: RouterContext, next: Next) => { 108 | if (!/^\d+$/.test(value)) { 109 | ctx.throw(400, 'Invalid ID format'); 110 | } 111 | validationCalled = true; 112 | return next(); 113 | }); 114 | 115 | router.param( 116 | 'id', 117 | async (value: string, ctx: RouterContext, next: Next) => { 118 | ctx.state.resource = { id: value, loaded: true }; 119 | loadCalled = true; 120 | return next(); 121 | } 122 | ); 123 | 124 | router.get('/resource/:id', (ctx: RouterContext) => { 125 | ctx.body = ctx.state.resource; 126 | }); 127 | 128 | app.use(router.routes()); 129 | 130 | const res = await request(http.createServer(app.callback())) 131 | .get('/resource/123') 132 | .expect(200); 133 | 134 | assert.strictEqual(validationCalled, true); 135 | assert.strictEqual(loadCalled, true); 136 | assert.strictEqual(res.body.id, '123'); 137 | assert.strictEqual(res.body.loaded, true); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | node_version: 13 | - '20' 14 | - '22' 15 | - '24' 16 | - '25' 17 | name: Node ${{ matrix.node_version }} on ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node_version }} 24 | cache: 'yarn' 25 | - name: Install dependencies 26 | run: yarn install --frozen-lockfile 27 | - name: Run lint 28 | run: yarn run lint 29 | - name: Build 30 | run: yarn run build 31 | - name: Run test coverage 32 | run: yarn run test:coverage 33 | 34 | benchmarks: 35 | runs-on: ubuntu-latest 36 | env: 37 | THRESHOLD: 50000 38 | steps: 39 | - name: Install wrk 40 | run: | 41 | sudo apt-get update 42 | sudo apt-get install -y wrk 43 | - name: Verify wrk installation 44 | run: | 45 | if ! command -v wrk &> /dev/null; then 46 | echo "Error: wrk installation failed" 47 | exit 1 48 | fi 49 | echo "wrk installed successfully: $(wrk --version 2>&1 | head -1)" 50 | # First checkout master and run benchmarks 51 | - name: Checkout master branch 52 | uses: actions/checkout@v4 53 | with: 54 | ref: master 55 | - name: Check if yarn.lock exists 56 | id: check-yarn-lock 57 | run: | 58 | if [ -f yarn.lock ]; then 59 | echo "exists=true" >> $GITHUB_OUTPUT 60 | echo "yarn.lock found: $(wc -l < yarn.lock) lines" 61 | else 62 | echo "exists=false" >> $GITHUB_OUTPUT 63 | echo "yarn.lock not found on master branch" 64 | fi 65 | - name: Setup node 66 | if: steps.check-yarn-lock.outputs.exists == 'true' 67 | uses: actions/setup-node@v4 68 | with: 69 | node-version: '24' 70 | cache: 'yarn' 71 | - name: Setup node without cache 72 | if: steps.check-yarn-lock.outputs.exists == 'false' 73 | uses: actions/setup-node@v4 74 | with: 75 | node-version: '24' 76 | - name: Install dependencies 77 | run: | 78 | if [ -f yarn.lock ]; then 79 | yarn install --frozen-lockfile 80 | else 81 | yarn install 82 | fi 83 | - name: Run benchmarks 84 | run: yarn run benchmark || echo "0" > bench-result.txt 85 | # Store output of bench-result.txt to workflow output 86 | - name: Store benchmark result 87 | id: main_benchmark 88 | run: | 89 | echo "result=$(cat bench-result.txt)" >> $GITHUB_OUTPUT 90 | 91 | # Now checkout the PR branch and run benchmarks 92 | - name: Checkout PR branch 93 | uses: actions/checkout@v4 94 | - name: Setup node for PR branch 95 | uses: actions/setup-node@v4 96 | with: 97 | node-version: '24' 98 | cache: 'yarn' 99 | - name: Install dependencies 100 | run: yarn install --frozen-lockfile 101 | - name: Run benchmarks 102 | run: yarn run benchmark 103 | # Store output of bench-result.txt to workflow output 104 | - name: Store benchmark result 105 | id: branch_benchmark 106 | run: | 107 | echo "result=$(cat bench-result.txt)" >> $GITHUB_OUTPUT 108 | 109 | # Verify difference between main and branch benchmark outputs aren't greater than THRESHOLD env var 110 | - name: Verify benchmark results 111 | run: | 112 | main_result=${{ steps.main_benchmark.outputs.result }} 113 | branch_result=${{ steps.branch_benchmark.outputs.result }} 114 | difference=$(echo "$main_result - $branch_result" | bc) 115 | echo "Main benchmark result: $main_result" 116 | echo "Branch benchmark result: $branch_result" 117 | echo "Difference: $difference" 118 | if (( $(echo "$difference > $THRESHOLD" | bc -l) )); then 119 | echo "Benchmark difference exceeds threshold of $THRESHOLD" 120 | exit 1 121 | else 122 | echo "Benchmark difference is within acceptable limit (< $THRESHOLD)." 123 | fi 124 | -------------------------------------------------------------------------------- /recipes/request-validation/request-validation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Request Validation Recipe 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import * as assert from 'node:assert'; 7 | import * as http from 'node:http'; 8 | import Router, { RouterContext } from '../router-module-loader'; 9 | import request from 'supertest'; 10 | import Koa from 'koa'; 11 | import { Next } from '../common'; 12 | 13 | type RequestBody = Record; 14 | 15 | type ContextWithBody = RouterContext & { 16 | request: RouterContext['request'] & { 17 | body?: RequestBody; 18 | }; 19 | }; 20 | 21 | type ValidationError = { 22 | path: string[]; 23 | message: string; 24 | }; 25 | 26 | type ValidationResult = { 27 | error: { details: ValidationError[] } | null; 28 | value: RequestBody | null; 29 | }; 30 | 31 | type ValidationSchema = { 32 | validate: ( 33 | data: RequestBody, 34 | options?: { abortEarly?: boolean; stripUnknown?: boolean } 35 | ) => ValidationResult; 36 | }; 37 | 38 | describe('Request Validation', () => { 39 | it('should validate request data with middleware', async () => { 40 | const app = new Koa(); 41 | const router = new Router(); 42 | 43 | app.use(async (ctx, next) => { 44 | if (ctx.request.is('application/json')) { 45 | let body = ''; 46 | for await (const chunk of ctx.req) { 47 | body += chunk; 48 | } 49 | try { 50 | (ctx.request as { body?: RequestBody }).body = JSON.parse(body); 51 | } catch { 52 | (ctx.request as { body?: RequestBody }).body = {}; 53 | } 54 | } 55 | await next(); 56 | }); 57 | 58 | const validate = 59 | (schema: ValidationSchema) => 60 | async (ctx: ContextWithBody, next: Next) => { 61 | const body = ctx.request.body || {}; 62 | const { error, value } = schema.validate(body, { 63 | abortEarly: false, 64 | stripUnknown: true 65 | }); 66 | 67 | if (error) { 68 | ctx.status = 400; 69 | ctx.body = { 70 | error: 'Validation failed', 71 | details: error.details.map((d) => ({ 72 | field: d.path.join('.'), 73 | message: d.message 74 | })) 75 | }; 76 | return; 77 | } 78 | 79 | ctx.request.body = value || {}; 80 | await next(); 81 | }; 82 | 83 | const createUserSchema: ValidationSchema = { 84 | validate: (data: RequestBody) => { 85 | const errors: ValidationError[] = []; 86 | const email = data.email as string | undefined; 87 | const password = data.password as string | undefined; 88 | const name = data.name as string | undefined; 89 | 90 | if (!email || !email.includes('@')) { 91 | errors.push({ path: ['email'], message: 'Email is invalid' }); 92 | } 93 | if (!password || password.length < 8) { 94 | errors.push({ 95 | path: ['password'], 96 | message: 'Password must be at least 8 characters' 97 | }); 98 | } 99 | if (!name || name.length < 2) { 100 | errors.push({ 101 | path: ['name'], 102 | message: 'Name must be at least 2 characters' 103 | }); 104 | } 105 | 106 | if (errors.length > 0) { 107 | return { error: { details: errors }, value: null }; 108 | } 109 | return { error: null, value: data }; 110 | } 111 | }; 112 | 113 | router.post( 114 | '/users', 115 | validate(createUserSchema), 116 | async (ctx: ContextWithBody) => { 117 | ctx.body = { success: true, user: ctx.request.body }; 118 | } 119 | ); 120 | 121 | app.use(router.routes()); 122 | 123 | const res1 = await request(http.createServer(app.callback())) 124 | .post('/users') 125 | .send({ 126 | email: 'test@example.com', 127 | password: 'password123', 128 | name: 'John Doe' 129 | }) 130 | .expect(200); 131 | 132 | assert.strictEqual(res1.body.success, true); 133 | assert.strictEqual(res1.body.user.email, 'test@example.com'); 134 | 135 | const res2 = await request(http.createServer(app.callback())) 136 | .post('/users') 137 | .send({ 138 | email: 'invalid-email', 139 | password: 'short' 140 | }) 141 | .expect(400); 142 | 143 | assert.strictEqual(res2.body.error, 'Validation failed'); 144 | assert.strictEqual(Array.isArray(res2.body.details), true); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /recipes/typescript-recipe/typescript-recipe.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript Recipe 3 | * 4 | * Demonstrates @koa/router's full TypeScript support with type inference. 5 | * 6 | * Key features showcased: 7 | * - Automatic type inference for ctx and next (no explicit types needed!) 8 | * - ctx.params is always typed as Record 9 | * - Generic router for custom state/context types 10 | * - Type-safe parameter middleware 11 | * - Proper body parsing with @koa/bodyparser 12 | * 13 | * Note: getUserById and createUser are placeholder functions. 14 | * Replace with your actual implementation. 15 | */ 16 | import Router from '../router-module-loader'; 17 | import type { RouterMiddleware } from '../router-module-loader'; 18 | 19 | import '@koa/bodyparser'; 20 | 21 | type User = { 22 | id: number; 23 | name: string; 24 | email: string; 25 | }; 26 | 27 | type CreateUserBody = { 28 | name: string; 29 | email: string; 30 | }; 31 | 32 | // =========================================== 33 | // Example 1: Basic router with type inference 34 | // =========================================== 35 | 36 | const router = new Router(); 37 | 38 | // ✅ ctx and next are automatically inferred - no explicit types needed! 39 | router.get('/users/:id', async (ctx, next) => { 40 | // ctx.params.id is inferred as string 41 | const userId = parseInt(ctx.params.id, 10); 42 | 43 | if (isNaN(userId)) { 44 | ctx.throw(400, 'Invalid user ID'); 45 | } 46 | 47 | const user: User = await getUserById(userId); 48 | ctx.body = user; 49 | return next(); 50 | }); 51 | 52 | // ✅ Multiple middleware - all have inferred types 53 | // Note: @koa/bodyparser adds ctx.request.body to Koa's types 54 | router.post( 55 | '/users', 56 | async (ctx, next) => { 57 | // Validation middleware - check body exists 58 | // ctx.request.body is typed by @koa/bodyparser 59 | if (!ctx.request.body) { 60 | ctx.throw(400, 'Request body required'); 61 | } 62 | return next(); 63 | }, 64 | async (ctx) => { 65 | // Handler - body is available from bodyparser 66 | // Type narrowing: we know body exists from previous middleware 67 | const body = ctx.request.body as CreateUserBody; 68 | const user = await createUser(body); 69 | ctx.status = 201; 70 | ctx.body = user; 71 | } 72 | ); 73 | 74 | // ✅ router.use() also has type inference 75 | router.use(async (ctx, next) => { 76 | const start = Date.now(); 77 | await next(); 78 | const ms = Date.now() - start; 79 | ctx.set('X-Response-Time', `${ms}ms`); 80 | }); 81 | 82 | // ✅ Parameter middleware with type inference 83 | router.param('id', (value, ctx, next) => { 84 | // value is inferred as string 85 | if (!/^\d+$/.test(value)) { 86 | ctx.throw(400, 'Invalid ID format'); 87 | } 88 | return next(); 89 | }); 90 | 91 | // =========================================== 92 | // Example 2: Generic router with custom types 93 | // =========================================== 94 | 95 | type AppState = { 96 | user?: User; 97 | requestId: string; 98 | }; 99 | 100 | type AppContext = { 101 | log: (message: string) => void; 102 | }; 103 | 104 | // Router with custom state and context types 105 | const typedRouter = new Router(); 106 | 107 | typedRouter.get('/profile', async (ctx) => { 108 | // ctx.state.user is typed as User | undefined 109 | // ctx.log is typed as (message: string) => void 110 | ctx.log(`Fetching profile for request ${ctx.state.requestId}`); 111 | 112 | if (!ctx.state.user) { 113 | ctx.throw(401, 'Not authenticated'); 114 | } 115 | 116 | ctx.body = ctx.state.user; 117 | }); 118 | 119 | // =========================================== 120 | // Example 3: Explicit types when needed 121 | // =========================================== 122 | 123 | // Sometimes you need explicit types for complex scenarios 124 | const authMiddleware: RouterMiddleware = async ( 125 | ctx, 126 | next 127 | ) => { 128 | // Verify token and set user 129 | ctx.state.user = await verifyToken(ctx.headers.authorization); 130 | return next(); 131 | }; 132 | 133 | typedRouter.get('/admin', authMiddleware, async (ctx) => { 134 | // ctx.state.user is available from the middleware 135 | ctx.body = { admin: true, user: ctx.state.user }; 136 | }); 137 | 138 | // =========================================== 139 | // Placeholder implementations 140 | // =========================================== 141 | 142 | async function getUserById(id: number): Promise { 143 | return { id, name: 'John Doe', email: 'john@example.com' }; 144 | } 145 | 146 | async function createUser(data: CreateUserBody): Promise { 147 | return { id: 1, ...data }; 148 | } 149 | 150 | async function verifyToken(_token?: string): Promise { 151 | return { id: 1, name: 'Admin', email: 'admin@example.com' }; 152 | } 153 | 154 | export { router, typedRouter }; 155 | export type { User, CreateUserBody, AppState, AppContext }; 156 | -------------------------------------------------------------------------------- /bench/run.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benchmark runner script 3 | * 4 | * Runs a single benchmark test with specified factor and middleware usage. 5 | * Usage: node --require ts-node/register bench/run.ts 6 | * Example: node --require ts-node/register bench/run.ts 10 false 7 | */ 8 | 9 | import { spawn } from 'node:child_process'; 10 | import { join } from 'node:path'; 11 | 12 | const projectRoot = join( 13 | typeof __dirname !== 'undefined' ? __dirname : process.cwd(), 14 | '..' 15 | ); 16 | 17 | const factor = process.argv[2] || '10'; 18 | const useMiddleware = process.argv[3] === 'true'; 19 | const port = process.env.PORT || '3000'; 20 | const host = `http://localhost:${port}`; 21 | 22 | const serverProcess = spawn( 23 | 'node', 24 | ['--require', 'ts-node/register', 'bench/server.ts'], 25 | { 26 | env: { 27 | ...process.env, 28 | TS_NODE_PROJECT: 'tsconfig.bench.json', 29 | FACTOR: factor, 30 | USE_MIDDLEWARE: String(useMiddleware), 31 | PORT: port 32 | }, 33 | stdio: 'pipe', 34 | cwd: projectRoot 35 | } 36 | ); 37 | 38 | let serverOutput = ''; 39 | serverProcess.stdout?.on('data', (data) => { 40 | serverOutput += data.toString(); 41 | }); 42 | 43 | serverProcess.stderr?.on('data', (data) => { 44 | console.error(data.toString()); 45 | }); 46 | 47 | async function waitForServer(maxRetries = 30, delay = 200): Promise { 48 | for (let i = 0; i < maxRetries; i++) { 49 | try { 50 | const response = await fetch(`${host}/_health`); 51 | if (response.ok) { 52 | return; 53 | } 54 | } catch {} 55 | await new Promise((resolve) => setTimeout(resolve, delay)); 56 | } 57 | throw new Error('Server failed to start'); 58 | } 59 | 60 | async function runBenchmark(): Promise { 61 | try { 62 | await waitForServer(); 63 | 64 | const wrkPath = process.env.WRK_PATH || 'wrk'; 65 | 66 | const { execSync } = await import('node:child_process'); 67 | const isWindows = process.platform === 'win32'; 68 | 69 | try { 70 | if (isWindows) { 71 | try { 72 | execSync(`where ${wrkPath}`, { stdio: 'ignore' }); 73 | } catch { 74 | try { 75 | execSync(`wsl which ${wrkPath}`, { stdio: 'ignore' }); 76 | } catch { 77 | throw new Error('wrk not found'); 78 | } 79 | } 80 | } else { 81 | execSync(`which ${wrkPath}`, { stdio: 'ignore' }); 82 | } 83 | } catch { 84 | console.error(`\nError: '${wrkPath}' command not found.`); 85 | console.error('Please install wrk:'); 86 | if (isWindows) { 87 | console.error(' Windows options:'); 88 | console.error(' 1. Use WSL (Windows Subsystem for Linux):'); 89 | console.error(' - Install WSL: wsl --install'); 90 | console.error(' - Then in WSL: sudo apt-get install wrk'); 91 | console.error(' 2. Use alternative tools that work on Windows:'); 92 | console.error(' - autocannon: npm install -g autocannon'); 93 | console.error( 94 | ' - Apache Bench (ab): Install via Apache HTTP Server' 95 | ); 96 | console.error( 97 | ' 3. Set WRK_PATH to point to wrk executable in WSL or alternative tool' 98 | ); 99 | } else { 100 | console.error(' macOS: brew install wrk'); 101 | console.error( 102 | ' Linux: sudo apt-get install wrk (or use your package manager)' 103 | ); 104 | } 105 | console.error( 106 | ' Or set WRK_PATH environment variable to point to wrk executable\n' 107 | ); 108 | process.exit(1); 109 | } 110 | 111 | const wrkProcess = spawn( 112 | wrkPath, 113 | [`${host}/10/child/grandchild/%40`, '-d', '3', '-c', '50', '-t', '8'], 114 | { 115 | stdio: 'pipe' 116 | } 117 | ); 118 | 119 | let wrkOutput = ''; 120 | wrkProcess.stdout?.on('data', (data) => { 121 | wrkOutput += data.toString(); 122 | }); 123 | 124 | wrkProcess.stderr?.on('data', () => {}); 125 | 126 | await new Promise((resolve, reject) => { 127 | wrkProcess.on('close', (code) => { 128 | if (code === 0) { 129 | const match = wrkOutput.match(/Requests\/sec:\s+([\d.]+)/); 130 | if (match) { 131 | console.log(` ${match[1]}`); 132 | } else { 133 | console.log(' Unable to parse requests/sec'); 134 | } 135 | resolve(); 136 | } else { 137 | reject(new Error(`wrk exited with code ${code}`)); 138 | } 139 | }); 140 | }); 141 | } catch (error) { 142 | console.error('Error running benchmark:', error); 143 | process.exit(1); 144 | } finally { 145 | serverProcess.kill(); 146 | await new Promise((resolve) => setTimeout(resolve, 100)); 147 | } 148 | } 149 | 150 | runBenchmark().catch((error) => { 151 | console.error('Fatal error:', error); 152 | process.exit(1); 153 | }); 154 | -------------------------------------------------------------------------------- /recipes/pagination/pagination.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pagination Recipe 3 | * 4 | * Implement pagination for list endpoints. 5 | * Demonstrates: 6 | * - Pagination middleware for reuse across routes 7 | * - Configurable page size with max limit 8 | * - Pagination metadata in response 9 | * - Pagination headers for API clients 10 | * - Using generics to avoid type casting 11 | * 12 | * Note: User and Post models are placeholders. 13 | * Replace with your actual models/services. 14 | */ 15 | import Router from '../router-module-loader'; 16 | import type { RouterMiddleware, RouterContext } from '../router-module-loader'; 17 | import { User, Post } from '../common'; 18 | 19 | // =========================================== 20 | // Pagination Configuration 21 | // =========================================== 22 | 23 | const DEFAULT_PAGE = 1; 24 | const DEFAULT_LIMIT = 10; 25 | const MAX_LIMIT = 100; 26 | 27 | // =========================================== 28 | // Pagination Types 29 | // =========================================== 30 | 31 | type PaginationInfo = { 32 | page: number; 33 | limit: number; 34 | offset: number; 35 | }; 36 | 37 | type PaginationState = { 38 | pagination?: PaginationInfo; 39 | }; 40 | 41 | type PaginatedResponse = { 42 | data: T[]; 43 | pagination: { 44 | page: number; 45 | limit: number; 46 | total: number; 47 | pages: number; 48 | hasNext: boolean; 49 | hasPrev: boolean; 50 | }; 51 | }; 52 | 53 | // =========================================== 54 | // Typed Router 55 | // =========================================== 56 | 57 | const router = new Router(); 58 | 59 | // =========================================== 60 | // Pagination Middleware 61 | // =========================================== 62 | 63 | /** 64 | * Pagination middleware factory 65 | * Extracts and validates pagination params from query string 66 | */ 67 | const paginate = (options?: { 68 | defaultLimit?: number; 69 | maxLimit?: number; 70 | }): RouterMiddleware => { 71 | const defaultLimit = options?.defaultLimit || DEFAULT_LIMIT; 72 | const maxLimit = options?.maxLimit || MAX_LIMIT; 73 | 74 | return async (ctx, next) => { 75 | const page = Math.max( 76 | 1, 77 | parseInt(ctx.query.page as string) || DEFAULT_PAGE 78 | ); 79 | const requestedLimit = parseInt(ctx.query.limit as string) || defaultLimit; 80 | const limit = Math.min(Math.max(1, requestedLimit), maxLimit); 81 | const offset = (page - 1) * limit; 82 | 83 | ctx.state.pagination = { page, limit, offset }; 84 | await next(); 85 | }; 86 | }; 87 | 88 | /** 89 | * Helper to build paginated response 90 | */ 91 | function buildPaginatedResponse( 92 | data: T[], 93 | total: number, 94 | pagination: PaginationInfo 95 | ): PaginatedResponse { 96 | const pages = Math.ceil(total / pagination.limit); 97 | 98 | return { 99 | data, 100 | pagination: { 101 | page: pagination.page, 102 | limit: pagination.limit, 103 | total, 104 | pages, 105 | hasNext: pagination.page < pages, 106 | hasPrev: pagination.page > 1 107 | } 108 | }; 109 | } 110 | 111 | /** 112 | * Helper to set pagination headers 113 | */ 114 | function setPaginationHeaders( 115 | ctx: RouterContext, 116 | total: number, 117 | pagination: PaginationInfo 118 | ): void { 119 | const pages = Math.ceil(total / pagination.limit); 120 | ctx.set('X-Total-Count', total.toString()); 121 | ctx.set('X-Page-Count', pages.toString()); 122 | ctx.set('X-Current-Page', pagination.page.toString()); 123 | ctx.set('X-Per-Page', pagination.limit.toString()); 124 | } 125 | 126 | // =========================================== 127 | // Routes 128 | // =========================================== 129 | 130 | /** 131 | * Get paginated users list 132 | * Query params: ?page=1&limit=10 133 | */ 134 | router.get('/users', paginate(), async (ctx) => { 135 | const pagination = ctx.state.pagination!; 136 | const { count, rows } = await User.findAndCountAll({ 137 | limit: pagination.limit, 138 | offset: pagination.offset 139 | }); 140 | 141 | setPaginationHeaders(ctx, count, pagination); 142 | ctx.body = buildPaginatedResponse(rows, count, pagination); 143 | }); 144 | 145 | /** 146 | * Get paginated posts with custom limit 147 | * Query params: ?page=1&limit=20 (max 50) 148 | */ 149 | router.get( 150 | '/posts', 151 | paginate({ defaultLimit: 20, maxLimit: 50 }), 152 | async (ctx) => { 153 | const pagination = ctx.state.pagination!; 154 | const posts = await Post.findAll({ 155 | limit: pagination.limit, 156 | offset: pagination.offset 157 | }); 158 | 159 | // Simple response without total count 160 | ctx.body = { 161 | data: posts, 162 | pagination: { 163 | page: pagination.page, 164 | limit: pagination.limit 165 | } 166 | }; 167 | } 168 | ); 169 | 170 | export { 171 | router, 172 | paginate, 173 | buildPaginatedResponse, 174 | setPaginationHeaders, 175 | DEFAULT_PAGE, 176 | DEFAULT_LIMIT, 177 | MAX_LIMIT 178 | }; 179 | export type { PaginatedResponse, PaginationInfo, PaginationState }; 180 | -------------------------------------------------------------------------------- /recipes/README.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | Common patterns and recipes for building real-world applications with @koa/router. 4 | 5 | ## Available Recipes 6 | 7 | | Recipe | Description | 8 | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- | 9 | | **[TypeScript Recipe](./typescript-recipe/)** | Full TypeScript example showcasing **type inference** - no explicit types needed! | 10 | | **[Nested Routes](./nested-routes/)** | Production-ready nested router patterns with multiple levels (3+ deep) | 11 | | **[RESTful API Structure](./restful-api-structure/)** | Organize your API with nested routers for clean separation | 12 | | **[Authentication & Authorization](./authentication-authorization/)** | JWT-based authentication with role-based access control | 13 | | **[Request Validation](./request-validation/)** | Validate request data with Joi middleware | 14 | | **[Parameter Validation](./parameter-validation/)** | Validate and transform URL parameters using `router.param()` | 15 | | **[API Versioning](./api-versioning/)** | Implement API versioning with multiple routers | 16 | | **[Error Handling](./error-handling/)** | Centralized error handling with custom error classes | 17 | | **[Pagination](./pagination/)** | Pagination middleware with configurable limits and metadata | 18 | | **[Health Checks](./health-checks/)** | Health check, readiness, and liveness probe endpoints | 19 | 20 | ## Key Features Demonstrated 21 | 22 | ### Type Inference (New!) 23 | 24 | The router now provides **automatic type inference** - no explicit type annotations needed: 25 | 26 | ```typescript 27 | import Router from '@koa/router'; 28 | 29 | const router = new Router(); 30 | 31 | // ✅ ctx and next are automatically inferred! 32 | router.get('/users/:id', async (ctx, next) => { 33 | ctx.params.id; // ✅ Inferred as string 34 | ctx.request.params; // ✅ Inferred as Record 35 | ctx.body = { ... }; // ✅ Works 36 | return next(); // ✅ Works 37 | }); 38 | 39 | // ✅ Also works for router.use() 40 | router.use(async (ctx, next) => { 41 | ctx.state.startTime = Date.now(); 42 | return next(); 43 | }); 44 | ``` 45 | 46 | See the [TypeScript Recipe](./typescript-recipe/) for complete examples. 47 | 48 | ## Recipe Structure 49 | 50 | Each recipe folder contains: 51 | 52 | - `[recipe-name].ts` - The recipe implementation with full documentation 53 | - `[recipe-name].test.ts` - Comprehensive tests demonstrating usage 54 | 55 | ## Running Tests 56 | 57 | ```bash 58 | # Run all recipe tests 59 | yarn test:recipes 60 | 61 | # Run a specific recipe test 62 | yarn test:recipes -- --test-name-pattern="Authentication" 63 | ``` 64 | 65 | ## Usage Examples 66 | 67 | ### Authentication & Authorization 68 | 69 | ```typescript 70 | import { 71 | authenticate, 72 | requireRole, 73 | requireAnyRole 74 | } from './recipes/authentication-authorization'; 75 | 76 | // Require authentication only 77 | router.get('/profile', authenticate, getProfile); 78 | 79 | // Require specific role 80 | router.get('/admin', authenticate, requireRole('admin'), adminHandler); 81 | 82 | // Require any of multiple roles 83 | router.get( 84 | '/moderate', 85 | authenticate, 86 | requireAnyRole('admin', 'moderator'), 87 | modHandler 88 | ); 89 | ``` 90 | 91 | ### Pagination 92 | 93 | ```typescript 94 | import { paginate, buildPaginatedResponse } from './recipes/pagination'; 95 | 96 | // Use default pagination (10 items per page) 97 | router.get('/users', paginate(), getUsers); 98 | 99 | // Custom pagination limits 100 | router.get('/posts', paginate({ defaultLimit: 20, maxLimit: 50 }), getPosts); 101 | ``` 102 | 103 | ### Error Handling 104 | 105 | ```typescript 106 | import { 107 | errorHandler, 108 | NotFoundError, 109 | ValidationError 110 | } from './recipes/error-handling'; 111 | 112 | // Global error handler (add first) 113 | app.use(errorHandler); 114 | 115 | // Throw typed errors in routes 116 | router.get('/users/:id', async (ctx) => { 117 | const user = await User.findById(ctx.params.id); 118 | if (!user) { 119 | throw new NotFoundError('User', ctx.params.id); 120 | } 121 | ctx.body = user; 122 | }); 123 | ``` 124 | 125 | ### Combining Recipes 126 | 127 | ```typescript 128 | import { authenticate } from './recipes/authentication-authorization'; 129 | import { paginate } from './recipes/pagination'; 130 | import { validate } from './recipes/request-validation'; 131 | 132 | router 133 | .get('/users', authenticate, paginate(), getUsers) 134 | .post('/users', authenticate, validate(createUserSchema), createUser); 135 | ``` 136 | 137 | ## Contributing 138 | 139 | If you have a useful recipe pattern, feel free to add it to this directory! 140 | -------------------------------------------------------------------------------- /recipes/error-handling/error-handling.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error Handling Recipe 3 | * 4 | * Centralized error handling with custom error classes. 5 | * Demonstrates: 6 | * - Custom error class with status codes and error codes 7 | * - Global error handling middleware 8 | * - Development vs production error responses 9 | * - Specific error types (NotFound, Validation, etc.) 10 | * - Proper body parsing with @koa/bodyparser 11 | * 12 | * Note: User model is a placeholder. Replace with your actual model/service. 13 | */ 14 | import Koa from 'koa'; 15 | import type { Middleware } from 'koa'; 16 | import { bodyParser } from '@koa/bodyparser'; 17 | 18 | import { User } from '../common'; 19 | import Router from '../router-module-loader'; 20 | 21 | const app = new Koa(); 22 | const router = new Router(); 23 | 24 | // =========================================== 25 | // Custom Error Classes 26 | // =========================================== 27 | 28 | type ErrorDetails = Record; 29 | 30 | /** 31 | * Base application error class 32 | * Use this for all operational errors (expected errors) 33 | */ 34 | class AppError extends Error { 35 | readonly status: number; 36 | readonly code: string; 37 | readonly isOperational: boolean; 38 | readonly details?: ErrorDetails; 39 | 40 | constructor( 41 | message: string, 42 | status = 500, 43 | code = 'INTERNAL_ERROR', 44 | details?: ErrorDetails 45 | ) { 46 | super(message); 47 | this.name = 'AppError'; 48 | this.status = status; 49 | this.code = code; 50 | this.isOperational = true; 51 | this.details = details; 52 | 53 | // Maintains proper stack trace for where error was thrown 54 | Error.captureStackTrace(this, this.constructor); 55 | } 56 | } 57 | 58 | /** 59 | * Not Found error (404) 60 | */ 61 | class NotFoundError extends AppError { 62 | constructor(resource: string, id?: string) { 63 | const message = id 64 | ? `${resource} with id '${id}' not found` 65 | : `${resource} not found`; 66 | super(message, 404, `${resource.toUpperCase()}_NOT_FOUND`); 67 | this.name = 'NotFoundError'; 68 | } 69 | } 70 | 71 | /** 72 | * Validation error (400) 73 | */ 74 | class ValidationError extends AppError { 75 | constructor(message: string, details?: ErrorDetails) { 76 | super(message, 400, 'VALIDATION_ERROR', details); 77 | this.name = 'ValidationError'; 78 | } 79 | } 80 | 81 | /** 82 | * Unauthorized error (401) 83 | */ 84 | class UnauthorizedError extends AppError { 85 | constructor(message = 'Authentication required') { 86 | super(message, 401, 'UNAUTHORIZED'); 87 | this.name = 'UnauthorizedError'; 88 | } 89 | } 90 | 91 | /** 92 | * Forbidden error (403) 93 | */ 94 | class ForbiddenError extends AppError { 95 | constructor(message = 'Access denied') { 96 | super(message, 403, 'FORBIDDEN'); 97 | this.name = 'ForbiddenError'; 98 | } 99 | } 100 | 101 | // =========================================== 102 | // Error Handler Middleware 103 | // =========================================== 104 | 105 | type CaughtError = Error & { 106 | status?: number; 107 | code?: string; 108 | details?: ErrorDetails; 109 | isOperational?: boolean; 110 | }; 111 | 112 | /** 113 | * Global error handling middleware 114 | * Should be the first middleware in the chain 115 | */ 116 | const errorHandler: Middleware = async (ctx, next) => { 117 | try { 118 | await next(); 119 | } catch (err) { 120 | const error = err as CaughtError; 121 | const isDev = process.env.NODE_ENV === 'development'; 122 | 123 | // Set response status 124 | ctx.status = error.status || 500; 125 | 126 | // Build error response 127 | ctx.body = { 128 | error: { 129 | message: error.isOperational ? error.message : 'Internal server error', 130 | code: error.code || 'INTERNAL_ERROR', 131 | // Include details only in development or for operational errors 132 | ...(isDev && { stack: error.stack }), 133 | ...(error.details && { details: error.details }) 134 | } 135 | }; 136 | 137 | // Emit error event for logging 138 | ctx.app.emit('error', error, ctx); 139 | } 140 | }; 141 | 142 | // =========================================== 143 | // Routes 144 | // =========================================== 145 | 146 | router.get('/users/:id', async (ctx) => { 147 | const { id } = ctx.params; 148 | 149 | // Validate ID format 150 | if (!/^[a-zA-Z0-9-]+$/.test(id)) { 151 | throw new ValidationError('Invalid user ID format', { 152 | field: 'id', 153 | value: id 154 | }); 155 | } 156 | 157 | const user = await User.findById(id); 158 | if (!user) { 159 | throw new NotFoundError('User', id); 160 | } 161 | 162 | ctx.body = user; 163 | }); 164 | 165 | // POST route using @koa/bodyparser 166 | // The body property is typed by @koa/bodyparser's type augmentation 167 | router.post('/users', async (ctx) => { 168 | // ctx.request.body is typed by @koa/bodyparser 169 | const body = ctx.request.body as 170 | | { email?: string; name?: string } 171 | | undefined; 172 | 173 | if (!body?.email || !body?.name) { 174 | throw new ValidationError('Email and name are required', { 175 | fields: ['email', 'name'] 176 | }); 177 | } 178 | 179 | const user = await User.create({ email: body.email, name: body.name }); 180 | ctx.status = 201; 181 | ctx.body = user; 182 | }); 183 | 184 | // =========================================== 185 | // App Setup 186 | // =========================================== 187 | 188 | // Error handler should be first 189 | app.use(errorHandler); 190 | 191 | // Body parser for POST/PUT requests 192 | app.use(bodyParser()); 193 | 194 | // Router middleware 195 | app.use(router.routes()); 196 | app.use(router.allowedMethods({ throw: true })); 197 | 198 | // Error logging 199 | app.on('error', (err: CaughtError) => { 200 | if (!err.isOperational) { 201 | console.error('Unexpected error:', err); 202 | } 203 | }); 204 | 205 | export { 206 | app, 207 | router, 208 | errorHandler, 209 | AppError, 210 | NotFoundError, 211 | ValidationError, 212 | UnauthorizedError, 213 | ForbiddenError 214 | }; 215 | -------------------------------------------------------------------------------- /recipes/nested-routes/nested-routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Production-Ready Nested Routes Recipe 3 | * 4 | * Demonstrates advanced nested router patterns used in production applications: 5 | * - Multiple levels of nesting (3+ levels deep) 6 | * - Parameter propagation through nested routers 7 | * - Middleware at different nesting levels 8 | * - Multiple resources organized hierarchically 9 | * - Type inference without explicit annotations 10 | * 11 | * This pattern is commonly used in: 12 | * - RESTful APIs with versioning 13 | * - Multi-tenant applications 14 | * - Resource hierarchies (e.g., /users/:userId/posts/:postId/comments) 15 | * - Admin panels with nested sections 16 | */ 17 | 18 | import Koa from 'koa'; 19 | import { bodyParser } from '@koa/bodyparser'; 20 | import Router from '../router-module-loader'; 21 | 22 | const app = new Koa(); 23 | 24 | // Add body parser middleware 25 | app.use(bodyParser()); 26 | 27 | const apiV1Router = new Router({ prefix: '/api/v1' }); 28 | 29 | // ✅ Type inference - no explicit types needed 30 | apiV1Router.use(async (ctx, next) => { 31 | console.log(`[API v1] ${ctx.method} ${ctx.path}`); 32 | ctx.state.apiVersion = 'v1'; 33 | await next(); 34 | }); 35 | 36 | const usersRouter = new Router({ prefix: '/users' }); 37 | 38 | usersRouter.use(async (_ctx, next) => { 39 | console.log('[Users Router] Processing user request'); 40 | await next(); 41 | }); 42 | 43 | usersRouter.get('/', async (ctx) => { 44 | ctx.body = { 45 | users: [ 46 | { id: '1', name: 'John', email: 'john@example.com' }, 47 | { id: '2', name: 'Jane', email: 'jane@example.com' } 48 | ] 49 | }; 50 | }); 51 | 52 | // POST with body - ctx.request.body typed by @koa/bodyparser 53 | usersRouter.post('/', async (ctx) => { 54 | ctx.body = { 55 | id: '3', 56 | ...(ctx.request.body || {}), 57 | createdAt: new Date().toISOString() 58 | }; 59 | }); 60 | 61 | usersRouter.get('/:userId', async (ctx) => { 62 | ctx.body = { 63 | id: ctx.params.userId, 64 | name: 'John', 65 | email: 'john@example.com' 66 | }; 67 | }); 68 | 69 | usersRouter.put('/:userId', async (ctx) => { 70 | ctx.body = { 71 | id: ctx.params.userId, 72 | ...(ctx.request.body || {}), 73 | updatedAt: new Date().toISOString() 74 | }; 75 | }); 76 | 77 | usersRouter.delete('/:userId', async (ctx) => { 78 | ctx.status = 204; 79 | }); 80 | 81 | const userPostsRouter = new Router({ prefix: '/:userId/posts' }); 82 | 83 | userPostsRouter.use(async (ctx, next) => { 84 | console.log(`[User Posts] Loading posts for user ${ctx.params.userId}`); 85 | ctx.state.userId = ctx.params.userId; 86 | await next(); 87 | }); 88 | 89 | userPostsRouter.get('/', async (ctx) => { 90 | ctx.body = { 91 | userId: ctx.params.userId, 92 | posts: [ 93 | { id: '1', title: 'Post 1', userId: ctx.params.userId }, 94 | { id: '2', title: 'Post 2', userId: ctx.params.userId } 95 | ] 96 | }; 97 | }); 98 | 99 | userPostsRouter.post('/', async (ctx) => { 100 | ctx.body = { 101 | id: '3', 102 | userId: ctx.params.userId, 103 | ...(ctx.request.body || {}), 104 | createdAt: new Date().toISOString() 105 | }; 106 | }); 107 | 108 | userPostsRouter.get('/:postId', async (ctx) => { 109 | ctx.body = { 110 | id: ctx.params.postId, 111 | userId: ctx.params.userId, 112 | title: 'Post Title', 113 | content: 'Post content...' 114 | }; 115 | }); 116 | 117 | userPostsRouter.put('/:postId', async (ctx) => { 118 | ctx.body = { 119 | id: ctx.params.postId, 120 | userId: ctx.params.userId, 121 | ...(ctx.request.body || {}), 122 | updatedAt: new Date().toISOString() 123 | }; 124 | }); 125 | 126 | userPostsRouter.delete('/:postId', async (ctx) => { 127 | ctx.status = 204; 128 | }); 129 | 130 | const postCommentsRouter = new Router({ prefix: '/:postId/comments' }); 131 | 132 | postCommentsRouter.use(async (ctx, next) => { 133 | console.log( 134 | `[Comments] Loading comments for post ${ctx.params.postId} by user ${ctx.params.userId}` 135 | ); 136 | ctx.state.postId = ctx.params.postId; 137 | await next(); 138 | }); 139 | 140 | postCommentsRouter.get('/', async (ctx) => { 141 | ctx.body = { 142 | postId: ctx.params.postId, 143 | userId: ctx.params.userId, 144 | comments: [ 145 | { id: '1', text: 'Comment 1', postId: ctx.params.postId }, 146 | { id: '2', text: 'Comment 2', postId: ctx.params.postId } 147 | ] 148 | }; 149 | }); 150 | 151 | postCommentsRouter.post('/', async (ctx) => { 152 | ctx.body = { 153 | id: '3', 154 | postId: ctx.params.postId, 155 | userId: ctx.params.userId, 156 | ...(ctx.request.body || {}), 157 | createdAt: new Date().toISOString() 158 | }; 159 | }); 160 | 161 | postCommentsRouter.get('/:commentId', async (ctx) => { 162 | ctx.body = { 163 | id: ctx.params.commentId, 164 | postId: ctx.params.postId, 165 | userId: ctx.params.userId, 166 | text: 'Comment text...' 167 | }; 168 | }); 169 | 170 | postCommentsRouter.delete('/:commentId', async (ctx) => { 171 | ctx.status = 204; 172 | }); 173 | 174 | const userSettingsRouter = new Router({ prefix: '/:userId/settings' }); 175 | 176 | userSettingsRouter.get('/', async (ctx) => { 177 | ctx.body = { 178 | userId: ctx.params.userId, 179 | theme: 'dark', 180 | notifications: true 181 | }; 182 | }); 183 | 184 | userSettingsRouter.put('/', async (ctx) => { 185 | ctx.body = { 186 | userId: ctx.params.userId, 187 | ...(ctx.request.body || {}), 188 | updatedAt: new Date().toISOString() 189 | }; 190 | }); 191 | 192 | userPostsRouter.use( 193 | postCommentsRouter.routes(), 194 | postCommentsRouter.allowedMethods() 195 | ); 196 | 197 | usersRouter.use(userPostsRouter.routes(), userPostsRouter.allowedMethods()); 198 | usersRouter.use( 199 | userSettingsRouter.routes(), 200 | userSettingsRouter.allowedMethods() 201 | ); 202 | 203 | apiV1Router.use(usersRouter.routes(), usersRouter.allowedMethods()); 204 | 205 | const postsRouter = new Router({ prefix: '/posts' }); 206 | 207 | postsRouter.get('/', async (ctx) => { 208 | ctx.body = { 209 | posts: [ 210 | { id: '1', title: 'Global Post 1' }, 211 | { id: '2', title: 'Global Post 2' } 212 | ] 213 | }; 214 | }); 215 | 216 | postsRouter.get('/:postId', async (ctx) => { 217 | ctx.body = { 218 | id: ctx.params.postId, 219 | title: 'Global Post Title' 220 | }; 221 | }); 222 | 223 | apiV1Router.use(postsRouter.routes(), postsRouter.allowedMethods()); 224 | 225 | app.use(apiV1Router.routes()); 226 | app.use(apiV1Router.allowedMethods()); 227 | 228 | export default app; 229 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module exports tests 3 | * 4 | * These tests verify that both CommonJS and ESM exports work correctly 5 | * after the TypeScript build. This ensures backwards compatibility for 6 | * users who use `const Router = require('@koa/router')` pattern. 7 | */ 8 | import { describe, it, before } from 'node:test'; 9 | import assert from 'node:assert'; 10 | import { execSync } from 'node:child_process'; 11 | import { existsSync } from 'node:fs'; 12 | import { join } from 'node:path'; 13 | 14 | const distPath = join(__dirname, '..', 'dist'); 15 | const cjsPath = join(distPath, 'index.js'); 16 | const esmPath = join(distPath, 'index.mjs'); 17 | 18 | describe('Module Exports (CJS/ESM)', () => { 19 | before(() => { 20 | if (!existsSync(cjsPath)) { 21 | throw new Error( 22 | `CJS dist file not found at ${cjsPath}. Run 'npm run build' first.` 23 | ); 24 | } 25 | if (!existsSync(esmPath)) { 26 | throw new Error( 27 | `ESM dist file not found at ${esmPath}. Run 'npm run build' first.` 28 | ); 29 | } 30 | }); 31 | 32 | describe('CommonJS exports', () => { 33 | it('should allow `const Router = require("@koa/router")`', () => { 34 | const code = ` 35 | const Router = require('${cjsPath.replace(/\\/g, '\\\\')}'); 36 | if (typeof Router !== 'function') { 37 | throw new Error('Router is not a function, got: ' + typeof Router); 38 | } 39 | const router = new Router(); 40 | if (!(router instanceof Router)) { 41 | throw new Error('router is not an instance of Router'); 42 | } 43 | console.log('OK'); 44 | `; 45 | const result = execSync(`node -e "${code}"`, { encoding: 'utf-8' }); 46 | assert.strictEqual(result.trim(), 'OK'); 47 | }); 48 | 49 | it('should allow `require("@koa/router").default` for backwards compatibility', () => { 50 | const code = ` 51 | const Router = require('${cjsPath.replace(/\\/g, '\\\\')}').default; 52 | if (typeof Router !== 'function') { 53 | throw new Error('Router.default is not a function, got: ' + typeof Router); 54 | } 55 | const router = new Router(); 56 | console.log('OK'); 57 | `; 58 | const result = execSync(`node -e "${code}"`, { encoding: 'utf-8' }); 59 | assert.strictEqual(result.trim(), 'OK'); 60 | }); 61 | 62 | it('should allow `require("@koa/router").Router` named export', () => { 63 | const code = ` 64 | const { Router } = require('${cjsPath.replace(/\\/g, '\\\\')}'); 65 | if (typeof Router !== 'function') { 66 | throw new Error('Router named export is not a function, got: ' + typeof Router); 67 | } 68 | const router = new Router(); 69 | console.log('OK'); 70 | `; 71 | const result = execSync(`node -e "${code}"`, { encoding: 'utf-8' }); 72 | assert.strictEqual(result.trim(), 'OK'); 73 | }); 74 | 75 | it('should have Router constructor equal to Router.Router and Router.default', () => { 76 | const code = ` 77 | const Router = require('${cjsPath.replace(/\\/g, '\\\\')}'); 78 | if (Router !== Router.Router) { 79 | throw new Error('Router !== Router.Router'); 80 | } 81 | if (Router !== Router.default) { 82 | throw new Error('Router !== Router.default'); 83 | } 84 | console.log('OK'); 85 | `; 86 | const result = execSync(`node -e "${code}"`, { encoding: 'utf-8' }); 87 | assert.strictEqual(result.trim(), 'OK'); 88 | }); 89 | 90 | it('should expose static url() method', () => { 91 | const code = ` 92 | const Router = require('${cjsPath.replace(/\\/g, '\\\\')}'); 93 | if (typeof Router.url !== 'function') { 94 | throw new Error('Router.url is not a function'); 95 | } 96 | const url = Router.url('/users/:id', { id: 123 }); 97 | if (url !== '/users/123') { 98 | throw new Error('Expected /users/123, got: ' + url); 99 | } 100 | console.log('OK'); 101 | `; 102 | const result = execSync(`node -e "${code}"`, { encoding: 'utf-8' }); 103 | assert.strictEqual(result.trim(), 'OK'); 104 | }); 105 | }); 106 | 107 | describe('ESM exports', () => { 108 | it('should allow `import Router from "@koa/router"`', () => { 109 | const code = ` 110 | import Router from '${esmPath.replace(/\\/g, '\\\\')}'; 111 | if (typeof Router !== 'function') { 112 | throw new Error('Router is not a function, got: ' + typeof Router); 113 | } 114 | const router = new Router(); 115 | if (!(router instanceof Router)) { 116 | throw new Error('router is not an instance of Router'); 117 | } 118 | console.log('OK'); 119 | `; 120 | const result = execSync(`node --input-type=module -e "${code}"`, { 121 | encoding: 'utf-8' 122 | }); 123 | assert.strictEqual(result.trim(), 'OK'); 124 | }); 125 | 126 | it('should allow `import { Router } from "@koa/router"`', () => { 127 | const code = ` 128 | import { Router } from '${esmPath.replace(/\\/g, '\\\\')}'; 129 | if (typeof Router !== 'function') { 130 | throw new Error('Router named import is not a function, got: ' + typeof Router); 131 | } 132 | const router = new Router(); 133 | console.log('OK'); 134 | `; 135 | const result = execSync(`node --input-type=module -e "${code}"`, { 136 | encoding: 'utf-8' 137 | }); 138 | assert.strictEqual(result.trim(), 'OK'); 139 | }); 140 | 141 | it('should have default export equal to named Router export', () => { 142 | const code = ` 143 | import DefaultRouter from '${esmPath.replace(/\\/g, '\\\\')}'; 144 | import { Router } from '${esmPath.replace(/\\/g, '\\\\')}'; 145 | if (DefaultRouter !== Router) { 146 | throw new Error('Default export !== named Router export'); 147 | } 148 | console.log('OK'); 149 | `; 150 | const result = execSync(`node --input-type=module -e "${code}"`, { 151 | encoding: 'utf-8' 152 | }); 153 | assert.strictEqual(result.trim(), 'OK'); 154 | }); 155 | 156 | it('should expose static url() method', () => { 157 | const code = ` 158 | import Router from '${esmPath.replace(/\\/g, '\\\\')}'; 159 | if (typeof Router.url !== 'function') { 160 | throw new Error('Router.url is not a function'); 161 | } 162 | const url = Router.url('/users/:id', { id: 456 }); 163 | if (url !== '/users/456') { 164 | throw new Error('Expected /users/456, got: ' + url); 165 | } 166 | console.log('OK'); 167 | `; 168 | const result = execSync(`node --input-type=module -e "${code}"`, { 169 | encoding: 'utf-8' 170 | }); 171 | assert.strictEqual(result.trim(), 'OK'); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for @koa/router 3 | */ 4 | 5 | import type { 6 | ParameterizedContext, 7 | DefaultContext, 8 | DefaultState, 9 | Middleware 10 | } from 'koa'; 11 | import type { RouterInstance as Router } from './router'; 12 | import type Layer from './layer'; 13 | 14 | /** 15 | * Re-export Koa types for convenience 16 | * This makes types.ts the single source of truth for all type imports 17 | */ 18 | export type { 19 | Middleware, 20 | ParameterizedContext, 21 | DefaultContext, 22 | DefaultState 23 | } from 'koa'; 24 | 25 | export type RouterOptions = { 26 | /** 27 | * Only run last matched route's controller when there are multiple matches 28 | */ 29 | exclusive?: boolean; 30 | 31 | /** 32 | * Prefix for all routes 33 | */ 34 | prefix?: string; 35 | 36 | /** 37 | * Host for router match (string, array of strings, or RegExp) 38 | * - string: exact match 39 | * - string[]: matches if input equals any string in the array 40 | * - RegExp: pattern match 41 | */ 42 | host?: string | string[] | RegExp; 43 | 44 | /** 45 | * HTTP methods this router should respond to 46 | */ 47 | methods?: string[]; 48 | 49 | /** 50 | * Path to use for routing (internal) 51 | */ 52 | routerPath?: string; 53 | 54 | /** 55 | * Whether to use case-sensitive routing 56 | */ 57 | sensitive?: boolean; 58 | 59 | /** 60 | * Whether trailing slashes are significant 61 | */ 62 | strict?: boolean; 63 | 64 | /** 65 | * Additional options passed through 66 | */ 67 | [key: string]: unknown; 68 | }; 69 | 70 | export type LayerOptions = { 71 | /** 72 | * Route name for URL generation 73 | */ 74 | name?: string | null; 75 | 76 | /** 77 | * Case sensitive routing 78 | */ 79 | sensitive?: boolean; 80 | 81 | /** 82 | * Require trailing slash 83 | */ 84 | strict?: boolean; 85 | 86 | /** 87 | * Whether trailing slashes matter (path-to-regexp v8) 88 | */ 89 | trailing?: boolean; 90 | 91 | /** 92 | * Route path ends at this path 93 | */ 94 | end?: boolean; 95 | 96 | /** 97 | * Prefix for the route 98 | */ 99 | prefix?: string; 100 | 101 | /** 102 | * Ignore captures in route matching 103 | */ 104 | ignoreCaptures?: boolean; 105 | 106 | /** 107 | * Treat path as a regular expression 108 | */ 109 | pathAsRegExp?: boolean; 110 | 111 | /** 112 | * Additional options passed through to path-to-regexp 113 | */ 114 | [key: string]: unknown; 115 | }; 116 | 117 | export type UrlOptions = { 118 | /** 119 | * Query string parameters 120 | */ 121 | query?: Record | string; 122 | 123 | [key: string]: unknown; 124 | }; 125 | 126 | export type RouterParameterContext< 127 | StateT = DefaultState, 128 | ContextT = DefaultContext 129 | > = { 130 | /** 131 | * URL parameters 132 | */ 133 | params: Record; 134 | 135 | /** 136 | * Router instance 137 | */ 138 | router: Router; 139 | 140 | /** 141 | * Matched route path (internal) 142 | */ 143 | _matchedRoute?: string | RegExp; 144 | 145 | /** 146 | * Matched route name (internal) 147 | */ 148 | _matchedRouteName?: string; 149 | }; 150 | 151 | export type RouterParameterMiddleware< 152 | StateT = DefaultState, 153 | ContextT = DefaultContext, 154 | BodyT = unknown 155 | > = ( 156 | parameterValue: string, 157 | context: RouterContext, 158 | next: () => Promise 159 | ) => unknown | Promise; 160 | 161 | export type MatchResult< 162 | StateT = DefaultState, 163 | ContextT = DefaultContext, 164 | BodyT = unknown 165 | > = { 166 | /** 167 | * Layers that matched the path 168 | */ 169 | path: Layer[]; 170 | 171 | /** 172 | * Layers that matched both path and HTTP method 173 | */ 174 | pathAndMethod: Layer[]; 175 | 176 | /** 177 | * Whether a route (not just middleware) was matched 178 | */ 179 | route: boolean; 180 | }; 181 | 182 | export type AllowedMethodsOptions = { 183 | /** 184 | * Throw error instead of setting status and header 185 | */ 186 | throw?: boolean; 187 | 188 | /** 189 | * Throw the returned value in place of the default NotImplemented error 190 | */ 191 | notImplemented?: () => Error; 192 | 193 | /** 194 | * Throw the returned value in place of the default MethodNotAllowed error 195 | */ 196 | methodNotAllowed?: () => Error; 197 | }; 198 | 199 | /** 200 | * Extended Koa context with router-specific properties 201 | * Matches the structure from @types/koa-router 202 | */ 203 | export type RouterContext< 204 | StateT = DefaultState, 205 | ContextT = DefaultContext, 206 | BodyT = unknown 207 | > = ParameterizedContext< 208 | StateT, 209 | ContextT & RouterParameterContext, 210 | BodyT 211 | > & { 212 | /** 213 | * Request with params (set by router during routing) 214 | */ 215 | request: { 216 | params: Record; 217 | }; 218 | 219 | /** 220 | * Path of matched route 221 | */ 222 | routerPath?: string; 223 | 224 | /** 225 | * Name of matched route 226 | */ 227 | routerName?: string; 228 | 229 | /** 230 | * Array of matched layers 231 | */ 232 | matched?: Layer[]; 233 | 234 | /** 235 | * Captured values from path 236 | */ 237 | captures?: string[]; 238 | 239 | /** 240 | * New router path (for nested routers) 241 | */ 242 | newRouterPath?: string; 243 | 244 | /** 245 | * Track param middleware execution (internal) 246 | */ 247 | _matchedParams?: WeakMap; 248 | }; 249 | 250 | /** 251 | * Router middleware function type 252 | */ 253 | export type RouterMiddleware< 254 | StateT = DefaultState, 255 | ContextT = DefaultContext, 256 | BodyT = unknown 257 | > = Middleware, BodyT>; 258 | 259 | /** 260 | * HTTP method names in lowercase 261 | */ 262 | export type HttpMethod = 263 | | 'get' 264 | | 'post' 265 | | 'put' 266 | | 'patch' 267 | | 'delete' 268 | | 'del' 269 | | 'head' 270 | | 'options' 271 | | 'connect' 272 | | 'trace' 273 | | string; 274 | 275 | /** 276 | * Router options with generic methods array for type inference 277 | */ 278 | export type RouterOptionsWithMethods = Omit< 279 | RouterOptions, 280 | 'methods' 281 | > & { 282 | methods?: readonly M[]; 283 | }; 284 | 285 | /** 286 | * Type for a dynamic HTTP method function on Router 287 | */ 288 | export type RouterMethodFunction< 289 | StateT = DefaultState, 290 | ContextT = DefaultContext 291 | > = { 292 | ( 293 | name: string, 294 | path: string | RegExp, 295 | ...middleware: Array> 296 | ): Router; 297 | ( 298 | path: string | RegExp | Array, 299 | ...middleware: Array> 300 | ): Router; 301 | }; 302 | 303 | /** 304 | * Router with additional HTTP methods based on the methods option. 305 | * Use createRouter() factory function for automatic type inference. 306 | */ 307 | export type RouterWithMethods< 308 | M extends string, 309 | StateT = DefaultState, 310 | ContextT = DefaultContext 311 | > = Router & 312 | Record, RouterMethodFunction>; 313 | 314 | export { type default as Layer } from './layer'; 315 | -------------------------------------------------------------------------------- /test/utils/path-helpers.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for path handling utilities 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import assert from 'node:assert'; 7 | 8 | import { 9 | hasPathParameters, 10 | determineMiddlewarePath 11 | } from '../../src/utils/path-helpers'; 12 | import type { LayerOptions } from '../../src/types'; 13 | 14 | describe('path-helpers utilities', () => { 15 | describe('hasPathParameters()', () => { 16 | it('should return false for empty string', () => { 17 | assert.strictEqual(hasPathParameters(''), false); 18 | }); 19 | 20 | it('should return false for undefined', () => { 21 | // @ts-expect-error - testing undefined input 22 | assert.strictEqual(hasPathParameters(undefined), false); 23 | }); 24 | 25 | it('should return false for path without parameters', () => { 26 | assert.strictEqual(hasPathParameters('/users'), false); 27 | assert.strictEqual(hasPathParameters('/api/v1'), false); 28 | assert.strictEqual(hasPathParameters('/'), false); 29 | }); 30 | 31 | it('should return true for path with single parameter', () => { 32 | assert.strictEqual(hasPathParameters('/users/:id'), true); 33 | assert.strictEqual(hasPathParameters('/:category'), true); 34 | }); 35 | 36 | it('should return true for path with multiple parameters', () => { 37 | assert.strictEqual( 38 | hasPathParameters('/users/:userId/posts/:postId'), 39 | true 40 | ); 41 | assert.strictEqual(hasPathParameters('/:category/:title'), true); 42 | }); 43 | 44 | it('should return true for path with optional parameter', () => { 45 | assert.strictEqual(hasPathParameters('/user/:id'), true); 46 | }); 47 | 48 | it('should return true for path with wildcard parameter', () => { 49 | assert.strictEqual(hasPathParameters('/files/{/*path}'), true); 50 | }); 51 | 52 | it('should return true for prefix with parameters', () => { 53 | assert.strictEqual(hasPathParameters('/api/v:version'), true); 54 | assert.strictEqual(hasPathParameters('/users/:userId'), true); 55 | }); 56 | 57 | it('should handle options parameter', () => { 58 | const options: LayerOptions = { 59 | sensitive: true, 60 | strict: false 61 | }; 62 | 63 | assert.strictEqual(hasPathParameters('/users/:id', options), true); 64 | assert.strictEqual(hasPathParameters('/users', options), false); 65 | }); 66 | }); 67 | 68 | describe('determineMiddlewarePath()', () => { 69 | describe('with explicit path provided', () => { 70 | it('should return empty string as wildcard when explicit path is empty string', () => { 71 | const result = determineMiddlewarePath('', false); 72 | 73 | assert.strictEqual(result.path, '{/*rest}'); 74 | assert.strictEqual(result.pathAsRegExp, false); 75 | }); 76 | 77 | it('should return root path as-is', () => { 78 | const result = determineMiddlewarePath('/', false); 79 | 80 | assert.strictEqual(result.path, '/'); 81 | assert.strictEqual(result.pathAsRegExp, false); 82 | }); 83 | 84 | it('should return string path as-is', () => { 85 | const result = determineMiddlewarePath('/api', false); 86 | 87 | assert.strictEqual(result.path, '/api'); 88 | assert.strictEqual(result.pathAsRegExp, false); 89 | }); 90 | 91 | it('should return RegExp path with pathAsRegExp flag', () => { 92 | const regexp = /^\/api\//; 93 | const result = determineMiddlewarePath(regexp, false); 94 | 95 | assert.strictEqual(result.path, regexp); 96 | assert.strictEqual(result.pathAsRegExp, true); 97 | }); 98 | 99 | it('should handle nested paths', () => { 100 | const result = determineMiddlewarePath('/api/v1/users', false); 101 | 102 | assert.strictEqual(result.path, '/api/v1/users'); 103 | assert.strictEqual(result.pathAsRegExp, false); 104 | }); 105 | 106 | it('should handle paths with parameters', () => { 107 | const result = determineMiddlewarePath('/users/:id', false); 108 | 109 | assert.strictEqual(result.path, '/users/:id'); 110 | assert.strictEqual(result.pathAsRegExp, false); 111 | }); 112 | }); 113 | 114 | describe('without explicit path', () => { 115 | it('should return wildcard when prefix has parameters', () => { 116 | const result = determineMiddlewarePath(undefined, true); 117 | 118 | assert.strictEqual(result.path, '{/*rest}'); 119 | assert.strictEqual(result.pathAsRegExp, false); 120 | }); 121 | 122 | it('should return regex boundary when prefix has no parameters', () => { 123 | const result = determineMiddlewarePath(undefined, false); 124 | 125 | assert.strictEqual(result.pathAsRegExp, true); 126 | assert.strictEqual(typeof result.path, 'string'); 127 | assert.strictEqual( 128 | (result.path as string).includes('\\/'), 129 | true || (result.path as string).includes('|') 130 | ); 131 | }); 132 | 133 | it('should return boundary regex pattern for default case', () => { 134 | const result = determineMiddlewarePath(undefined, false); 135 | 136 | assert.strictEqual(result.pathAsRegExp, true); 137 | const pattern = result.path as string; 138 | assert.strictEqual(typeof pattern, 'string'); 139 | }); 140 | }); 141 | 142 | describe('edge cases', () => { 143 | it('should handle empty string with prefix parameters', () => { 144 | const result = determineMiddlewarePath('', true); 145 | 146 | assert.strictEqual(result.path, '{/*rest}'); 147 | assert.strictEqual(result.pathAsRegExp, false); 148 | }); 149 | 150 | it('should handle root path with prefix parameters', () => { 151 | const result = determineMiddlewarePath('/', true); 152 | 153 | assert.strictEqual(result.path, '/'); 154 | assert.strictEqual(result.pathAsRegExp, false); 155 | }); 156 | 157 | it('should handle RegExp with prefix parameters', () => { 158 | const regexp = /^\/api\//; 159 | const result = determineMiddlewarePath(regexp, true); 160 | 161 | assert.strictEqual(result.path, regexp); 162 | assert.strictEqual(result.pathAsRegExp, true); 163 | }); 164 | 165 | it('should handle complex RegExp patterns', () => { 166 | const regexp = /^\/api\/v\d+\//; 167 | const result = determineMiddlewarePath(regexp, false); 168 | 169 | assert.strictEqual(result.path, regexp); 170 | assert.strictEqual(result.pathAsRegExp, true); 171 | }); 172 | }); 173 | 174 | describe('integration scenarios', () => { 175 | it('should handle middleware path for nested routers', () => { 176 | const result = determineMiddlewarePath('/api', false); 177 | 178 | assert.strictEqual(result.path, '/api'); 179 | assert.strictEqual(result.pathAsRegExp, false); 180 | }); 181 | 182 | it('should handle middleware path for parameterized prefix', () => { 183 | const result = determineMiddlewarePath(undefined, true); 184 | 185 | assert.strictEqual(result.path, '{/*rest}'); 186 | assert.strictEqual(result.pathAsRegExp, false); 187 | }); 188 | 189 | it('should handle explicit path override for parameterized prefix', () => { 190 | const result = determineMiddlewarePath('/posts', true); 191 | 192 | assert.strictEqual(result.path, '/posts'); 193 | assert.strictEqual(result.pathAsRegExp, false); 194 | }); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /test/utils/parameter-helpers.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for parameter handling utilities 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import assert from 'node:assert'; 7 | 8 | import Router from '../../src'; 9 | import Layer from '../../src/layer'; 10 | import { 11 | normalizeParameterMiddleware, 12 | applyParameterMiddlewareToRoute, 13 | applyAllParameterMiddleware 14 | } from '../../src/utils/parameter-helpers'; 15 | import type { RouterParameterMiddleware } from '../../src/types'; 16 | 17 | describe('parameter-helpers utilities', () => { 18 | describe('normalizeParameterMiddleware()', () => { 19 | it('should return empty array for undefined', () => { 20 | const result = normalizeParameterMiddleware(undefined); 21 | assert.deepStrictEqual(result, []); 22 | }); 23 | 24 | it('should return array for single middleware function', () => { 25 | const middleware: RouterParameterMiddleware = async ( 26 | value, 27 | ctx, 28 | next 29 | ) => { 30 | return next(); 31 | }; 32 | 33 | const result = normalizeParameterMiddleware(middleware); 34 | assert.strictEqual(Array.isArray(result), true); 35 | assert.strictEqual(result.length, 1); 36 | assert.strictEqual(result[0], middleware); 37 | }); 38 | 39 | it('should return array as-is for array input', () => { 40 | const middleware1: RouterParameterMiddleware = async ( 41 | value, 42 | ctx, 43 | next 44 | ) => { 45 | return next(); 46 | }; 47 | const middleware2: RouterParameterMiddleware = async ( 48 | value, 49 | ctx, 50 | next 51 | ) => { 52 | return next(); 53 | }; 54 | const middlewareArray = [middleware1, middleware2]; 55 | 56 | const result = normalizeParameterMiddleware(middlewareArray); 57 | assert.strictEqual(Array.isArray(result), true); 58 | assert.strictEqual(result.length, 2); 59 | assert.deepStrictEqual(result, middlewareArray); 60 | }); 61 | 62 | it('should handle empty array', () => { 63 | const result = normalizeParameterMiddleware([]); 64 | assert.deepStrictEqual(result, []); 65 | }); 66 | 67 | it('should handle single item array', () => { 68 | const middleware: RouterParameterMiddleware = async ( 69 | value, 70 | ctx, 71 | next 72 | ) => { 73 | return next(); 74 | }; 75 | const result = normalizeParameterMiddleware([middleware]); 76 | assert.strictEqual(result.length, 1); 77 | assert.strictEqual(result[0], middleware); 78 | }); 79 | }); 80 | 81 | describe('applyParameterMiddlewareToRoute()', () => { 82 | it('should apply single middleware to router', () => { 83 | const router = new Router(); 84 | 85 | const middleware: RouterParameterMiddleware = async ( 86 | value, 87 | ctx, 88 | next 89 | ) => { 90 | return next(); 91 | }; 92 | 93 | applyParameterMiddlewareToRoute(router, 'id', middleware); 94 | 95 | assert.strictEqual(router.params.id !== undefined, true); 96 | assert.strictEqual(Array.isArray(router.params.id), true); 97 | const registered = router.params.id as RouterParameterMiddleware[]; 98 | assert.strictEqual(registered.length, 1); 99 | assert.strictEqual(registered[0], middleware); 100 | }); 101 | 102 | it('should apply array of middleware to router', () => { 103 | const router = new Router(); 104 | const callOrder: string[] = []; 105 | 106 | const middleware1: RouterParameterMiddleware = async ( 107 | value, 108 | ctx, 109 | next 110 | ) => { 111 | callOrder.push('1'); 112 | return next(); 113 | }; 114 | const middleware2: RouterParameterMiddleware = async ( 115 | value, 116 | ctx, 117 | next 118 | ) => { 119 | callOrder.push('2'); 120 | return next(); 121 | }; 122 | 123 | applyParameterMiddlewareToRoute(router, 'id', [middleware1, middleware2]); 124 | 125 | assert.strictEqual(Array.isArray(router.params.id), true); 126 | const registered = router.params.id as RouterParameterMiddleware[]; 127 | assert.strictEqual(registered.length, 2); 128 | assert.strictEqual(registered[0], middleware1); 129 | assert.strictEqual(registered[1], middleware2); 130 | }); 131 | 132 | it('should apply middleware to Layer', () => { 133 | const layer = new Layer('/users/:id', ['GET'], async () => {}); 134 | let called = false; 135 | 136 | const middleware: RouterParameterMiddleware = async ( 137 | value, 138 | ctx, 139 | next 140 | ) => { 141 | called = true; 142 | return next(); 143 | }; 144 | 145 | applyParameterMiddlewareToRoute(layer, 'id', middleware); 146 | 147 | assert.strictEqual(typeof middleware, 'function'); 148 | }); 149 | 150 | it('should handle undefined middleware gracefully', () => { 151 | const router = new Router(); 152 | 153 | // @ts-expect-error - testing undefined middleware 154 | applyParameterMiddlewareToRoute(router, 'id', undefined); 155 | 156 | assert.strictEqual(router instanceof Router, true); 157 | }); 158 | }); 159 | 160 | describe('applyAllParameterMiddleware()', () => { 161 | it('should apply all middleware from params object', () => { 162 | const router = new Router(); 163 | const callOrder: string[] = []; 164 | 165 | const idMiddleware: RouterParameterMiddleware = async ( 166 | value, 167 | ctx, 168 | next 169 | ) => { 170 | callOrder.push('id'); 171 | return next(); 172 | }; 173 | 174 | const nameMiddleware: RouterParameterMiddleware = async ( 175 | value, 176 | ctx, 177 | next 178 | ) => { 179 | callOrder.push('name'); 180 | return next(); 181 | }; 182 | 183 | const paramsObject = { 184 | id: idMiddleware, 185 | name: nameMiddleware 186 | }; 187 | 188 | const layer = new Layer('/users/:id/:name', ['GET'], async () => {}); 189 | 190 | applyAllParameterMiddleware(layer, paramsObject); 191 | 192 | assert.strictEqual(typeof idMiddleware, 'function'); 193 | assert.strictEqual(typeof nameMiddleware, 'function'); 194 | }); 195 | 196 | it('should handle empty params object', () => { 197 | const layer = new Layer('/users', ['GET'], async () => {}); 198 | 199 | applyAllParameterMiddleware(layer, {}); 200 | 201 | assert.strictEqual(layer instanceof Layer, true); 202 | }); 203 | 204 | it('should handle params object with array middleware', () => { 205 | const router = new Router(); 206 | 207 | const middleware1: RouterParameterMiddleware = async ( 208 | value, 209 | ctx, 210 | next 211 | ) => { 212 | return next(); 213 | }; 214 | const middleware2: RouterParameterMiddleware = async ( 215 | value, 216 | ctx, 217 | next 218 | ) => { 219 | return next(); 220 | }; 221 | 222 | const paramsObject = { 223 | id: [middleware1, middleware2] as RouterParameterMiddleware[] 224 | }; 225 | 226 | const layer = new Layer('/users/:id', ['GET'], async () => {}); 227 | 228 | applyAllParameterMiddleware(layer, paramsObject); 229 | 230 | assert.strictEqual(layer instanceof Layer, true); 231 | }); 232 | 233 | it('should apply middleware for multiple parameters', () => { 234 | const router = new Router(); 235 | 236 | const idMiddleware: RouterParameterMiddleware = async ( 237 | value, 238 | ctx, 239 | next 240 | ) => { 241 | return next(); 242 | }; 243 | 244 | const userIdMiddleware: RouterParameterMiddleware = async ( 245 | value, 246 | ctx, 247 | next 248 | ) => { 249 | return next(); 250 | }; 251 | 252 | const postIdMiddleware: RouterParameterMiddleware = async ( 253 | value, 254 | ctx, 255 | next 256 | ) => { 257 | return next(); 258 | }; 259 | 260 | const paramsObject = { 261 | id: idMiddleware, 262 | userId: userIdMiddleware, 263 | postId: postIdMiddleware 264 | }; 265 | 266 | const layer = new Layer( 267 | '/users/:userId/posts/:postId', 268 | ['GET'], 269 | async () => {} 270 | ); 271 | 272 | applyAllParameterMiddleware(layer, paramsObject); 273 | 274 | assert.strictEqual(layer instanceof Layer, true); 275 | }); 276 | 277 | it('should handle mixed single and array middleware', () => { 278 | const router = new Router(); 279 | 280 | const singleMiddleware: RouterParameterMiddleware = async ( 281 | value, 282 | ctx, 283 | next 284 | ) => { 285 | return next(); 286 | }; 287 | 288 | const arrayMiddleware1: RouterParameterMiddleware = async ( 289 | value, 290 | ctx, 291 | next 292 | ) => { 293 | return next(); 294 | }; 295 | const arrayMiddleware2: RouterParameterMiddleware = async ( 296 | value, 297 | ctx, 298 | next 299 | ) => { 300 | return next(); 301 | }; 302 | 303 | const paramsObject = { 304 | id: singleMiddleware, 305 | userId: [ 306 | arrayMiddleware1, 307 | arrayMiddleware2 308 | ] as RouterParameterMiddleware[] 309 | }; 310 | 311 | const layer = new Layer('/users/:userId/:id', ['GET'], async () => {}); 312 | 313 | applyAllParameterMiddleware(layer, paramsObject); 314 | 315 | assert.strictEqual(layer instanceof Layer, true); 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /recipes/nested-routes/nested-routes.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Production-Ready Nested Routes Recipe 3 | * 4 | * Tests multiple levels of nesting, parameter propagation, and real-world scenarios. 5 | */ 6 | 7 | import { describe, it } from 'node:test'; 8 | import * as assert from 'node:assert'; 9 | import * as http from 'node:http'; 10 | import Koa from 'koa'; 11 | import Router, { RouterContext } from '../router-module-loader'; 12 | import request from 'supertest'; 13 | 14 | type RequestBody = Record; 15 | 16 | type ContextWithBody = RouterContext & { 17 | request: RouterContext['request'] & { 18 | body?: RequestBody; 19 | }; 20 | }; 21 | 22 | describe('Production-Ready Nested Routes', () => { 23 | it('should handle multiple levels of nested routes correctly', async () => { 24 | const app = new Koa(); 25 | 26 | app.use(async (ctx, next) => { 27 | if (ctx.request.is('application/json')) { 28 | let body = ''; 29 | for await (const chunk of ctx.req) { 30 | body += chunk; 31 | } 32 | try { 33 | (ctx.request as { body?: RequestBody }).body = JSON.parse(body); 34 | } catch { 35 | (ctx.request as { body?: RequestBody }).body = {}; 36 | } 37 | } 38 | await next(); 39 | }); 40 | 41 | // ============================================================================ 42 | // Level 1: API Version Router 43 | // ============================================================================ 44 | const apiV1Router = new Router({ prefix: '/api/v1' }); 45 | 46 | // ============================================================================ 47 | // Level 2: Users Router 48 | // ============================================================================ 49 | const usersRouter = new Router({ prefix: '/users' }); 50 | 51 | usersRouter.get('/', async (ctx: RouterContext) => { 52 | ctx.body = { users: [{ id: '1', name: 'John' }] }; 53 | }); 54 | 55 | usersRouter.get('/:userId', async (ctx: RouterContext) => { 56 | ctx.body = { id: ctx.params.userId, name: 'John' }; 57 | }); 58 | 59 | // ============================================================================ 60 | // Level 3: User Posts Router 61 | // ============================================================================ 62 | const userPostsRouter = new Router({ prefix: '/:userId/posts' }); 63 | 64 | userPostsRouter.get('/', async (ctx: RouterContext) => { 65 | ctx.body = { 66 | userId: ctx.params.userId, 67 | posts: [{ id: '1', title: 'Post 1' }] 68 | }; 69 | }); 70 | 71 | userPostsRouter.get('/:postId', async (ctx: RouterContext) => { 72 | ctx.body = { 73 | id: ctx.params.postId, 74 | userId: ctx.params.userId, 75 | title: 'Post Title' 76 | }; 77 | }); 78 | 79 | userPostsRouter.post('/', async (ctx: ContextWithBody) => { 80 | ctx.body = { 81 | id: '2', 82 | userId: ctx.params.userId, 83 | ...ctx.request.body 84 | }; 85 | }); 86 | 87 | // ============================================================================ 88 | // Level 4: Post Comments Router 89 | // ============================================================================ 90 | const postCommentsRouter = new Router({ prefix: '/:postId/comments' }); 91 | 92 | postCommentsRouter.get('/', async (ctx: RouterContext) => { 93 | ctx.body = { 94 | postId: ctx.params.postId, 95 | userId: ctx.params.userId, 96 | comments: [{ id: '1', text: 'Comment 1' }] 97 | }; 98 | }); 99 | 100 | postCommentsRouter.get('/:commentId', async (ctx: RouterContext) => { 101 | ctx.body = { 102 | id: ctx.params.commentId, 103 | postId: ctx.params.postId, 104 | userId: ctx.params.userId, 105 | text: 'Comment text' 106 | }; 107 | }); 108 | 109 | postCommentsRouter.post('/', async (ctx: ContextWithBody) => { 110 | ctx.body = { 111 | id: '2', 112 | postId: ctx.params.postId, 113 | userId: ctx.params.userId, 114 | ...ctx.request.body 115 | }; 116 | }); 117 | 118 | // ============================================================================ 119 | // Level 3: User Settings Router 120 | // ============================================================================ 121 | const userSettingsRouter = new Router({ prefix: '/:userId/settings' }); 122 | 123 | userSettingsRouter.get('/', async (ctx: RouterContext) => { 124 | ctx.body = { 125 | userId: ctx.params.userId, 126 | theme: 'dark' 127 | }; 128 | }); 129 | 130 | userSettingsRouter.put('/', async (ctx: ContextWithBody) => { 131 | ctx.body = { 132 | userId: ctx.params.userId, 133 | ...ctx.request.body 134 | }; 135 | }); 136 | 137 | // ============================================================================ 138 | // Mounting: Assemble nested structure 139 | // ============================================================================ 140 | userPostsRouter.use( 141 | postCommentsRouter.routes(), 142 | postCommentsRouter.allowedMethods() 143 | ); 144 | usersRouter.use(userPostsRouter.routes(), userPostsRouter.allowedMethods()); 145 | usersRouter.use( 146 | userSettingsRouter.routes(), 147 | userSettingsRouter.allowedMethods() 148 | ); 149 | apiV1Router.use(usersRouter.routes(), usersRouter.allowedMethods()); 150 | app.use(apiV1Router.routes()); 151 | app.use(apiV1Router.allowedMethods()); 152 | 153 | const server = http.createServer(app.callback()); 154 | 155 | // Test Level 2: Users routes 156 | const res1 = await request(server).get('/api/v1/users').expect(200); 157 | assert.strictEqual(Array.isArray(res1.body.users), true); 158 | 159 | const res2 = await request(server).get('/api/v1/users/123').expect(200); 160 | assert.strictEqual(res2.body.id, '123'); 161 | 162 | // Test Level 3: User Posts routes 163 | const res3 = await request(server) 164 | .get('/api/v1/users/123/posts') 165 | .expect(200); 166 | assert.strictEqual(res3.body.userId, '123'); 167 | assert.strictEqual(Array.isArray(res3.body.posts), true); 168 | 169 | const res4 = await request(server) 170 | .get('/api/v1/users/123/posts/456') 171 | .expect(200); 172 | assert.strictEqual(res4.body.id, '456'); 173 | assert.strictEqual(res4.body.userId, '123'); 174 | 175 | const res5 = await request(server) 176 | .post('/api/v1/users/123/posts') 177 | .send({ title: 'New Post' }) 178 | .expect(200); 179 | assert.strictEqual(res5.body.userId, '123'); 180 | assert.strictEqual(res5.body.title, 'New Post'); 181 | 182 | // Test Level 4: Post Comments routes (deeply nested) 183 | const res6 = await request(server) 184 | .get('/api/v1/users/123/posts/456/comments') 185 | .expect(200); 186 | assert.strictEqual(res6.body.userId, '123'); 187 | assert.strictEqual(res6.body.postId, '456'); 188 | assert.strictEqual(Array.isArray(res6.body.comments), true); 189 | 190 | const res7 = await request(server) 191 | .get('/api/v1/users/123/posts/456/comments/789') 192 | .expect(200); 193 | assert.strictEqual(res7.body.userId, '123'); 194 | assert.strictEqual(res7.body.postId, '456'); 195 | assert.strictEqual(res7.body.id, '789'); 196 | 197 | const res8 = await request(server) 198 | .post('/api/v1/users/123/posts/456/comments') 199 | .send({ text: 'New Comment' }) 200 | .expect(200); 201 | assert.strictEqual(res8.body.userId, '123'); 202 | assert.strictEqual(res8.body.postId, '456'); 203 | assert.strictEqual(res8.body.text, 'New Comment'); 204 | 205 | // Test Level 3: User Settings routes 206 | const res9 = await request(server) 207 | .get('/api/v1/users/123/settings') 208 | .expect(200); 209 | assert.strictEqual(res9.body.userId, '123'); 210 | 211 | const res10 = await request(server) 212 | .put('/api/v1/users/123/settings') 213 | .send({ theme: 'light' }) 214 | .expect(200); 215 | assert.strictEqual(res10.body.userId, '123'); 216 | assert.strictEqual(res10.body.theme, 'light'); 217 | }); 218 | 219 | it('should propagate parameters correctly through nested routers', async () => { 220 | const app = new Koa(); 221 | const apiRouter = new Router({ prefix: '/api' }); 222 | const usersRouter = new Router({ prefix: '/users' }); 223 | const postsRouter = new Router({ prefix: '/:userId/posts' }); 224 | const commentsRouter = new Router({ prefix: '/:postId/comments' }); 225 | 226 | commentsRouter.get('/:commentId', async (ctx: RouterContext) => { 227 | ctx.body = { 228 | userId: ctx.params.userId, 229 | postId: ctx.params.postId, 230 | commentId: ctx.params.commentId, 231 | allParams: ctx.params 232 | }; 233 | }); 234 | 235 | postsRouter.use(commentsRouter.routes(), commentsRouter.allowedMethods()); 236 | usersRouter.use(postsRouter.routes(), postsRouter.allowedMethods()); 237 | apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods()); 238 | app.use(apiRouter.routes()); 239 | app.use(apiRouter.allowedMethods()); 240 | 241 | const server = http.createServer(app.callback()); 242 | 243 | const res = await request(server) 244 | .get('/api/users/user123/posts/post456/comments/comment789') 245 | .expect(200); 246 | 247 | assert.strictEqual(res.body.userId, 'user123'); 248 | assert.strictEqual(res.body.postId, 'post456'); 249 | assert.strictEqual(res.body.commentId, 'comment789'); 250 | assert.deepStrictEqual(res.body.allParams, { 251 | userId: 'user123', 252 | postId: 'post456', 253 | commentId: 'comment789' 254 | }); 255 | }); 256 | 257 | it('should handle multiple nested resources at the same level', async () => { 258 | const app = new Koa(); 259 | const apiRouter = new Router({ prefix: '/api' }); 260 | const usersRouter = new Router({ prefix: '/users' }); 261 | 262 | const postsRouter = new Router({ prefix: '/:userId/posts' }); 263 | const settingsRouter = new Router({ prefix: '/:userId/settings' }); 264 | const followersRouter = new Router({ prefix: '/:userId/followers' }); 265 | 266 | postsRouter.get('/', async (ctx: RouterContext) => { 267 | ctx.body = { userId: ctx.params.userId, resource: 'posts' }; 268 | }); 269 | 270 | settingsRouter.get('/', async (ctx: RouterContext) => { 271 | ctx.body = { userId: ctx.params.userId, resource: 'settings' }; 272 | }); 273 | 274 | followersRouter.get('/', async (ctx: RouterContext) => { 275 | ctx.body = { userId: ctx.params.userId, resource: 'followers' }; 276 | }); 277 | 278 | usersRouter.use(postsRouter.routes(), postsRouter.allowedMethods()); 279 | usersRouter.use(settingsRouter.routes(), settingsRouter.allowedMethods()); 280 | usersRouter.use(followersRouter.routes(), followersRouter.allowedMethods()); 281 | apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods()); 282 | app.use(apiRouter.routes()); 283 | app.use(apiRouter.allowedMethods()); 284 | 285 | const server = http.createServer(app.callback()); 286 | 287 | const res1 = await request(server).get('/api/users/123/posts').expect(200); 288 | assert.strictEqual(res1.body.resource, 'posts'); 289 | 290 | const res2 = await request(server) 291 | .get('/api/users/123/settings') 292 | .expect(200); 293 | assert.strictEqual(res2.body.resource, 'settings'); 294 | 295 | const res3 = await request(server) 296 | .get('/api/users/123/followers') 297 | .expect(200); 298 | assert.strictEqual(res3.body.resource, 'followers'); 299 | }); 300 | 301 | it('should handle 405 Method Not Allowed correctly for nested routes', async () => { 302 | const app = new Koa(); 303 | const apiRouter = new Router({ prefix: '/api' }); 304 | const usersRouter = new Router({ prefix: '/users' }); 305 | const postsRouter = new Router({ prefix: '/:userId/posts' }); 306 | 307 | postsRouter.get('/:postId', async (ctx: RouterContext) => { 308 | ctx.body = { id: ctx.params.postId }; 309 | }); 310 | 311 | usersRouter.use(postsRouter.routes(), postsRouter.allowedMethods()); 312 | apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods()); 313 | app.use(apiRouter.routes()); 314 | app.use(apiRouter.allowedMethods()); 315 | 316 | const server = http.createServer(app.callback()); 317 | 318 | await request(server).get('/api/users/123/posts/456').expect(200); 319 | 320 | await request(server).post('/api/users/123/posts/456').expect(405); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /test/utils/path-to-regexp-wrapper.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for path-to-regexp wrapper utilities 3 | */ 4 | 5 | import { describe, it } from 'node:test'; 6 | import assert from 'node:assert'; 7 | 8 | import { 9 | compilePathToRegexp, 10 | compilePath, 11 | parsePath, 12 | normalizeLayerOptionsToPathToRegexp, 13 | type Key 14 | } from '../../src/utils/path-to-regexp-wrapper'; 15 | import type { LayerOptions } from '../../src/types'; 16 | 17 | type ParsedToken = string | { name: string; [key: string]: unknown }; 18 | 19 | function getTokens( 20 | result: ReturnType 21 | ): (string | ParsedToken)[] { 22 | if (Array.isArray(result)) { 23 | return result as (string | ParsedToken)[]; 24 | } 25 | const resultObj = result as unknown as { tokens?: (string | ParsedToken)[] }; 26 | return resultObj.tokens || []; 27 | } 28 | 29 | function isNamedToken(token: unknown): token is { name: string } { 30 | return token !== null && typeof token === 'object' && 'name' in token; 31 | } 32 | 33 | describe('path-to-regexp-wrapper utilities', () => { 34 | describe('compilePathToRegexp()', () => { 35 | it('should compile a simple path without parameters', () => { 36 | const result = compilePathToRegexp('/users'); 37 | 38 | assert.strictEqual(result.regexp instanceof RegExp, true); 39 | assert.strictEqual(result.keys.length, 0); 40 | assert.strictEqual(result.regexp.test('/users'), true); 41 | assert.strictEqual(result.regexp.test('/users/123'), false); 42 | }); 43 | 44 | it('should compile a path with single parameter', () => { 45 | const result = compilePathToRegexp('/users/:id'); 46 | 47 | assert.strictEqual(result.regexp instanceof RegExp, true); 48 | assert.strictEqual(result.keys.length, 1); 49 | assert.strictEqual(result.keys[0].name, 'id'); 50 | assert.strictEqual(result.regexp.test('/users/123'), true); 51 | assert.strictEqual(result.regexp.test('/users'), false); 52 | }); 53 | 54 | it('should compile a path with multiple parameters', () => { 55 | const result = compilePathToRegexp('/users/:userId/posts/:postId'); 56 | 57 | assert.strictEqual(result.regexp instanceof RegExp, true); 58 | assert.strictEqual(result.keys.length, 2); 59 | assert.strictEqual(result.keys[0].name, 'userId'); 60 | assert.strictEqual(result.keys[1].name, 'postId'); 61 | assert.strictEqual(result.regexp.test('/users/123/posts/456'), true); 62 | }); 63 | 64 | it('should handle case-sensitive option', () => { 65 | const result1 = compilePathToRegexp('/Users', { sensitive: false }); 66 | const result2 = compilePathToRegexp('/Users', { sensitive: true }); 67 | 68 | assert.strictEqual(result1.regexp.test('/users'), true); 69 | assert.strictEqual(result2.regexp.test('/users'), false); 70 | assert.strictEqual(result2.regexp.test('/Users'), true); 71 | }); 72 | 73 | it('should handle strict/trailing option conversion', () => { 74 | const result1 = compilePathToRegexp('/users/', { strict: true }); 75 | assert.strictEqual(result1.regexp.test('/users/'), true); 76 | 77 | const result2 = compilePathToRegexp('/users/', { strict: false }); 78 | assert.strictEqual(result2.regexp.test('/users/'), true); 79 | }); 80 | 81 | it('should handle trailing option directly', () => { 82 | const result1 = compilePathToRegexp('/users/', { trailing: false }); 83 | assert.strictEqual(result1.regexp.test('/users/'), true); 84 | 85 | const result2 = compilePathToRegexp('/users/', { trailing: true }); 86 | assert.strictEqual(result2.regexp.test('/users/'), true); 87 | }); 88 | 89 | it('should handle end option', () => { 90 | const result1 = compilePathToRegexp('/users', { end: true }); 91 | assert.strictEqual(result1.regexp.test('/users'), true); 92 | assert.strictEqual(result1.regexp.test('/users/123'), false); 93 | 94 | const result2 = compilePathToRegexp('/users', { end: false }); 95 | assert.strictEqual(result2.regexp.test('/users'), true); 96 | assert.strictEqual(result2.regexp.test('/users/123'), true); 97 | }); 98 | 99 | it('should remove LayerOptions-specific properties', () => { 100 | const options: LayerOptions = { 101 | pathAsRegExp: true, 102 | ignoreCaptures: true, 103 | prefix: '/api', 104 | sensitive: true, 105 | end: true 106 | }; 107 | 108 | const result = compilePathToRegexp('/users/:id', options); 109 | assert.strictEqual(result.keys.length, 1); 110 | assert.strictEqual(result.keys[0].name, 'id'); 111 | }); 112 | 113 | it('should handle wildcard paths', () => { 114 | const result = compilePathToRegexp('/files/{/*path}'); 115 | 116 | assert.strictEqual(result.regexp instanceof RegExp, true); 117 | assert.strictEqual(result.regexp.test('/files/'), true); 118 | }); 119 | 120 | it('should handle root path', () => { 121 | const result = compilePathToRegexp('/'); 122 | 123 | assert.strictEqual(result.regexp instanceof RegExp, true); 124 | assert.strictEqual(result.keys.length, 0); 125 | assert.strictEqual(result.regexp.test('/'), true); 126 | }); 127 | 128 | it('should handle empty options object', () => { 129 | const result = compilePathToRegexp('/users/:id', {}); 130 | 131 | assert.strictEqual(result.regexp instanceof RegExp, true); 132 | assert.strictEqual(result.keys.length, 1); 133 | }); 134 | }); 135 | 136 | describe('compilePath()', () => { 137 | it('should compile a path to a URL generator function', () => { 138 | const urlGenerator = compilePath('/users/:id'); 139 | 140 | assert.strictEqual(typeof urlGenerator, 'function'); 141 | const url = urlGenerator({ id: '123' }); 142 | assert.strictEqual(url, '/users/123'); 143 | }); 144 | 145 | it('should handle multiple parameters', () => { 146 | const urlGenerator = compilePath('/users/:userId/posts/:postId'); 147 | 148 | const url = urlGenerator({ userId: '123', postId: '456' }); 149 | assert.strictEqual(url, '/users/123/posts/456'); 150 | }); 151 | 152 | it('should handle encode option', () => { 153 | const urlGenerator = compilePath('/users/:id', { 154 | encode: encodeURIComponent 155 | }); 156 | 157 | const url = urlGenerator({ id: 'user name' }); 158 | assert.strictEqual(url, '/users/user%20name'); 159 | }); 160 | 161 | it('should handle custom encode function', () => { 162 | const customEncode = (value: string) => value.toUpperCase(); 163 | const urlGenerator = compilePath('/users/:id', { encode: customEncode }); 164 | 165 | const url = urlGenerator({ id: 'test' }); 166 | assert.strictEqual(url, '/users/TEST'); 167 | }); 168 | 169 | it('should handle paths without parameters', () => { 170 | const urlGenerator = compilePath('/users'); 171 | 172 | const url = urlGenerator(); 173 | assert.strictEqual(url, '/users'); 174 | }); 175 | 176 | it('should handle optional parameters', () => { 177 | const urlGenerator = compilePath('/users{/:id}'); 178 | 179 | const url1 = urlGenerator({ id: '123' }); 180 | assert.strictEqual(url1, '/users/123'); 181 | 182 | const url2 = urlGenerator({}); 183 | assert.strictEqual(typeof url2, 'string'); 184 | assert.strictEqual(url2, '/users'); 185 | 186 | const url3 = urlGenerator(); 187 | assert.strictEqual(typeof url3, 'string'); 188 | assert.strictEqual(url3, '/users'); 189 | }); 190 | }); 191 | 192 | describe('parsePath()', () => { 193 | it('should parse a simple path', () => { 194 | const result = parsePath('/users'); 195 | const tokens = getTokens(result); 196 | assert.strictEqual(tokens.length > 0, true); 197 | }); 198 | 199 | it('should parse a path with parameters', () => { 200 | const result = parsePath('/users/:id'); 201 | const tokens = getTokens(result); 202 | assert.strictEqual(tokens.length > 0, true); 203 | const hasParam = tokens.some( 204 | (token) => isNamedToken(token) && token.name === 'id' 205 | ); 206 | assert.strictEqual(hasParam, true); 207 | }); 208 | 209 | it('should parse a path with multiple parameters', () => { 210 | const result = parsePath('/users/:userId/posts/:postId'); 211 | const tokens = getTokens(result); 212 | assert.strictEqual(tokens.length > 0, true); 213 | const userIdToken = tokens.find( 214 | (token) => isNamedToken(token) && token.name === 'userId' 215 | ); 216 | const postIdToken = tokens.find( 217 | (token) => isNamedToken(token) && token.name === 'postId' 218 | ); 219 | 220 | assert.strictEqual(userIdToken !== undefined, true); 221 | assert.strictEqual(postIdToken !== undefined, true); 222 | }); 223 | 224 | it('should parse wildcard paths', () => { 225 | const result = parsePath('/files/{/*path}'); 226 | const tokens = getTokens(result); 227 | assert.strictEqual(tokens.length > 0, true); 228 | }); 229 | 230 | it('should parse root path', () => { 231 | const result = parsePath('/'); 232 | const tokens = getTokens(result); 233 | assert.strictEqual(tokens.length > 0, true); 234 | }); 235 | 236 | it('should handle options parameter', () => { 237 | const result = parsePath('/users/:id', {}); 238 | const tokens = getTokens(result); 239 | assert.strictEqual(tokens.length > 0, true); 240 | }); 241 | }); 242 | 243 | describe('normalizeLayerOptionsToPathToRegexp()', () => { 244 | it('should normalize basic LayerOptions', () => { 245 | const options: LayerOptions = { 246 | sensitive: true, 247 | end: false, 248 | strict: true 249 | }; 250 | 251 | const normalized = normalizeLayerOptionsToPathToRegexp(options); 252 | 253 | assert.strictEqual(normalized.sensitive, true); 254 | assert.strictEqual(normalized.end, false); 255 | assert.strictEqual('strict' in normalized, true); 256 | assert.strictEqual(normalized.strict, true); 257 | }); 258 | 259 | it('should convert strict to trailing when trailing is not provided', () => { 260 | const options1: LayerOptions = { strict: true }; 261 | const normalized1 = normalizeLayerOptionsToPathToRegexp(options1); 262 | assert.strictEqual('strict' in normalized1, true); 263 | assert.strictEqual(normalized1.strict, true); 264 | 265 | const options2: LayerOptions = { strict: false }; 266 | const normalized2 = normalizeLayerOptionsToPathToRegexp(options2); 267 | assert.strictEqual('strict' in normalized2, true); 268 | assert.strictEqual(normalized2.strict, false); 269 | }); 270 | 271 | it('should preserve trailing when both strict and trailing are provided', () => { 272 | const options: LayerOptions = { 273 | strict: true, 274 | trailing: true 275 | }; 276 | 277 | const normalized = normalizeLayerOptionsToPathToRegexp(options); 278 | assert.strictEqual(normalized.trailing, true); 279 | }); 280 | 281 | it('should remove undefined values', () => { 282 | const options: LayerOptions = { 283 | sensitive: undefined, 284 | end: true, 285 | strict: undefined 286 | }; 287 | 288 | const normalized = normalizeLayerOptionsToPathToRegexp(options); 289 | assert.strictEqual('sensitive' in normalized, false); 290 | assert.strictEqual(normalized.end, true); 291 | assert.strictEqual('strict' in normalized, false); 292 | }); 293 | 294 | it('should handle empty options object', () => { 295 | const normalized = normalizeLayerOptionsToPathToRegexp({}); 296 | 297 | assert.strictEqual(typeof normalized, 'object'); 298 | assert.strictEqual(Object.keys(normalized).length, 0); 299 | }); 300 | 301 | it('should handle all LayerOptions properties', () => { 302 | const options: LayerOptions = { 303 | sensitive: true, 304 | strict: false, 305 | trailing: true, 306 | end: true, 307 | name: 'test-route', 308 | prefix: '/api', 309 | ignoreCaptures: true, 310 | pathAsRegExp: false 311 | }; 312 | 313 | const normalized = normalizeLayerOptionsToPathToRegexp(options); 314 | 315 | assert.strictEqual(normalized.sensitive, true); 316 | assert.strictEqual(normalized.trailing, true); 317 | assert.strictEqual(normalized.end, true); 318 | assert.strictEqual('name' in normalized, false); 319 | assert.strictEqual('prefix' in normalized, false); 320 | assert.strictEqual('ignoreCaptures' in normalized, false); 321 | assert.strictEqual('pathAsRegExp' in normalized, false); 322 | }); 323 | 324 | it('should handle undefined options', () => { 325 | const normalized = normalizeLayerOptionsToPathToRegexp(undefined); 326 | 327 | assert.strictEqual(typeof normalized, 'object'); 328 | assert.strictEqual(Object.keys(normalized).length, 0); 329 | }); 330 | }); 331 | 332 | describe('integration tests', () => { 333 | it('should work together: compilePathToRegexp + compilePath', () => { 334 | const path = '/users/:id'; 335 | 336 | const { regexp, keys } = compilePathToRegexp(path); 337 | assert.strictEqual(keys.length, 1); 338 | 339 | const urlGenerator = compilePath(path); 340 | const url = urlGenerator({ id: '123' }); 341 | 342 | assert.strictEqual(regexp.test(url), true); 343 | }); 344 | 345 | it('should work together: parsePath + compilePath', () => { 346 | const path = '/users/:userId/posts/:postId'; 347 | 348 | const parseResult = parsePath(path); 349 | const tokens = getTokens(parseResult); 350 | const paramNames = tokens 351 | .filter((token) => isNamedToken(token)) 352 | .map((token) => (token as { name: string }).name); 353 | 354 | assert.strictEqual(paramNames.includes('userId'), true); 355 | assert.strictEqual(paramNames.includes('postId'), true); 356 | 357 | const urlGenerator = compilePath(path); 358 | const url = urlGenerator({ userId: '123', postId: '456' }); 359 | 360 | assert.strictEqual(url, '/users/123/posts/456'); 361 | }); 362 | 363 | it('should handle complex path with all options', () => { 364 | const options: LayerOptions = { 365 | sensitive: true, 366 | strict: false, 367 | end: true 368 | }; 369 | 370 | const path = '/Users/:Id'; 371 | const { regexp, keys } = compilePathToRegexp(path, options); 372 | 373 | assert.strictEqual(keys.length, 1); 374 | assert.strictEqual(regexp.test('/Users/123'), true); 375 | assert.strictEqual(regexp.test('/users/123'), false); 376 | }); 377 | }); 378 | }); 379 | -------------------------------------------------------------------------------- /test/layer.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Route tests 3 | */ 4 | import { describe, it, before } from 'node:test'; 5 | import assert from 'node:assert'; 6 | import http from 'node:http'; 7 | 8 | import Koa from 'koa'; 9 | import request from 'supertest'; 10 | 11 | import Router, { RouterContext } from '../src'; 12 | import Layer from '../src/layer'; 13 | 14 | type TestState = { 15 | user?: { name: string }; 16 | [key: string]: unknown; 17 | }; 18 | 19 | type TestContext = RouterContext & { 20 | user?: { name: string }; 21 | captures?: string[]; 22 | }; 23 | 24 | describe('Layer', () => { 25 | it('composes multiple callbacks/middleware', async () => { 26 | const app = new Koa(); 27 | const router = new Router(); 28 | app.use(router.routes()); 29 | router.get( 30 | '/:category/:title', 31 | (ctx, next) => { 32 | ctx.status = 500; 33 | return next(); 34 | }, 35 | (ctx, next) => { 36 | ctx.status = 204; 37 | return next(); 38 | } 39 | ); 40 | 41 | await request(http.createServer(app.callback())) 42 | .get('/programming/how-to-node') 43 | .expect(204); 44 | }); 45 | 46 | describe('Layer#match()', () => { 47 | it('captures URL path parameters', async () => { 48 | const app = new Koa(); 49 | const router = new Router(); 50 | app.use(router.routes()); 51 | router.get('/:category/:title', (ctx) => { 52 | assert.strictEqual(typeof ctx.params, 'object'); 53 | assert.strictEqual(ctx.params.category, 'match'); 54 | assert.strictEqual(ctx.params.title, 'this'); 55 | ctx.status = 204; 56 | }); 57 | await request(http.createServer(app.callback())) 58 | .get('/match/this') 59 | .expect(204); 60 | }); 61 | 62 | it('return original path parameters when decodeURIComponent throw error', async () => { 63 | const app = new Koa(); 64 | const router = new Router(); 65 | app.use(router.routes()); 66 | router.get('/:category/:title', (ctx) => { 67 | assert.strictEqual(typeof ctx.params, 'object'); 68 | assert.strictEqual(ctx.params.category, '100%'); 69 | assert.strictEqual(ctx.params.title, '101%'); 70 | ctx.status = 204; 71 | }); 72 | await request(http.createServer(app.callback())) 73 | .get('/100%/101%') 74 | .expect(204); 75 | }); 76 | 77 | it('preserves plus signs in URL path parameters', async () => { 78 | const app = new Koa(); 79 | const router = new Router(); 80 | app.use(router.routes()); 81 | router.get('/users/:username', (ctx) => { 82 | assert.strictEqual(ctx.params.username, 'john+doe'); 83 | ctx.status = 200; 84 | ctx.body = { username: ctx.params.username }; 85 | }); 86 | const res = await request(http.createServer(app.callback())) 87 | .get('/users/john%2Bdoe') 88 | .expect(200); 89 | assert.strictEqual(res.body.username, 'john+doe'); 90 | }); 91 | 92 | it('populates ctx.captures with regexp captures', async () => { 93 | const app = new Koa(); 94 | const router = new Router(); 95 | app.use(router.routes()); 96 | router.get( 97 | /^\/api\/([^/]+)\/?/i, 98 | (ctx, next) => { 99 | assert.strictEqual(Array.isArray(ctx.captures), true); 100 | assert.strictEqual(ctx.captures?.[0], '1'); 101 | return next(); 102 | }, 103 | (ctx) => { 104 | assert.strictEqual(Array.isArray(ctx.captures), true); 105 | assert.strictEqual(ctx.captures?.[0], '1'); 106 | ctx.status = 204; 107 | } 108 | ); 109 | await request(http.createServer(app.callback())) 110 | .get('/api/1') 111 | .expect(204); 112 | }); 113 | 114 | it('return original ctx.captures when decodeURIComponent throw error', async () => { 115 | const app = new Koa(); 116 | const router = new Router(); 117 | app.use(router.routes()); 118 | router.get( 119 | /^\/api\/([^/]+)\/?/i, 120 | (ctx, next) => { 121 | assert.strictEqual(typeof ctx.captures, 'object'); 122 | assert.strictEqual(ctx.captures?.[0], '101%'); 123 | return next(); 124 | }, 125 | (ctx) => { 126 | assert.strictEqual(typeof ctx.captures, 'object'); 127 | assert.strictEqual(ctx.captures?.[0], '101%'); 128 | ctx.status = 204; 129 | } 130 | ); 131 | await request(http.createServer(app.callback())) 132 | .get('/api/101%') 133 | .expect(204); 134 | }); 135 | 136 | it('populates ctx.captures with regexp captures include undefined', async () => { 137 | const app = new Koa(); 138 | const router = new Router(); 139 | app.use(router.routes()); 140 | router.get( 141 | /^\/api(\/.+)?/i, 142 | (ctx, next) => { 143 | assert.strictEqual(typeof ctx.captures, 'object'); 144 | assert.strictEqual(ctx.captures?.[0], undefined); 145 | return next(); 146 | }, 147 | (ctx) => { 148 | assert.strictEqual(typeof ctx.captures, 'object'); 149 | assert.strictEqual(ctx.captures?.[0], undefined); 150 | ctx.status = 204; 151 | } 152 | ); 153 | await request(http.createServer(app.callback())).get('/api').expect(204); 154 | }); 155 | 156 | it('should throw friendly error message when handle not exists', () => { 157 | const app = new Koa(); 158 | const router = new Router(); 159 | app.use(router.routes()); 160 | const notexistHandle = undefined; 161 | assert.throws( 162 | // @ts-expect-error - testing invalid input 163 | () => router.get('/foo', notexistHandle), 164 | new Error( 165 | 'get `/foo`: `middleware` must be a function, not `undefined`' 166 | ) 167 | ); 168 | 169 | assert.throws( 170 | // @ts-expect-error - testing invalid input 171 | () => router.get('foo router', '/foo', notexistHandle), 172 | new Error( 173 | 'get `foo router`: `middleware` must be a function, not `undefined`' 174 | ) 175 | ); 176 | 177 | assert.throws( 178 | // @ts-expect-error - testing invalid input 179 | () => router.post('/foo', () => {}, notexistHandle), 180 | new Error( 181 | 'post `/foo`: `middleware` must be a function, not `undefined`' 182 | ) 183 | ); 184 | }); 185 | }); 186 | 187 | describe('Layer#param()', () => { 188 | it('composes middleware for param fn', async () => { 189 | const app = new Koa(); 190 | const router = new Router(); 191 | const route = new Layer( 192 | '/users/:user', 193 | ['GET'], 194 | [ 195 | (ctx) => { 196 | ctx.body = ctx.user; 197 | } 198 | ] 199 | ); 200 | route.param('user', (id, ctx, next) => { 201 | ctx.user = { name: 'alex' }; 202 | if (!id) { 203 | ctx.status = 404; 204 | return; 205 | } 206 | 207 | return next(); 208 | }); 209 | router.stack.push(route); 210 | app.use(router.middleware()); 211 | const res = await request(http.createServer(app.callback())) 212 | .get('/users/3') 213 | .expect(200); 214 | assert.strictEqual(res.body.name, 'alex'); 215 | }); 216 | 217 | it('ignores params which are not matched', async () => { 218 | const app = new Koa(); 219 | const router = new Router(); 220 | const route = new Layer( 221 | '/users/:user', 222 | ['GET'], 223 | [ 224 | (ctx) => { 225 | ctx.body = ctx.user; 226 | } 227 | ] 228 | ); 229 | route.param('user', (id, ctx, next) => { 230 | ctx.user = { name: 'alex' }; 231 | if (!id) { 232 | ctx.status = 404; 233 | return; 234 | } 235 | 236 | return next(); 237 | }); 238 | route.param('title', (id, ctx, next) => { 239 | ctx.user = { name: 'mark' }; 240 | if (!id) { 241 | ctx.status = 404; 242 | return; 243 | } 244 | 245 | return next(); 246 | }); 247 | router.stack.push(route); 248 | app.use(router.middleware()); 249 | const res = await request(http.createServer(app.callback())) 250 | .get('/users/3') 251 | .expect(200); 252 | 253 | assert.strictEqual(res.body.name, 'alex'); 254 | }); 255 | }); 256 | 257 | describe('Layer#params()', () => { 258 | let route: Layer; 259 | 260 | before(() => { 261 | route = new Layer('/:category', ['GET'], [() => {}]); 262 | }); 263 | 264 | it('should return an empty object if params were not pass', () => { 265 | const params = route.params('', []); 266 | 267 | assert.deepStrictEqual(params, {}); 268 | }); 269 | 270 | it('should return empty object if params is empty string', () => { 271 | const params = route.params('', ['']); 272 | 273 | assert.deepStrictEqual(params, {}); 274 | }); 275 | 276 | it('should return an object with escaped params', () => { 277 | const params = route.params('', ['how%20to%20node']); 278 | 279 | assert.deepStrictEqual(params, { category: 'how to node' }); 280 | }); 281 | 282 | it('should preserve plus signs in path parameters (not convert to spaces)', () => { 283 | const route = new Layer('/users/:username', ['GET'], [() => {}]); 284 | const params = route.params('', ['john%2Bdoe']); 285 | 286 | assert.deepStrictEqual(params, { username: 'john+doe' }); 287 | }); 288 | 289 | it('should return an object with the same params if an error occurs', () => { 290 | const params = route.params('', ['%E0%A4%A']); 291 | 292 | assert.deepStrictEqual(params, { category: '%E0%A4%A' }); 293 | }); 294 | 295 | it('should return an object with data if params were pass', () => { 296 | const params = route.params('', ['programming']); 297 | 298 | assert.deepStrictEqual(params, { category: 'programming' }); 299 | }); 300 | 301 | it('should return empty object if params were not pass', () => { 302 | route.paramNames = []; 303 | const params = route.params('', ['programming']); 304 | 305 | assert.deepStrictEqual(params, {}); 306 | }); 307 | }); 308 | 309 | describe('Layer#url()', () => { 310 | it('generates route URL', () => { 311 | const route = new Layer('/:category/:title', ['get'], [() => {}], { 312 | name: 'books' 313 | }); 314 | let url = route.url({ category: 'programming', title: 'how-to-node' }); 315 | assert.strictEqual(url, '/programming/how-to-node'); 316 | url = route.url('programming', 'how-to-node'); 317 | assert.strictEqual(url, '/programming/how-to-node'); 318 | }); 319 | 320 | it('escapes using encodeURIComponent()', () => { 321 | const route = new Layer('/:category/:title', ['get'], [() => {}], { 322 | name: 'books' 323 | }); 324 | const url = route.url({ 325 | category: 'programming', 326 | title: 'how to node & js/ts' 327 | }); 328 | assert.strictEqual(url, '/programming/how%20to%20node%20%26%20js%2Fts'); 329 | }); 330 | 331 | it('setPrefix method checks Layer for path', () => { 332 | const route = new Layer('/category', ['get'], [() => {}], { 333 | name: 'books' 334 | }); 335 | route.path = '/hunter2'; 336 | const prefix = route.setPrefix('TEST'); 337 | assert.strictEqual(prefix.path, 'TEST/hunter2'); 338 | }); 339 | 340 | it('should throw TypeError when attempting to generate URL for RegExp path', () => { 341 | const route = new Layer(/\/users\/\d+/, ['GET'], [() => {}], { 342 | pathAsRegExp: true 343 | }); 344 | 345 | assert.throws( 346 | () => route.url({ id: 123 }), 347 | /Cannot generate URL for routes defined with RegExp paths/ 348 | ); 349 | }); 350 | 351 | it('should generate URL correctly for string path with named parameters', () => { 352 | const route = new Layer('/users/:id', ['GET'], [() => {}]); 353 | const url = route.url({ id: 123 }); 354 | assert.strictEqual(url, '/users/123'); 355 | }); 356 | }); 357 | 358 | describe('Layer#prefix', () => { 359 | it('setPrefix method passes check Layer for path', () => { 360 | const route = new Layer('/category', ['get'], [() => {}], { 361 | name: 'books' 362 | }); 363 | route.path = '/hunter2'; 364 | const prefix = route.setPrefix('/TEST'); 365 | assert.strictEqual(prefix.path, '/TEST/hunter2'); 366 | }); 367 | 368 | it('setPrefix method fails check Layer for path', () => { 369 | // @ts-expect-error - testing invalid input 370 | const route = new Layer(false, ['get'], [() => {}], { 371 | name: 'books' 372 | }); 373 | // @ts-expect-error - testing invalid input 374 | route.path = false; 375 | const prefix = route.setPrefix('/TEST'); 376 | assert.strictEqual(prefix.path, false); 377 | }); 378 | }); 379 | 380 | describe('Layer#_reconfigurePathMatching()', () => { 381 | it('should use path-to-regexp when prefix has parameters and pathAsRegExp is true', async () => { 382 | const app = new Koa(); 383 | const router = new Router(); 384 | app.use(router.routes()); 385 | 386 | const route = new Layer( 387 | '/users/:id', 388 | ['GET'], 389 | [ 390 | (ctx) => { 391 | ctx.body = { userId: ctx.params.id }; 392 | } 393 | ], 394 | { 395 | pathAsRegExp: true 396 | } 397 | ); 398 | 399 | route.setPrefix('/api/:version'); 400 | 401 | router.stack.push(route); 402 | 403 | const res = await request(http.createServer(app.callback())) 404 | .get('/api/v1/users/123') 405 | .expect(200); 406 | 407 | assert.strictEqual(res.body.userId, '123'); 408 | assert.strictEqual(route.opts.pathAsRegExp, false); 409 | }); 410 | 411 | it('should handle RegExp path when pathAsRegExp is true and prefix has no parameters', () => { 412 | const route = new Layer('/api/users/\\d+', ['GET'], [() => {}], { 413 | pathAsRegExp: true 414 | }); 415 | 416 | route.setPrefix('/v1'); 417 | 418 | assert.strictEqual(route.regexp instanceof RegExp, true); 419 | }); 420 | }); 421 | 422 | describe('Layer#captures()', () => { 423 | it('should return empty array when regexp does not match', () => { 424 | const route = new Layer('/api/users/:id', ['GET'], [() => {}]); 425 | 426 | route.regexp = /^\/api\/users\/\d+$/; 427 | 428 | const captures = route.captures('/api/users/abc'); 429 | 430 | assert.deepStrictEqual(captures, []); 431 | }); 432 | }); 433 | }); 434 | -------------------------------------------------------------------------------- /src/layer.ts: -------------------------------------------------------------------------------- 1 | import type { UrlObject } from 'node:url'; 2 | import { parse as parseUrl, format as formatUrl } from 'node:url'; 3 | import type { 4 | DefaultState, 5 | DefaultContext, 6 | LayerOptions, 7 | RouterMiddleware, 8 | RouterParameterMiddleware, 9 | UrlOptions 10 | } from './types'; 11 | import type { Key } from './utils/path-to-regexp-wrapper'; 12 | import { 13 | compilePathToRegexp, 14 | compilePath, 15 | parsePath, 16 | normalizeLayerOptionsToPathToRegexp 17 | } from './utils/path-to-regexp-wrapper'; 18 | import { safeDecodeURIComponent } from './utils/safe-decode-uri-components'; 19 | 20 | /** 21 | * Extended middleware with param metadata 22 | * @internal 23 | */ 24 | type ParameterMiddleware< 25 | StateT = DefaultState, 26 | ContextT = DefaultContext, 27 | BodyT = unknown 28 | > = RouterMiddleware & { 29 | param?: string; 30 | _originalFn?: RouterParameterMiddleware; 31 | }; 32 | 33 | /** 34 | * Layer class represents a single route or middleware layer. 35 | * It handles path matching, parameter extraction, and middleware execution. 36 | * 37 | * @typeParam StateT - Custom state type extending Koa's DefaultState 38 | * @typeParam ContextT - Custom context type extending Koa's DefaultContext 39 | * @typeParam BodyT - Response body type 40 | */ 41 | export default class Layer< 42 | StateT = DefaultState, 43 | ContextT = DefaultContext, 44 | BodyT = unknown 45 | > { 46 | opts: LayerOptions; 47 | name: string | undefined; 48 | methods: string[]; 49 | paramNames: Key[]; 50 | stack: Array< 51 | | RouterMiddleware 52 | | ParameterMiddleware 53 | >; 54 | path: string | RegExp; 55 | regexp!: RegExp; 56 | 57 | /** 58 | * Initialize a new routing Layer with given `method`, `path`, and `middleware`. 59 | * 60 | * @param path - Path string or regular expression 61 | * @param methods - Array of HTTP verbs 62 | * @param middleware - Layer callback/middleware or series of 63 | * @param opts - Layer options 64 | * @private 65 | */ 66 | constructor( 67 | path: string | RegExp, 68 | methods: string[], 69 | middleware: 70 | | RouterMiddleware 71 | | Array>, 72 | options: LayerOptions = {} 73 | ) { 74 | this.opts = options; 75 | this.name = this.opts.name || undefined; 76 | 77 | this.methods = this._normalizeHttpMethods(methods); 78 | 79 | this.stack = this._normalizeAndValidateMiddleware( 80 | middleware, 81 | methods, 82 | path 83 | ); 84 | 85 | this.path = path; 86 | this.paramNames = []; 87 | this._configurePathMatching(); 88 | } 89 | 90 | /** 91 | * Normalize HTTP methods and add automatic HEAD support for GET 92 | * @private 93 | */ 94 | private _normalizeHttpMethods(methods: string[]): string[] { 95 | const normalizedMethods: string[] = []; 96 | 97 | for (const method of methods) { 98 | const upperMethod = method.toUpperCase(); 99 | normalizedMethods.push(upperMethod); 100 | 101 | if (upperMethod === 'GET') { 102 | normalizedMethods.unshift('HEAD'); 103 | } 104 | } 105 | 106 | return normalizedMethods; 107 | } 108 | 109 | /** 110 | * Normalize middleware to array and validate all are functions 111 | * @private 112 | */ 113 | private _normalizeAndValidateMiddleware( 114 | middleware: 115 | | RouterMiddleware 116 | | Array>, 117 | methods: string[], 118 | path: string | RegExp 119 | ): Array> { 120 | const middlewareArray = Array.isArray(middleware) 121 | ? middleware 122 | : [middleware]; 123 | 124 | for (const middlewareFunction of middlewareArray) { 125 | const middlewareType = typeof middlewareFunction; 126 | 127 | if (middlewareType !== 'function') { 128 | const routeIdentifier = this.opts.name || path; 129 | throw new Error( 130 | `${methods.toString()} \`${routeIdentifier}\`: \`middleware\` must be a function, not \`${middlewareType}\`` 131 | ); 132 | } 133 | } 134 | 135 | return middlewareArray; 136 | } 137 | 138 | /** 139 | * Configure path matching regexp and parameters 140 | * @private 141 | */ 142 | private _configurePathMatching(): void { 143 | if (this.opts.pathAsRegExp === true) { 144 | this.regexp = 145 | this.path instanceof RegExp 146 | ? this.path 147 | : new RegExp(this.path as string); 148 | } else if (this.path) { 149 | this._configurePathToRegexp(); 150 | } 151 | } 152 | 153 | /** 154 | * Configure path-to-regexp for string paths 155 | * @private 156 | */ 157 | private _configurePathToRegexp(): void { 158 | const options = normalizeLayerOptionsToPathToRegexp(this.opts); 159 | const { regexp, keys } = compilePathToRegexp(this.path as string, options); 160 | this.regexp = regexp; 161 | this.paramNames = keys; 162 | } 163 | 164 | /** 165 | * Returns whether request `path` matches route. 166 | * 167 | * @param path - Request path 168 | * @returns Whether path matches 169 | * @private 170 | */ 171 | match(path: string): boolean { 172 | return this.regexp.test(path); 173 | } 174 | 175 | /** 176 | * Returns map of URL parameters for given `path` and `paramNames`. 177 | * 178 | * @param _path - Request path (not used, kept for API compatibility) 179 | * @param captures - Captured values from regexp 180 | * @param existingParams - Existing params to merge with 181 | * @returns Parameter map 182 | * @private 183 | */ 184 | params( 185 | _path: string, 186 | captures: string[], 187 | existingParameters: Record = {} 188 | ): Record { 189 | const parameterValues = { ...existingParameters }; 190 | 191 | for (const [captureIndex, capturedValue] of captures.entries()) { 192 | const parameterDefinition = this.paramNames[captureIndex]; 193 | 194 | if (parameterDefinition && capturedValue && capturedValue.length > 0) { 195 | const parameterName = parameterDefinition.name; 196 | parameterValues[parameterName] = safeDecodeURIComponent(capturedValue); 197 | } 198 | } 199 | 200 | return parameterValues; 201 | } 202 | 203 | /** 204 | * Returns array of regexp url path captures. 205 | * 206 | * @param path - Request path 207 | * @returns Array of captured values 208 | * @private 209 | */ 210 | captures(path: string): string[] { 211 | if (this.opts.ignoreCaptures) { 212 | return []; 213 | } 214 | 215 | const match = path.match(this.regexp); 216 | return match ? match.slice(1) : []; 217 | } 218 | 219 | /** 220 | * Generate URL for route using given `params`. 221 | * 222 | * @example 223 | * 224 | * ```javascript 225 | * const route = new Layer('/users/:id', ['GET'], fn); 226 | * 227 | * route.url({ id: 123 }); // => "/users/123" 228 | * ``` 229 | * 230 | * @param args - URL parameters (various formats supported) 231 | * @returns Generated URL 232 | * @throws Error if route path is a RegExp (cannot generate URL from RegExp) 233 | * @private 234 | */ 235 | url(...arguments_: unknown[]): string { 236 | if (this.path instanceof RegExp) { 237 | throw new TypeError( 238 | 'Cannot generate URL for routes defined with RegExp paths. Use string paths with named parameters instead.' 239 | ); 240 | } 241 | 242 | const { params, options } = this._parseUrlArguments(arguments_); 243 | 244 | const cleanPath = this.path.replaceAll('(.*)', ''); 245 | 246 | const pathCompiler = compilePath(cleanPath, { 247 | encode: encodeURIComponent, 248 | ...options 249 | }); 250 | 251 | const parameterReplacements = this._buildParamReplacements( 252 | params, 253 | cleanPath 254 | ); 255 | 256 | const generatedUrl = pathCompiler(parameterReplacements); 257 | 258 | if (options && options.query) { 259 | return this._addQueryString(generatedUrl, options.query); 260 | } 261 | 262 | return generatedUrl; 263 | } 264 | 265 | /** 266 | * Parse url() arguments into params and options 267 | * Supports multiple call signatures: 268 | * - url({ id: 1 }) 269 | * - url(1, 2, 3) 270 | * - url({ query: {...} }) 271 | * - url({ id: 1 }, { query: {...} }) 272 | * @private 273 | */ 274 | private _parseUrlArguments(allArguments: unknown[]): { 275 | params: Record | unknown[]; 276 | options?: UrlOptions; 277 | } { 278 | let parameters: Record | unknown[] = 279 | (allArguments[0] as Record) ?? {}; 280 | let options: UrlOptions | undefined = allArguments[1] as 281 | | UrlOptions 282 | | undefined; 283 | 284 | if (typeof parameters !== 'object' || parameters === null) { 285 | const argumentsList = [...allArguments]; 286 | const lastArgument = argumentsList.at(-1); 287 | 288 | if (typeof lastArgument === 'object' && lastArgument !== null) { 289 | options = lastArgument as UrlOptions; 290 | parameters = argumentsList.slice(0, -1); 291 | } else { 292 | parameters = argumentsList; 293 | } 294 | } else if (parameters && !options) { 295 | const parameterKeys = Object.keys(parameters); 296 | const isOnlyOptions = 297 | parameterKeys.length === 1 && parameterKeys[0] === 'query'; 298 | 299 | if (isOnlyOptions) { 300 | options = parameters as UrlOptions; 301 | parameters = {}; 302 | } else if ('query' in parameters && parameters.query) { 303 | const { query, ...restParameters } = parameters; 304 | options = { query: query as Record | string }; 305 | parameters = restParameters; 306 | } 307 | } 308 | 309 | return { params: parameters, options }; 310 | } 311 | 312 | /** 313 | * Build parameter replacements for URL generation 314 | * @private 315 | */ 316 | private _buildParamReplacements( 317 | parameters: Record | unknown[], 318 | cleanPath: string 319 | ): Record { 320 | const { tokens } = parsePath(cleanPath); 321 | const hasNamedParameters = tokens.some( 322 | (token) => 'name' in token && token.name 323 | ); 324 | const parameterReplacements: Record = {}; 325 | 326 | if (Array.isArray(parameters)) { 327 | let parameterIndex = 0; 328 | 329 | for (const token of tokens) { 330 | if ('name' in token && token.name) { 331 | parameterReplacements[token.name] = String( 332 | parameters[parameterIndex++] 333 | ); 334 | } 335 | } 336 | } else if ( 337 | hasNamedParameters && 338 | typeof parameters === 'object' && 339 | !('query' in parameters) 340 | ) { 341 | for (const [parameterName, parameterValue] of Object.entries( 342 | parameters 343 | )) { 344 | parameterReplacements[parameterName] = String(parameterValue); 345 | } 346 | } 347 | 348 | return parameterReplacements; 349 | } 350 | 351 | /** 352 | * Add query string to URL 353 | * @private 354 | */ 355 | private _addQueryString( 356 | baseUrl: string, 357 | query: Record | string 358 | ): string { 359 | const parsed = parseUrl(baseUrl); 360 | const urlObject: UrlObject = { 361 | ...parsed, 362 | query: parsed.query ?? undefined 363 | }; 364 | 365 | if (typeof query === 'string') { 366 | urlObject.search = query; 367 | urlObject.query = undefined; 368 | } else { 369 | urlObject.search = undefined; 370 | urlObject.query = query as Record< 371 | string, 372 | | string 373 | | number 374 | | boolean 375 | | readonly string[] 376 | | readonly number[] 377 | | readonly boolean[] 378 | | null 379 | >; 380 | } 381 | 382 | return formatUrl(urlObject); 383 | } 384 | 385 | /** 386 | * Run validations on route named parameters. 387 | * 388 | * @example 389 | * 390 | * ```javascript 391 | * router 392 | * .param('user', function (id, ctx, next) { 393 | * ctx.user = users[id]; 394 | * if (!ctx.user) return ctx.status = 404; 395 | * next(); 396 | * }) 397 | * .get('/users/:user', function (ctx, next) { 398 | * ctx.body = ctx.user; 399 | * }); 400 | * ``` 401 | * 402 | * @param paramName - Parameter name 403 | * @param paramHandler - Middleware function 404 | * @returns This layer instance 405 | * @private 406 | */ 407 | param( 408 | parameterName: string, 409 | parameterHandler: RouterParameterMiddleware 410 | ): Layer { 411 | const middlewareStack = this.stack; 412 | const routeParameterNames = this.paramNames; 413 | 414 | const parameterMiddleware = this._createParamMiddleware( 415 | parameterName, 416 | parameterHandler 417 | ); 418 | 419 | const parameterNamesList = routeParameterNames.map( 420 | (parameterDefinition) => parameterDefinition.name 421 | ); 422 | 423 | const parameterPosition = parameterNamesList.indexOf(parameterName); 424 | 425 | if (parameterPosition !== -1) { 426 | this._insertParamMiddleware( 427 | middlewareStack, 428 | parameterMiddleware, 429 | parameterNamesList, 430 | parameterPosition 431 | ); 432 | } 433 | 434 | return this; 435 | } 436 | 437 | /** 438 | * Create param middleware with deduplication tracking 439 | * @private 440 | */ 441 | private _createParamMiddleware( 442 | parameterName: string, 443 | parameterHandler: RouterParameterMiddleware 444 | ): ParameterMiddleware { 445 | // Create a wrapper middleware that handles param deduplication 446 | const middleware = (( 447 | context: Parameters>[0] & { 448 | params: Record; 449 | _matchedParams?: WeakMap; 450 | }, 451 | next: Parameters>[1] 452 | ) => { 453 | if (!context._matchedParams) { 454 | context._matchedParams = new WeakMap(); 455 | } 456 | 457 | if (context._matchedParams.has(parameterHandler)) { 458 | return next(); 459 | } 460 | 461 | context._matchedParams.set(parameterHandler, true); 462 | 463 | return parameterHandler(context.params[parameterName], context, next); 464 | }) as ParameterMiddleware; 465 | 466 | middleware.param = parameterName; 467 | middleware._originalFn = parameterHandler; 468 | 469 | return middleware; 470 | } 471 | 472 | /** 473 | * Insert param middleware at the correct position in the stack 474 | * @private 475 | */ 476 | private _insertParamMiddleware( 477 | middlewareStack: Array< 478 | | RouterMiddleware 479 | | ParameterMiddleware 480 | >, 481 | parameterMiddleware: ParameterMiddleware, 482 | parameterNamesList: string[], 483 | currentParameterPosition: number 484 | ): void { 485 | let inserted = false; 486 | 487 | for ( 488 | let stackIndex = 0; 489 | stackIndex < middlewareStack.length; 490 | stackIndex++ 491 | ) { 492 | const existingMiddleware = middlewareStack[ 493 | stackIndex 494 | ] as ParameterMiddleware; 495 | 496 | if (!existingMiddleware.param) { 497 | // Insert before first non-param middleware 498 | middlewareStack.splice(stackIndex, 0, parameterMiddleware); 499 | inserted = true; 500 | break; 501 | } 502 | 503 | const existingParameterPosition = parameterNamesList.indexOf( 504 | existingMiddleware.param 505 | ); 506 | if (existingParameterPosition > currentParameterPosition) { 507 | // Insert before param middleware that comes later in the URL 508 | middlewareStack.splice(stackIndex, 0, parameterMiddleware); 509 | inserted = true; 510 | break; 511 | } 512 | } 513 | 514 | // If not inserted yet, append to the end of the stack 515 | if (!inserted) { 516 | middlewareStack.push(parameterMiddleware); 517 | } 518 | } 519 | 520 | /** 521 | * Prefix route path. 522 | * 523 | * @param prefixPath - Prefix to prepend 524 | * @returns This layer instance 525 | * @private 526 | */ 527 | setPrefix(prefixPath: string): Layer { 528 | if (!this.path) { 529 | return this; 530 | } 531 | 532 | if (this.path instanceof RegExp) { 533 | return this; 534 | } 535 | 536 | this.path = this._applyPrefix(prefixPath); 537 | 538 | this._reconfigurePathMatching(prefixPath); 539 | 540 | return this; 541 | } 542 | 543 | /** 544 | * Apply prefix to the current path 545 | * @private 546 | */ 547 | private _applyPrefix(prefixPath: string): string { 548 | const isRootPath = this.path === '/'; 549 | const isStrictMode = this.opts.strict === true; 550 | const prefixHasParameters = prefixPath.includes(':'); 551 | const pathIsRawRegex = 552 | this.opts.pathAsRegExp === true && typeof this.path === 'string'; 553 | 554 | if (prefixHasParameters && pathIsRawRegex) { 555 | const currentPath = this.path as string; 556 | if ( 557 | currentPath === String.raw`(?:\/|$)` || 558 | currentPath === String.raw`(?:\\\/|$)` 559 | ) { 560 | this.path = '{/*rest}'; 561 | this.opts.pathAsRegExp = false; 562 | } 563 | } 564 | 565 | if (isRootPath && !isStrictMode) { 566 | return prefixPath; 567 | } 568 | 569 | return `${prefixPath}${this.path}`; 570 | } 571 | 572 | /** 573 | * Reconfigure path matching after prefix is applied 574 | * @private 575 | */ 576 | private _reconfigurePathMatching(prefixPath: string): void { 577 | const treatAsRegExp = this.opts.pathAsRegExp === true; 578 | const prefixHasParameters = prefixPath && prefixPath.includes(':'); 579 | 580 | if (prefixHasParameters && treatAsRegExp) { 581 | const options = normalizeLayerOptionsToPathToRegexp(this.opts); 582 | const { regexp, keys } = compilePathToRegexp( 583 | this.path as string, 584 | options 585 | ); 586 | this.regexp = regexp; 587 | this.paramNames = keys; 588 | this.opts.pathAsRegExp = false; 589 | } else if (treatAsRegExp) { 590 | const pathString = this.path as string; 591 | const anchoredPattern = pathString.startsWith('^') 592 | ? pathString 593 | : `^${pathString}`; 594 | this.regexp = 595 | this.path instanceof RegExp ? this.path : new RegExp(anchoredPattern); 596 | } else { 597 | const options = normalizeLayerOptionsToPathToRegexp(this.opts); 598 | const { regexp, keys } = compilePathToRegexp( 599 | this.path as string, 600 | options 601 | ); 602 | this.regexp = regexp; 603 | this.paramNames = keys; 604 | } 605 | } 606 | } 607 | --------------------------------------------------------------------------------