@koa/router 15.3.2 → 15.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,10 +192,15 @@ import {
192
192
  RouterOptions,
193
193
  RouterMiddleware,
194
194
  RouterParameterMiddleware,
195
- RouterParamContext,
196
195
  AllowedMethodsOptions,
197
196
  UrlOptions,
198
- HttpMethod
197
+ HttpMethod,
198
+ MatchResult,
199
+ LayerOptions,
200
+ Layer,
201
+ RouterEvent,
202
+ RouterEventSelector,
203
+ RouterEvents
199
204
  } from '@koa/router';
200
205
  import type { Next } from 'koa';
201
206
 
@@ -261,14 +266,14 @@ Create a new router instance.
261
266
 
262
267
  **Options:**
263
268
 
264
- | Option | Type | Description |
265
- | ----------- | ------------------------------ | ----------------------------------------- |
266
- | `prefix` | `string` | Prefix all routes with this path |
267
- | `exclusive` | `boolean` | Only run the most specific matching route |
268
- | `host` | `string \| string[] \| RegExp` | Match routes only for this hostname(s) |
269
- | `methods` | `string[]` | Custom HTTP methods to support |
270
- | `sensitive` | `boolean` | Enable case-sensitive routing |
271
- | `strict` | `boolean` | Require trailing slashes |
269
+ | Option | Type | Description |
270
+ | ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
271
+ | `prefix` | `string` | Prefix all routes with this path |
272
+ | `exclusive` | `boolean \| 'specificity'` | `true`: only run the last-registered matching route. `'specificity'`: only run the route with the fewest path parameters (OpenAPI-compliant). Omit to run all matching routes. |
273
+ | `host` | `string \| string[] \| RegExp` | Match routes only for this hostname(s) |
274
+ | `methods` | `string[]` | Custom HTTP methods to support |
275
+ | `sensitive` | `boolean` | Enable case-sensitive routing |
276
+ | `strict` | `boolean` | Require trailing slashes |
272
277
 
273
278
  **Example:**
274
279
 
@@ -740,6 +745,56 @@ const url = Router.url('/users/:id', { id: 1, name: 'John' });
740
745
  // => "/users/1"
741
746
  ```
742
747
 
748
+ ### router.on() (experimental)
749
+
750
+ Register an event handler on the router for lifecycle events.
751
+
752
+ **Signature:**
753
+
754
+ ```typescript
755
+ router.on(event: RouterEventSelector, handler: RouterMiddleware): Router
756
+ ```
757
+
758
+ **Parameters:**
759
+
760
+ | Parameter | Type | Description |
761
+ | --------- | --------------------- | ---------------------------------------------------------------- |
762
+ | `event` | `RouterEventSelector` | Event name string, `RouterEvents` constant, or selector function |
763
+ | `handler` | `RouterMiddleware` | Middleware function `(ctx, next) => {}` |
764
+
765
+ **Returns:** Router instance for chaining.
766
+
767
+ **Active events:**
768
+
769
+ | Event | Constant | Description |
770
+ | ------------- | ----------------------- | ----------------------------------------- |
771
+ | `'not-found'` | `RouterEvents.NotFound` | Fires when no route matched path + method |
772
+
773
+ **Example:**
774
+
775
+ ```javascript
776
+ import { RouterEvents } from '@koa/router';
777
+
778
+ // All three forms are equivalent:
779
+ router.on(RouterEvents.NotFound, handler); // constant (recommended)
780
+ router.on((events) => events.NotFound, handler); // selector function
781
+ router.on('not-found', handler); // raw string
782
+
783
+ // Handlers can be chained:
784
+ router
785
+ .on(RouterEvents.NotFound, async (ctx, next) => {
786
+ console.log('no route matched:', ctx.path);
787
+ await next();
788
+ })
789
+ .on(RouterEvents.NotFound, (ctx) => {
790
+ ctx.status = 404;
791
+ ctx.body = { error: 'Not Found' };
792
+ });
793
+ ```
794
+
795
+ > **Note:** This API is experimental and may change in future versions. See the
796
+ > [Not Found Event](#not-found-event-experimental) section in Advanced Features for details.
797
+
743
798
  ## Advanced Features
744
799
 
745
800
  ### Host Matching
@@ -842,7 +897,8 @@ router.get('/post/:id', handler);
842
897
 
843
898
  ### Catch-All Routes
844
899
 
845
- Create a catch-all route that only runs when no other routes match:
900
+ Create a catch-all route that runs when no specific route handler has set a response.
901
+ Check `!ctx.body` to determine whether a previous handler already responded:
846
902
 
847
903
  ```javascript
848
904
  router.get('/users', handler1);
@@ -850,13 +906,38 @@ router.get('/posts', handler2);
850
906
 
851
907
  // Catch-all for unmatched routes
852
908
  router.all('{/*rest}', (ctx) => {
853
- if (!ctx.matched || ctx.matched.length === 0) {
909
+ if (!ctx.body) {
854
910
  ctx.status = 404;
855
911
  ctx.body = { error: 'Not Found' };
856
912
  }
857
913
  });
858
914
  ```
859
915
 
916
+ > **Note:** `ctx.matched.length === 0` will never be true inside a catch-all handler.
917
+ > The router populates `ctx.matched` **before** the handler body runs, so the catch-all
918
+ > layer (`{/*rest}`) is already included in the array by the time your code executes.
919
+ >
920
+ > **Recommended:** Use `!ctx.body` as shown above — it is the simplest and most reliable
921
+ > condition inside a catch-all route.
922
+ >
923
+ > **Workaround:** If you specifically need `ctx.matched`-based logic inside a catch-all,
924
+ > filter out the catch-all layer first and check the remaining array:
925
+ >
926
+ > ```javascript
927
+ > router.all('{/*rest}', (ctx) => {
928
+ > const realMatches = ctx.matched.filter(
929
+ > (layer) => layer.path !== '{/*rest}'
930
+ > );
931
+ > if (realMatches.length === 0) {
932
+ > ctx.status = 404;
933
+ > ctx.body = { error: 'Not Found' };
934
+ > }
935
+ > });
936
+ > ```
937
+ >
938
+ > For cleaner and more precise 404 detection, prefer app-level middleware with
939
+ > `ctx.routeMatched` — see the [404 Handling](#404-handling) section.
940
+
860
941
  ### Array of Paths
861
942
 
862
943
  Register multiple paths with the same middleware:
@@ -868,12 +949,30 @@ router.get(['/users', '/people'], handler);
868
949
 
869
950
  ### 404 Handling
870
951
 
871
- Implement custom 404 handling:
952
+ Implement custom 404 handling using app-level middleware after the router.
953
+
954
+ **Using `ctx.routeMatched` (recommended):**
955
+
956
+ ```javascript
957
+ app.use(router.routes());
958
+
959
+ // ctx.routeMatched is set by the router before handlers run
960
+ app.use((ctx) => {
961
+ if (!ctx.routeMatched) {
962
+ ctx.status = 404;
963
+ ctx.body = {
964
+ error: 'Not Found',
965
+ path: ctx.path
966
+ };
967
+ }
968
+ });
969
+ ```
970
+
971
+ **Using `ctx.matched`:**
872
972
 
873
973
  ```javascript
874
974
  app.use(router.routes());
875
975
 
876
- // 404 middleware - runs after router
877
976
  app.use((ctx) => {
878
977
  if (!ctx.matched || ctx.matched.length === 0) {
879
978
  ctx.status = 404;
@@ -885,6 +984,69 @@ app.use((ctx) => {
885
984
  });
886
985
  ```
887
986
 
987
+ > For a router-scoped alternative that doesn't require app-level middleware,
988
+ > see the [Not Found Event](#not-found-event-experimental) section below.
989
+
990
+ ### Not Found Event (Experimental)
991
+
992
+ > **Note:** This API is experimental and may change in future versions.
993
+
994
+ Use `router.on()` for a clean, dedicated 404 handler that runs only when no route
995
+ matched — without needing a catch-all route. Three equivalent forms are supported:
996
+
997
+ ```javascript
998
+ import { RouterEvents } from '@koa/router';
999
+
1000
+ // Named constant (recommended — autocomplete + refactor safe)
1001
+ router.on(RouterEvents.NotFound, handler);
1002
+
1003
+ // Selector function (fluent style)
1004
+ router.on((events) => events.NotFound, handler);
1005
+
1006
+ // Raw string (still accepted)
1007
+ router.on('not-found', handler);
1008
+ ```
1009
+
1010
+ Full example:
1011
+
1012
+ ```javascript
1013
+ import { RouterEvents } from '@koa/router';
1014
+
1015
+ router.get('/users', handler1);
1016
+ router.get('/posts', handler2);
1017
+
1018
+ router.on(RouterEvents.NotFound, (ctx) => {
1019
+ ctx.status = 404;
1020
+ ctx.body = {
1021
+ error: 'Not Found',
1022
+ path: ctx.path,
1023
+ method: ctx.method
1024
+ };
1025
+ });
1026
+
1027
+ app.use(router.routes());
1028
+ ```
1029
+
1030
+ Multiple handlers are supported and compose in registration order:
1031
+
1032
+ ```javascript
1033
+ router.on(RouterEvents.NotFound, async (ctx, next) => {
1034
+ console.log('no route matched:', ctx.path);
1035
+ await next();
1036
+ });
1037
+
1038
+ router.on(RouterEvents.NotFound, (ctx) => {
1039
+ ctx.status = 404;
1040
+ ctx.body = { error: 'Not Found' };
1041
+ });
1042
+ ```
1043
+
1044
+ Benefits over catch-all routes:
1045
+
1046
+ - Does not appear in `ctx.matched`
1047
+ - Only fires when no route matched — no need to check `ctx.routeMatched` or `ctx.body`
1048
+ - Handlers compose naturally with `next()`
1049
+
888
1050
  ## Best Practices
889
1051
 
890
1052
  ### 1. Use Middleware Composition
@@ -983,6 +1145,9 @@ router.get('/users/:id', (ctx: RouterContext) => {
983
1145
  // All matched layers
984
1146
  const matched = ctx.matched; // Array of Layer objects
985
1147
 
1148
+ // Whether any route (with HTTP methods) was matched
1149
+ const wasMatched = ctx.routeMatched; // boolean | undefined
1150
+
986
1151
  // Captured values from RegExp routes
987
1152
  const captures = ctx.captures; // string[] | undefined
988
1153
 
@@ -1043,11 +1208,13 @@ See the [recipes directory](./recipes/) for complete TypeScript examples:
1043
1208
  - **[Authentication & Authorization](./recipes/authentication-authorization/)** - JWT-based authentication with middleware
1044
1209
  - **[Request Validation](./recipes/request-validation/)** - Validate request data with middleware
1045
1210
  - **[Parameter Validation](./recipes/parameter-validation/)** - Validate and transform parameters using router.param()
1211
+ - **[Regex Parameter Validation](./recipes/regex-parameter-validation/)** - Validate URL parameters with regex (replacement for `:param(regex)` in v14+)
1046
1212
  - **[API Versioning](./recipes/api-versioning/)** - Implement API versioning with multiple routers
1047
1213
  - **[Error Handling](./recipes/error-handling/)** - Centralized error handling with custom error classes
1048
1214
  - **[Pagination](./recipes/pagination/)** - Implement pagination for list endpoints
1049
1215
  - **[Health Checks](./recipes/health-checks/)** - Add health check endpoints for monitoring
1050
1216
  - **[TypeScript Recipe](./recipes/typescript-recipe/)** - Full TypeScript example with types and type safety
1217
+ - **[Not Found Handling](./recipes/not-found-handling/)** - All approaches to handling unmatched routes: `ctx.routeMatched`, events, catch-all
1051
1218
 
1052
1219
  Each recipe file contains complete, runnable TypeScript code that you can copy and adapt to your needs.
1053
1220
 
package/dist/index.d.mts CHANGED
@@ -172,6 +172,47 @@ declare class Layer<StateT = DefaultState, ContextT = DefaultContext, BodyT = un
172
172
  private _reconfigurePathMatching;
173
173
  }
174
174
 
175
+ /**
176
+ * Named constants for active router lifecycle events.
177
+ *
178
+ * Only events that are fully implemented appear here. Planned events will be
179
+ * added as each one is implemented so the public API always reflects what
180
+ * actually works.
181
+ *
182
+ * @experimental
183
+ */
184
+ declare const RouterEvents: {
185
+ /**
186
+ * Fires when no route matched the request path + HTTP method.
187
+ */
188
+ readonly NotFound: "not-found";
189
+ };
190
+ /**
191
+ * Union of all valid router event name strings.
192
+ * Derived from `RouterEvents` so the two are always in sync.
193
+ *
194
+ * @experimental
195
+ */
196
+ type RouterEvent = (typeof RouterEvents)[keyof typeof RouterEvents];
197
+ /**
198
+ * Accepts either a raw event name string or a selector function that receives
199
+ * the `RouterEvents` object and returns an event name.
200
+ *
201
+ * Both forms are equivalent — choose whichever reads better in context.
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * // string
206
+ * 'not-found'
207
+ *
208
+ * // selector function
209
+ * (events) => events.NotFound
210
+ * ```
211
+ *
212
+ * @experimental
213
+ */
214
+ type RouterEventSelector = RouterEvent | ((events: typeof RouterEvents) => RouterEvent);
215
+
175
216
  type RouterInstance<StateT = koa.DefaultState, ContextT = koa.DefaultContext> = RouterImplementation<StateT, ContextT>;
176
217
  /**
177
218
  * Middleware with router property
@@ -189,6 +230,11 @@ declare class RouterImplementation<StateT = koa.DefaultState, ContextT = koa.Def
189
230
  params: Record<string, RouterParameterMiddleware<StateT, ContextT> | RouterParameterMiddleware<StateT, ContextT>[]>;
190
231
  stack: Layer<StateT, ContextT>[];
191
232
  host?: string | string[] | RegExp;
233
+ /**
234
+ * Event emitter for router lifecycle events (experimental)
235
+ * @internal
236
+ */
237
+ private _events;
192
238
  /**
193
239
  * Create a new router.
194
240
  *
@@ -356,6 +402,52 @@ declare class RouterImplementation<StateT = koa.DefaultState, ContextT = koa.Def
356
402
  */
357
403
  private _buildMiddlewareChain;
358
404
  routes(): RouterComposedMiddleware<StateT, ContextT>;
405
+ /**
406
+ * Register an event handler on the router.
407
+ *
408
+ * @experimental This API is experimental and may change in future versions.
409
+ *
410
+ * **Active events:**
411
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
412
+ * composed and called instead of `next()`, so they are responsible for
413
+ * calling `next()` themselves if they want to pass control downstream.
414
+ *
415
+ * @example
416
+ *
417
+ * All three forms are equivalent:
418
+ *
419
+ * ```javascript
420
+ * import { RouterEvents } from '@koa/router';
421
+ *
422
+ * // Named constant (recommended — autocomplete + refactor safe)
423
+ * router.on(RouterEvents.NotFound, handler);
424
+ *
425
+ * // Selector function (fluent style)
426
+ * router.on((events) => events.NotFound, handler);
427
+ *
428
+ * // Raw string (still accepted)
429
+ * router.on('not-found', handler);
430
+ * ```
431
+ *
432
+ * Multiple handlers compose in registration order:
433
+ *
434
+ * ```javascript
435
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
436
+ * console.log('no route matched', ctx.path);
437
+ * await next();
438
+ * });
439
+ * router.on(RouterEvents.NotFound, (ctx) => {
440
+ * ctx.status = 404;
441
+ * ctx.body = { error: 'Not Found' };
442
+ * });
443
+ * ```
444
+ *
445
+ * @param event - Event name string, `RouterEvents` constant, or selector
446
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
447
+ * @param handler - Middleware to run when the event fires
448
+ * @returns This router instance for chaining
449
+ */
450
+ on(event: RouterEventSelector, handler: RouterMiddleware<StateT, ContextT>): Router<StateT, ContextT>;
359
451
  /**
360
452
  * Returns separate middleware for responding to `OPTIONS` requests with
361
453
  * an `Allow` header containing the allowed methods, as well as responding
@@ -656,9 +748,18 @@ declare const Router: RouterConstructor;
656
748
 
657
749
  type RouterOptions = {
658
750
  /**
659
- * Only run last matched route's controller when there are multiple matches
751
+ * Control which route is executed when multiple routes match a request.
752
+ *
753
+ * - `true` — run only the last registered matching route (legacy behaviour)
754
+ * - `'specificity'` — run only the most specific matching route, defined as
755
+ * the one with the fewest path parameters. This is OpenAPI-compliant
756
+ * (see https://spec.openapis.org/oas/v3.0.3#path-templating-matching) and
757
+ * is the recommended mode for code generated from OpenAPI specifications.
758
+ * When two routes have an equal number of parameters the last registered
759
+ * one wins, preserving deterministic behaviour.
760
+ * - `false` / omitted — all matching routes run in registration order
660
761
  */
661
- exclusive?: boolean;
762
+ exclusive?: boolean | 'specificity';
662
763
  /**
663
764
  * Prefix for all routes
664
765
  */
@@ -806,6 +907,25 @@ type RouterContext<StateT = DefaultState, ContextT = DefaultContext, BodyT = unk
806
907
  * Array of matched layers
807
908
  */
808
909
  matched?: Layer<StateT, ContextT, BodyT>[];
910
+ /**
911
+ * Whether a route (with HTTP methods) was matched for this request.
912
+ * Set by the router before any handlers run.
913
+ *
914
+ * Use this in app-level middleware after `router.routes()` to detect
915
+ * requests that the router did not handle.
916
+ *
917
+ * @example
918
+ * ```javascript
919
+ * app.use(router.routes());
920
+ * app.use((ctx) => {
921
+ * if (!ctx.routeMatched) {
922
+ * ctx.status = 404;
923
+ * ctx.body = { error: 'Not Found' };
924
+ * }
925
+ * });
926
+ * ```
927
+ */
928
+ routeMatched?: boolean;
809
929
  /**
810
930
  * Captured values from path
811
931
  */
@@ -889,4 +1009,4 @@ type ParameterValidationOptions = {
889
1009
  */
890
1010
  declare function createParameterValidationMiddleware(parameterName: string, pattern: RegExp, options?: ParameterValidationOptions): RouterMiddleware<any, any, any> & RouterParameterMiddleware<any, any, any>;
891
1011
 
892
- export { type AllowedMethodsOptions, type HttpMethod, Layer, type LayerOptions, type MatchResult, type ParameterValidationOptions, Router, type RouterContext, type RouterInstance, type RouterMethodFunction, type RouterMiddleware, type RouterOptions, type RouterOptionsWithMethods, type RouterParameterMiddleware, type RouterWithMethods, type UrlOptions, createParameterValidationMiddleware, Router as default };
1012
+ export { type AllowedMethodsOptions, type HttpMethod, Layer, type LayerOptions, type MatchResult, type ParameterValidationOptions, Router, type RouterContext, type RouterEvent, type RouterEventSelector, RouterEvents, type RouterInstance, type RouterMethodFunction, type RouterMiddleware, type RouterOptions, type RouterOptionsWithMethods, type RouterParameterMiddleware, type RouterWithMethods, type UrlOptions, createParameterValidationMiddleware, Router as default };
package/dist/index.d.ts CHANGED
@@ -172,6 +172,47 @@ declare class Layer<StateT = DefaultState, ContextT = DefaultContext, BodyT = un
172
172
  private _reconfigurePathMatching;
173
173
  }
174
174
 
175
+ /**
176
+ * Named constants for active router lifecycle events.
177
+ *
178
+ * Only events that are fully implemented appear here. Planned events will be
179
+ * added as each one is implemented so the public API always reflects what
180
+ * actually works.
181
+ *
182
+ * @experimental
183
+ */
184
+ declare const RouterEvents: {
185
+ /**
186
+ * Fires when no route matched the request path + HTTP method.
187
+ */
188
+ readonly NotFound: "not-found";
189
+ };
190
+ /**
191
+ * Union of all valid router event name strings.
192
+ * Derived from `RouterEvents` so the two are always in sync.
193
+ *
194
+ * @experimental
195
+ */
196
+ type RouterEvent = (typeof RouterEvents)[keyof typeof RouterEvents];
197
+ /**
198
+ * Accepts either a raw event name string or a selector function that receives
199
+ * the `RouterEvents` object and returns an event name.
200
+ *
201
+ * Both forms are equivalent — choose whichever reads better in context.
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * // string
206
+ * 'not-found'
207
+ *
208
+ * // selector function
209
+ * (events) => events.NotFound
210
+ * ```
211
+ *
212
+ * @experimental
213
+ */
214
+ type RouterEventSelector = RouterEvent | ((events: typeof RouterEvents) => RouterEvent);
215
+
175
216
  type RouterInstance<StateT = koa.DefaultState, ContextT = koa.DefaultContext> = RouterImplementation<StateT, ContextT>;
176
217
  /**
177
218
  * Middleware with router property
@@ -189,6 +230,11 @@ declare class RouterImplementation<StateT = koa.DefaultState, ContextT = koa.Def
189
230
  params: Record<string, RouterParameterMiddleware<StateT, ContextT> | RouterParameterMiddleware<StateT, ContextT>[]>;
190
231
  stack: Layer<StateT, ContextT>[];
191
232
  host?: string | string[] | RegExp;
233
+ /**
234
+ * Event emitter for router lifecycle events (experimental)
235
+ * @internal
236
+ */
237
+ private _events;
192
238
  /**
193
239
  * Create a new router.
194
240
  *
@@ -356,6 +402,52 @@ declare class RouterImplementation<StateT = koa.DefaultState, ContextT = koa.Def
356
402
  */
357
403
  private _buildMiddlewareChain;
358
404
  routes(): RouterComposedMiddleware<StateT, ContextT>;
405
+ /**
406
+ * Register an event handler on the router.
407
+ *
408
+ * @experimental This API is experimental and may change in future versions.
409
+ *
410
+ * **Active events:**
411
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
412
+ * composed and called instead of `next()`, so they are responsible for
413
+ * calling `next()` themselves if they want to pass control downstream.
414
+ *
415
+ * @example
416
+ *
417
+ * All three forms are equivalent:
418
+ *
419
+ * ```javascript
420
+ * import { RouterEvents } from '@koa/router';
421
+ *
422
+ * // Named constant (recommended — autocomplete + refactor safe)
423
+ * router.on(RouterEvents.NotFound, handler);
424
+ *
425
+ * // Selector function (fluent style)
426
+ * router.on((events) => events.NotFound, handler);
427
+ *
428
+ * // Raw string (still accepted)
429
+ * router.on('not-found', handler);
430
+ * ```
431
+ *
432
+ * Multiple handlers compose in registration order:
433
+ *
434
+ * ```javascript
435
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
436
+ * console.log('no route matched', ctx.path);
437
+ * await next();
438
+ * });
439
+ * router.on(RouterEvents.NotFound, (ctx) => {
440
+ * ctx.status = 404;
441
+ * ctx.body = { error: 'Not Found' };
442
+ * });
443
+ * ```
444
+ *
445
+ * @param event - Event name string, `RouterEvents` constant, or selector
446
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
447
+ * @param handler - Middleware to run when the event fires
448
+ * @returns This router instance for chaining
449
+ */
450
+ on(event: RouterEventSelector, handler: RouterMiddleware<StateT, ContextT>): Router<StateT, ContextT>;
359
451
  /**
360
452
  * Returns separate middleware for responding to `OPTIONS` requests with
361
453
  * an `Allow` header containing the allowed methods, as well as responding
@@ -656,9 +748,18 @@ declare const Router: RouterConstructor;
656
748
 
657
749
  type RouterOptions = {
658
750
  /**
659
- * Only run last matched route's controller when there are multiple matches
751
+ * Control which route is executed when multiple routes match a request.
752
+ *
753
+ * - `true` — run only the last registered matching route (legacy behaviour)
754
+ * - `'specificity'` — run only the most specific matching route, defined as
755
+ * the one with the fewest path parameters. This is OpenAPI-compliant
756
+ * (see https://spec.openapis.org/oas/v3.0.3#path-templating-matching) and
757
+ * is the recommended mode for code generated from OpenAPI specifications.
758
+ * When two routes have an equal number of parameters the last registered
759
+ * one wins, preserving deterministic behaviour.
760
+ * - `false` / omitted — all matching routes run in registration order
660
761
  */
661
- exclusive?: boolean;
762
+ exclusive?: boolean | 'specificity';
662
763
  /**
663
764
  * Prefix for all routes
664
765
  */
@@ -806,6 +907,25 @@ type RouterContext<StateT = DefaultState, ContextT = DefaultContext, BodyT = unk
806
907
  * Array of matched layers
807
908
  */
808
909
  matched?: Layer<StateT, ContextT, BodyT>[];
910
+ /**
911
+ * Whether a route (with HTTP methods) was matched for this request.
912
+ * Set by the router before any handlers run.
913
+ *
914
+ * Use this in app-level middleware after `router.routes()` to detect
915
+ * requests that the router did not handle.
916
+ *
917
+ * @example
918
+ * ```javascript
919
+ * app.use(router.routes());
920
+ * app.use((ctx) => {
921
+ * if (!ctx.routeMatched) {
922
+ * ctx.status = 404;
923
+ * ctx.body = { error: 'Not Found' };
924
+ * }
925
+ * });
926
+ * ```
927
+ */
928
+ routeMatched?: boolean;
809
929
  /**
810
930
  * Captured values from path
811
931
  */
@@ -889,4 +1009,4 @@ type ParameterValidationOptions = {
889
1009
  */
890
1010
  declare function createParameterValidationMiddleware(parameterName: string, pattern: RegExp, options?: ParameterValidationOptions): RouterMiddleware<any, any, any> & RouterParameterMiddleware<any, any, any>;
891
1011
 
892
- export { type AllowedMethodsOptions, type HttpMethod, Layer, type LayerOptions, type MatchResult, type ParameterValidationOptions, Router, type RouterContext, type RouterInstance, type RouterMethodFunction, type RouterMiddleware, type RouterOptions, type RouterOptionsWithMethods, type RouterParameterMiddleware, type RouterWithMethods, type UrlOptions, createParameterValidationMiddleware, Router as default };
1012
+ export { type AllowedMethodsOptions, type HttpMethod, Layer, type LayerOptions, type MatchResult, type ParameterValidationOptions, Router, type RouterContext, type RouterEvent, type RouterEventSelector, RouterEvents, type RouterInstance, type RouterMethodFunction, type RouterMiddleware, type RouterOptions, type RouterOptionsWithMethods, type RouterParameterMiddleware, type RouterWithMethods, type UrlOptions, createParameterValidationMiddleware, Router as default };
package/dist/index.js CHANGED
@@ -31,11 +31,72 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  Router: () => Router,
34
+ RouterEvents: () => RouterEvents,
34
35
  createParameterValidationMiddleware: () => createParameterValidationMiddleware,
35
36
  default: () => router_default
36
37
  });
37
38
  module.exports = __toCommonJS(index_exports);
38
39
 
40
+ // src/utils/router-events.ts
41
+ var import_koa_compose = __toESM(require("koa-compose"));
42
+ var RouterEvents = {
43
+ /**
44
+ * Fires when no route matched the request path + HTTP method.
45
+ */
46
+ NotFound: "not-found"
47
+ // /**
48
+ // * Fires when the request path matched a registered route but the HTTP
49
+ // * method did not match any of its allowed methods.
50
+ // *
51
+ // * @planned Not yet active — handlers are stored but not called.
52
+ // */
53
+ // MethodNotAllowed: 'method-not-allowed',
54
+ // /**
55
+ // * Fires after a route is matched and before its handlers run.
56
+ // * Useful for tracing and logging the matched route.
57
+ // *
58
+ // * @planned Not yet active — handlers are stored but not called.
59
+ // */
60
+ // Match: 'match',
61
+ // /**
62
+ // * Fires on every request that enters the router, before route matching.
63
+ // * Useful for per-router metrics and request logging.
64
+ // *
65
+ // * @planned Not yet active — handlers are stored but not called.
66
+ // */
67
+ // Dispatch: 'dispatch'
68
+ };
69
+ function resolveEvent(selector) {
70
+ return typeof selector === "function" ? selector(RouterEvents) : selector;
71
+ }
72
+ var RouterEventEmitter = class {
73
+ _handlers = /* @__PURE__ */ new Map();
74
+ /**
75
+ * Register a handler for the given event.
76
+ * Multiple handlers for the same event are composed in registration order.
77
+ */
78
+ register(event, handler) {
79
+ const existing = this._handlers.get(event) ?? [];
80
+ existing.push(handler);
81
+ this._handlers.set(event, existing);
82
+ }
83
+ /**
84
+ * Emit an event by composing and running all registered handlers.
85
+ * If no handlers are registered for the event, calls `next()` directly.
86
+ *
87
+ * @param event - The event to emit
88
+ * @param context - The router context
89
+ * @param next - The downstream next function
90
+ */
91
+ emit(event, context, next) {
92
+ const handlers = this._handlers.get(event);
93
+ if (handlers && handlers.length > 0) {
94
+ return (0, import_koa_compose.default)(handlers)(context, next);
95
+ }
96
+ return next();
97
+ }
98
+ };
99
+
39
100
  // src/utils/parameter-match.ts
40
101
  var import_http_errors = __toESM(require("http-errors"));
41
102
  function createParameterValidationMiddleware(parameterName, pattern, options = {}) {
@@ -86,7 +147,7 @@ function createParameterValidationMiddleware(parameterName, pattern, options = {
86
147
  }
87
148
 
88
149
  // src/router.ts
89
- var import_koa_compose = __toESM(require("koa-compose"));
150
+ var import_koa_compose2 = __toESM(require("koa-compose"));
90
151
  var import_http_errors2 = __toESM(require("http-errors"));
91
152
 
92
153
  // src/layer.ts
@@ -651,6 +712,11 @@ var RouterImplementation = class {
651
712
  params;
652
713
  stack;
653
714
  host;
715
+ /**
716
+ * Event emitter for router lifecycle events (experimental)
717
+ * @internal
718
+ */
719
+ _events = new RouterEventEmitter();
654
720
  /**
655
721
  * Create a new router.
656
722
  *
@@ -900,8 +966,11 @@ var RouterImplementation = class {
900
966
  const previousPrefix = this.opts.prefix || "";
901
967
  this.opts.prefix = normalizedPrefix;
902
968
  for (const route of this.stack) {
903
- if (previousPrefix && typeof route.path === "string" && route.path.startsWith(previousPrefix)) {
904
- route.path = route.path.slice(previousPrefix.length) || "/";
969
+ if (typeof route.path === "string" && previousPrefix) {
970
+ const stripBoundary = previousPrefix + "/";
971
+ if (route.path === previousPrefix || route.path.startsWith(stripBoundary)) {
972
+ route.path = route.path.slice(previousPrefix.length) || "/";
973
+ }
905
974
  }
906
975
  route.setPrefix(normalizedPrefix);
907
976
  }
@@ -922,8 +991,13 @@ var RouterImplementation = class {
922
991
  const matchResult = this.match(requestPath, context.method);
923
992
  this._storeMatchedRoutes(context, matchResult);
924
993
  context.router = this;
994
+ context.routeMatched = matchResult.route;
925
995
  if (!matchResult.route) {
926
- return next();
996
+ return this._events.emit(
997
+ RouterEvents.NotFound,
998
+ context,
999
+ next
1000
+ );
927
1001
  }
928
1002
  const matchedLayers = matchResult.pathAndMethod;
929
1003
  this._setMatchedRouteInfo(context, matchedLayers);
@@ -931,7 +1005,7 @@ var RouterImplementation = class {
931
1005
  matchedLayers,
932
1006
  requestPath
933
1007
  );
934
- return (0, import_koa_compose.default)(middlewareChain)(
1008
+ return (0, import_koa_compose2.default)(middlewareChain)(
935
1009
  context,
936
1010
  next
937
1011
  );
@@ -978,9 +1052,22 @@ var RouterImplementation = class {
978
1052
  * @private
979
1053
  */
980
1054
  _buildMiddlewareChain(matchedLayers, requestPath) {
981
- const layersToExecute = this.opts.exclusive ? [matchedLayers.at(-1)].filter(
982
- (layer) => layer !== void 0
983
- ) : matchedLayers;
1055
+ let layersToExecute;
1056
+ if (this.exclusive) {
1057
+ let chosenLayer;
1058
+ if (this.opts.exclusive === "specificity") {
1059
+ for (const layer of matchedLayers) {
1060
+ if (!chosenLayer || layer.paramNames.length < chosenLayer.paramNames.length) {
1061
+ chosenLayer = layer;
1062
+ }
1063
+ }
1064
+ } else {
1065
+ chosenLayer = matchedLayers.at(-1);
1066
+ }
1067
+ layersToExecute = chosenLayer ? [chosenLayer] : [];
1068
+ } else {
1069
+ layersToExecute = matchedLayers;
1070
+ }
984
1071
  const middlewareChain = [];
985
1072
  for (const layer of layersToExecute) {
986
1073
  middlewareChain.push(
@@ -1009,6 +1096,55 @@ var RouterImplementation = class {
1009
1096
  routes() {
1010
1097
  return this.middleware();
1011
1098
  }
1099
+ /**
1100
+ * Register an event handler on the router.
1101
+ *
1102
+ * @experimental This API is experimental and may change in future versions.
1103
+ *
1104
+ * **Active events:**
1105
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
1106
+ * composed and called instead of `next()`, so they are responsible for
1107
+ * calling `next()` themselves if they want to pass control downstream.
1108
+ *
1109
+ * @example
1110
+ *
1111
+ * All three forms are equivalent:
1112
+ *
1113
+ * ```javascript
1114
+ * import { RouterEvents } from '@koa/router';
1115
+ *
1116
+ * // Named constant (recommended — autocomplete + refactor safe)
1117
+ * router.on(RouterEvents.NotFound, handler);
1118
+ *
1119
+ * // Selector function (fluent style)
1120
+ * router.on((events) => events.NotFound, handler);
1121
+ *
1122
+ * // Raw string (still accepted)
1123
+ * router.on('not-found', handler);
1124
+ * ```
1125
+ *
1126
+ * Multiple handlers compose in registration order:
1127
+ *
1128
+ * ```javascript
1129
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
1130
+ * console.log('no route matched', ctx.path);
1131
+ * await next();
1132
+ * });
1133
+ * router.on(RouterEvents.NotFound, (ctx) => {
1134
+ * ctx.status = 404;
1135
+ * ctx.body = { error: 'Not Found' };
1136
+ * });
1137
+ * ```
1138
+ *
1139
+ * @param event - Event name string, `RouterEvents` constant, or selector
1140
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
1141
+ * @param handler - Middleware to run when the event fires
1142
+ * @returns This router instance for chaining
1143
+ */
1144
+ on(event, handler) {
1145
+ this._events.register(resolveEvent(event), handler);
1146
+ return this;
1147
+ }
1012
1148
  /**
1013
1149
  * Returns separate middleware for responding to `OPTIONS` requests with
1014
1150
  * an `Allow` header containing the allowed methods, as well as responding
@@ -1487,6 +1623,7 @@ for (const httpMethod of httpMethods) {
1487
1623
  // Annotate the CommonJS export names for ESM import in node:
1488
1624
  0 && (module.exports = {
1489
1625
  Router,
1626
+ RouterEvents,
1490
1627
  createParameterValidationMiddleware
1491
1628
  });
1492
1629
  if (module.exports.default) {
package/dist/index.mjs CHANGED
@@ -1,3 +1,63 @@
1
+ // src/utils/router-events.ts
2
+ import compose from "koa-compose";
3
+ var RouterEvents = {
4
+ /**
5
+ * Fires when no route matched the request path + HTTP method.
6
+ */
7
+ NotFound: "not-found"
8
+ // /**
9
+ // * Fires when the request path matched a registered route but the HTTP
10
+ // * method did not match any of its allowed methods.
11
+ // *
12
+ // * @planned Not yet active — handlers are stored but not called.
13
+ // */
14
+ // MethodNotAllowed: 'method-not-allowed',
15
+ // /**
16
+ // * Fires after a route is matched and before its handlers run.
17
+ // * Useful for tracing and logging the matched route.
18
+ // *
19
+ // * @planned Not yet active — handlers are stored but not called.
20
+ // */
21
+ // Match: 'match',
22
+ // /**
23
+ // * Fires on every request that enters the router, before route matching.
24
+ // * Useful for per-router metrics and request logging.
25
+ // *
26
+ // * @planned Not yet active — handlers are stored but not called.
27
+ // */
28
+ // Dispatch: 'dispatch'
29
+ };
30
+ function resolveEvent(selector) {
31
+ return typeof selector === "function" ? selector(RouterEvents) : selector;
32
+ }
33
+ var RouterEventEmitter = class {
34
+ _handlers = /* @__PURE__ */ new Map();
35
+ /**
36
+ * Register a handler for the given event.
37
+ * Multiple handlers for the same event are composed in registration order.
38
+ */
39
+ register(event, handler) {
40
+ const existing = this._handlers.get(event) ?? [];
41
+ existing.push(handler);
42
+ this._handlers.set(event, existing);
43
+ }
44
+ /**
45
+ * Emit an event by composing and running all registered handlers.
46
+ * If no handlers are registered for the event, calls `next()` directly.
47
+ *
48
+ * @param event - The event to emit
49
+ * @param context - The router context
50
+ * @param next - The downstream next function
51
+ */
52
+ emit(event, context, next) {
53
+ const handlers = this._handlers.get(event);
54
+ if (handlers && handlers.length > 0) {
55
+ return compose(handlers)(context, next);
56
+ }
57
+ return next();
58
+ }
59
+ };
60
+
1
61
  // src/utils/parameter-match.ts
2
62
  import createHttpError from "http-errors";
3
63
  function createParameterValidationMiddleware(parameterName, pattern, options = {}) {
@@ -48,7 +108,7 @@ function createParameterValidationMiddleware(parameterName, pattern, options = {
48
108
  }
49
109
 
50
110
  // src/router.ts
51
- import compose from "koa-compose";
111
+ import compose2 from "koa-compose";
52
112
  import HttpError from "http-errors";
53
113
 
54
114
  // src/layer.ts
@@ -613,6 +673,11 @@ var RouterImplementation = class {
613
673
  params;
614
674
  stack;
615
675
  host;
676
+ /**
677
+ * Event emitter for router lifecycle events (experimental)
678
+ * @internal
679
+ */
680
+ _events = new RouterEventEmitter();
616
681
  /**
617
682
  * Create a new router.
618
683
  *
@@ -862,8 +927,11 @@ var RouterImplementation = class {
862
927
  const previousPrefix = this.opts.prefix || "";
863
928
  this.opts.prefix = normalizedPrefix;
864
929
  for (const route of this.stack) {
865
- if (previousPrefix && typeof route.path === "string" && route.path.startsWith(previousPrefix)) {
866
- route.path = route.path.slice(previousPrefix.length) || "/";
930
+ if (typeof route.path === "string" && previousPrefix) {
931
+ const stripBoundary = previousPrefix + "/";
932
+ if (route.path === previousPrefix || route.path.startsWith(stripBoundary)) {
933
+ route.path = route.path.slice(previousPrefix.length) || "/";
934
+ }
867
935
  }
868
936
  route.setPrefix(normalizedPrefix);
869
937
  }
@@ -884,8 +952,13 @@ var RouterImplementation = class {
884
952
  const matchResult = this.match(requestPath, context.method);
885
953
  this._storeMatchedRoutes(context, matchResult);
886
954
  context.router = this;
955
+ context.routeMatched = matchResult.route;
887
956
  if (!matchResult.route) {
888
- return next();
957
+ return this._events.emit(
958
+ RouterEvents.NotFound,
959
+ context,
960
+ next
961
+ );
889
962
  }
890
963
  const matchedLayers = matchResult.pathAndMethod;
891
964
  this._setMatchedRouteInfo(context, matchedLayers);
@@ -893,7 +966,7 @@ var RouterImplementation = class {
893
966
  matchedLayers,
894
967
  requestPath
895
968
  );
896
- return compose(middlewareChain)(
969
+ return compose2(middlewareChain)(
897
970
  context,
898
971
  next
899
972
  );
@@ -940,9 +1013,22 @@ var RouterImplementation = class {
940
1013
  * @private
941
1014
  */
942
1015
  _buildMiddlewareChain(matchedLayers, requestPath) {
943
- const layersToExecute = this.opts.exclusive ? [matchedLayers.at(-1)].filter(
944
- (layer) => layer !== void 0
945
- ) : matchedLayers;
1016
+ let layersToExecute;
1017
+ if (this.exclusive) {
1018
+ let chosenLayer;
1019
+ if (this.opts.exclusive === "specificity") {
1020
+ for (const layer of matchedLayers) {
1021
+ if (!chosenLayer || layer.paramNames.length < chosenLayer.paramNames.length) {
1022
+ chosenLayer = layer;
1023
+ }
1024
+ }
1025
+ } else {
1026
+ chosenLayer = matchedLayers.at(-1);
1027
+ }
1028
+ layersToExecute = chosenLayer ? [chosenLayer] : [];
1029
+ } else {
1030
+ layersToExecute = matchedLayers;
1031
+ }
946
1032
  const middlewareChain = [];
947
1033
  for (const layer of layersToExecute) {
948
1034
  middlewareChain.push(
@@ -971,6 +1057,55 @@ var RouterImplementation = class {
971
1057
  routes() {
972
1058
  return this.middleware();
973
1059
  }
1060
+ /**
1061
+ * Register an event handler on the router.
1062
+ *
1063
+ * @experimental This API is experimental and may change in future versions.
1064
+ *
1065
+ * **Active events:**
1066
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
1067
+ * composed and called instead of `next()`, so they are responsible for
1068
+ * calling `next()` themselves if they want to pass control downstream.
1069
+ *
1070
+ * @example
1071
+ *
1072
+ * All three forms are equivalent:
1073
+ *
1074
+ * ```javascript
1075
+ * import { RouterEvents } from '@koa/router';
1076
+ *
1077
+ * // Named constant (recommended — autocomplete + refactor safe)
1078
+ * router.on(RouterEvents.NotFound, handler);
1079
+ *
1080
+ * // Selector function (fluent style)
1081
+ * router.on((events) => events.NotFound, handler);
1082
+ *
1083
+ * // Raw string (still accepted)
1084
+ * router.on('not-found', handler);
1085
+ * ```
1086
+ *
1087
+ * Multiple handlers compose in registration order:
1088
+ *
1089
+ * ```javascript
1090
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
1091
+ * console.log('no route matched', ctx.path);
1092
+ * await next();
1093
+ * });
1094
+ * router.on(RouterEvents.NotFound, (ctx) => {
1095
+ * ctx.status = 404;
1096
+ * ctx.body = { error: 'Not Found' };
1097
+ * });
1098
+ * ```
1099
+ *
1100
+ * @param event - Event name string, `RouterEvents` constant, or selector
1101
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
1102
+ * @param handler - Middleware to run when the event fires
1103
+ * @returns This router instance for chaining
1104
+ */
1105
+ on(event, handler) {
1106
+ this._events.register(resolveEvent(event), handler);
1107
+ return this;
1108
+ }
974
1109
  /**
975
1110
  * Returns separate middleware for responding to `OPTIONS` requests with
976
1111
  * an `Allow` header containing the allowed methods, as well as responding
@@ -1448,6 +1583,7 @@ for (const httpMethod of httpMethods) {
1448
1583
  }
1449
1584
  export {
1450
1585
  Router,
1586
+ RouterEvents,
1451
1587
  createParameterValidationMiddleware,
1452
1588
  router_default as default
1453
1589
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koa/router",
3
3
  "description": "Router middleware for Koa.",
4
- "version": "15.3.2",
4
+ "version": "15.5.0",
5
5
  "author": "Koa.js",
6
6
  "bugs": {
7
7
  "url": "https://github.com/koajs/router/issues",
@@ -28,31 +28,31 @@
28
28
  "debug": "^4.4.3",
29
29
  "http-errors": "^2.0.1",
30
30
  "koa-compose": "^4.1.0",
31
- "path-to-regexp": "^8.3.0"
31
+ "path-to-regexp": "^8.4.2"
32
32
  },
33
33
  "devDependencies": {
34
- "@commitlint/cli": "^20.4.4",
35
- "@commitlint/config-conventional": "^20.4.4",
34
+ "@commitlint/cli": "^20.5.3",
35
+ "@commitlint/config-conventional": "^20.5.3",
36
36
  "@eslint/js": "^10.0.1",
37
37
  "@koa/bodyparser": "^6.1.0",
38
- "@types/debug": "^4.1.12",
38
+ "@types/debug": "^4.1.13",
39
39
  "@types/jsonwebtoken": "^9.0.10",
40
- "@types/koa": "^3.0.1",
41
- "@types/node": "^25.5.0",
40
+ "@types/koa": "^3.0.2",
41
+ "@types/node": "^25.6.0",
42
42
  "@types/supertest": "^7.2.0",
43
- "@typescript-eslint/eslint-plugin": "^8.57.0",
44
- "@typescript-eslint/parser": "^8.57.0",
43
+ "@typescript-eslint/eslint-plugin": "^8.59.1",
44
+ "@typescript-eslint/parser": "^8.59.1",
45
45
  "c8": "^11.0.0",
46
46
  "chalk": "^5.6.2",
47
- "eslint": "^10.0.3",
48
- "eslint-plugin-unicorn": "^63.0.0",
47
+ "eslint": "^10.3.0",
48
+ "eslint-plugin-unicorn": "^64.0.0",
49
49
  "husky": "^9.1.7",
50
- "joi": "^18.0.2",
50
+ "joi": "^18.1.2",
51
51
  "jsonwebtoken": "^9.0.3",
52
- "koa": "^3.1.2",
52
+ "koa": "^3.2.0",
53
53
  "lint-staged": "^16.4.0",
54
- "np": "^11.0.2",
55
- "prettier": "^3.8.1",
54
+ "np": "^11.2.0",
55
+ "prettier": "^3.8.3",
56
56
  "rimraf": "^6.1.3",
57
57
  "supertest": "^7.2.2",
58
58
  "ts-node": "^10.9.2",