@koa/router 15.3.2 → 15.4.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
 
@@ -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
@@ -806,6 +898,25 @@ type RouterContext<StateT = DefaultState, ContextT = DefaultContext, BodyT = unk
806
898
  * Array of matched layers
807
899
  */
808
900
  matched?: Layer<StateT, ContextT, BodyT>[];
901
+ /**
902
+ * Whether a route (with HTTP methods) was matched for this request.
903
+ * Set by the router before any handlers run.
904
+ *
905
+ * Use this in app-level middleware after `router.routes()` to detect
906
+ * requests that the router did not handle.
907
+ *
908
+ * @example
909
+ * ```javascript
910
+ * app.use(router.routes());
911
+ * app.use((ctx) => {
912
+ * if (!ctx.routeMatched) {
913
+ * ctx.status = 404;
914
+ * ctx.body = { error: 'Not Found' };
915
+ * }
916
+ * });
917
+ * ```
918
+ */
919
+ routeMatched?: boolean;
809
920
  /**
810
921
  * Captured values from path
811
922
  */
@@ -889,4 +1000,4 @@ type ParameterValidationOptions = {
889
1000
  */
890
1001
  declare function createParameterValidationMiddleware(parameterName: string, pattern: RegExp, options?: ParameterValidationOptions): RouterMiddleware<any, any, any> & RouterParameterMiddleware<any, any, any>;
891
1002
 
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 };
1003
+ 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
@@ -806,6 +898,25 @@ type RouterContext<StateT = DefaultState, ContextT = DefaultContext, BodyT = unk
806
898
  * Array of matched layers
807
899
  */
808
900
  matched?: Layer<StateT, ContextT, BodyT>[];
901
+ /**
902
+ * Whether a route (with HTTP methods) was matched for this request.
903
+ * Set by the router before any handlers run.
904
+ *
905
+ * Use this in app-level middleware after `router.routes()` to detect
906
+ * requests that the router did not handle.
907
+ *
908
+ * @example
909
+ * ```javascript
910
+ * app.use(router.routes());
911
+ * app.use((ctx) => {
912
+ * if (!ctx.routeMatched) {
913
+ * ctx.status = 404;
914
+ * ctx.body = { error: 'Not Found' };
915
+ * }
916
+ * });
917
+ * ```
918
+ */
919
+ routeMatched?: boolean;
809
920
  /**
810
921
  * Captured values from path
811
922
  */
@@ -889,4 +1000,4 @@ type ParameterValidationOptions = {
889
1000
  */
890
1001
  declare function createParameterValidationMiddleware(parameterName: string, pattern: RegExp, options?: ParameterValidationOptions): RouterMiddleware<any, any, any> & RouterParameterMiddleware<any, any, any>;
891
1002
 
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 };
1003
+ 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
  *
@@ -922,8 +988,13 @@ var RouterImplementation = class {
922
988
  const matchResult = this.match(requestPath, context.method);
923
989
  this._storeMatchedRoutes(context, matchResult);
924
990
  context.router = this;
991
+ context.routeMatched = matchResult.route;
925
992
  if (!matchResult.route) {
926
- return next();
993
+ return this._events.emit(
994
+ RouterEvents.NotFound,
995
+ context,
996
+ next
997
+ );
927
998
  }
928
999
  const matchedLayers = matchResult.pathAndMethod;
929
1000
  this._setMatchedRouteInfo(context, matchedLayers);
@@ -931,7 +1002,7 @@ var RouterImplementation = class {
931
1002
  matchedLayers,
932
1003
  requestPath
933
1004
  );
934
- return (0, import_koa_compose.default)(middlewareChain)(
1005
+ return (0, import_koa_compose2.default)(middlewareChain)(
935
1006
  context,
936
1007
  next
937
1008
  );
@@ -1009,6 +1080,55 @@ var RouterImplementation = class {
1009
1080
  routes() {
1010
1081
  return this.middleware();
1011
1082
  }
1083
+ /**
1084
+ * Register an event handler on the router.
1085
+ *
1086
+ * @experimental This API is experimental and may change in future versions.
1087
+ *
1088
+ * **Active events:**
1089
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
1090
+ * composed and called instead of `next()`, so they are responsible for
1091
+ * calling `next()` themselves if they want to pass control downstream.
1092
+ *
1093
+ * @example
1094
+ *
1095
+ * All three forms are equivalent:
1096
+ *
1097
+ * ```javascript
1098
+ * import { RouterEvents } from '@koa/router';
1099
+ *
1100
+ * // Named constant (recommended — autocomplete + refactor safe)
1101
+ * router.on(RouterEvents.NotFound, handler);
1102
+ *
1103
+ * // Selector function (fluent style)
1104
+ * router.on((events) => events.NotFound, handler);
1105
+ *
1106
+ * // Raw string (still accepted)
1107
+ * router.on('not-found', handler);
1108
+ * ```
1109
+ *
1110
+ * Multiple handlers compose in registration order:
1111
+ *
1112
+ * ```javascript
1113
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
1114
+ * console.log('no route matched', ctx.path);
1115
+ * await next();
1116
+ * });
1117
+ * router.on(RouterEvents.NotFound, (ctx) => {
1118
+ * ctx.status = 404;
1119
+ * ctx.body = { error: 'Not Found' };
1120
+ * });
1121
+ * ```
1122
+ *
1123
+ * @param event - Event name string, `RouterEvents` constant, or selector
1124
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
1125
+ * @param handler - Middleware to run when the event fires
1126
+ * @returns This router instance for chaining
1127
+ */
1128
+ on(event, handler) {
1129
+ this._events.register(resolveEvent(event), handler);
1130
+ return this;
1131
+ }
1012
1132
  /**
1013
1133
  * Returns separate middleware for responding to `OPTIONS` requests with
1014
1134
  * an `Allow` header containing the allowed methods, as well as responding
@@ -1487,6 +1607,7 @@ for (const httpMethod of httpMethods) {
1487
1607
  // Annotate the CommonJS export names for ESM import in node:
1488
1608
  0 && (module.exports = {
1489
1609
  Router,
1610
+ RouterEvents,
1490
1611
  createParameterValidationMiddleware
1491
1612
  });
1492
1613
  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
  *
@@ -884,8 +949,13 @@ var RouterImplementation = class {
884
949
  const matchResult = this.match(requestPath, context.method);
885
950
  this._storeMatchedRoutes(context, matchResult);
886
951
  context.router = this;
952
+ context.routeMatched = matchResult.route;
887
953
  if (!matchResult.route) {
888
- return next();
954
+ return this._events.emit(
955
+ RouterEvents.NotFound,
956
+ context,
957
+ next
958
+ );
889
959
  }
890
960
  const matchedLayers = matchResult.pathAndMethod;
891
961
  this._setMatchedRouteInfo(context, matchedLayers);
@@ -893,7 +963,7 @@ var RouterImplementation = class {
893
963
  matchedLayers,
894
964
  requestPath
895
965
  );
896
- return compose(middlewareChain)(
966
+ return compose2(middlewareChain)(
897
967
  context,
898
968
  next
899
969
  );
@@ -971,6 +1041,55 @@ var RouterImplementation = class {
971
1041
  routes() {
972
1042
  return this.middleware();
973
1043
  }
1044
+ /**
1045
+ * Register an event handler on the router.
1046
+ *
1047
+ * @experimental This API is experimental and may change in future versions.
1048
+ *
1049
+ * **Active events:**
1050
+ * - `'not-found'` — fires when no route matched path + method. Handlers are
1051
+ * composed and called instead of `next()`, so they are responsible for
1052
+ * calling `next()` themselves if they want to pass control downstream.
1053
+ *
1054
+ * @example
1055
+ *
1056
+ * All three forms are equivalent:
1057
+ *
1058
+ * ```javascript
1059
+ * import { RouterEvents } from '@koa/router';
1060
+ *
1061
+ * // Named constant (recommended — autocomplete + refactor safe)
1062
+ * router.on(RouterEvents.NotFound, handler);
1063
+ *
1064
+ * // Selector function (fluent style)
1065
+ * router.on((events) => events.NotFound, handler);
1066
+ *
1067
+ * // Raw string (still accepted)
1068
+ * router.on('not-found', handler);
1069
+ * ```
1070
+ *
1071
+ * Multiple handlers compose in registration order:
1072
+ *
1073
+ * ```javascript
1074
+ * router.on(RouterEvents.NotFound, async (ctx, next) => {
1075
+ * console.log('no route matched', ctx.path);
1076
+ * await next();
1077
+ * });
1078
+ * router.on(RouterEvents.NotFound, (ctx) => {
1079
+ * ctx.status = 404;
1080
+ * ctx.body = { error: 'Not Found' };
1081
+ * });
1082
+ * ```
1083
+ *
1084
+ * @param event - Event name string, `RouterEvents` constant, or selector
1085
+ * function `(events) => events.NotFound` (see `RouterEventSelector`)
1086
+ * @param handler - Middleware to run when the event fires
1087
+ * @returns This router instance for chaining
1088
+ */
1089
+ on(event, handler) {
1090
+ this._events.register(resolveEvent(event), handler);
1091
+ return this;
1092
+ }
974
1093
  /**
975
1094
  * Returns separate middleware for responding to `OPTIONS` requests with
976
1095
  * an `Allow` header containing the allowed methods, as well as responding
@@ -1448,6 +1567,7 @@ for (const httpMethod of httpMethods) {
1448
1567
  }
1449
1568
  export {
1450
1569
  Router,
1570
+ RouterEvents,
1451
1571
  createParameterValidationMiddleware,
1452
1572
  router_default as default
1453
1573
  };
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.4.0",
5
5
  "author": "Koa.js",
6
6
  "bugs": {
7
7
  "url": "https://github.com/koajs/router/issues",