@koa/router 15.4.0 → 15.6.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
@@ -40,6 +40,7 @@
40
40
  - ✅ **405 Method Not Allowed** - Automatic method validation
41
41
  - ✅ **501 Not Implemented** - Proper HTTP status codes
42
42
  - ✅ **Async/Await** - Full promise-based middleware support
43
+ - ✅ **Clean ctx.params** - Only URL parameters you define are present in `ctx.params`; no internal routing keys leak out
43
44
 
44
45
  ## Installation
45
46
 
@@ -266,14 +267,14 @@ Create a new router instance.
266
267
 
267
268
  **Options:**
268
269
 
269
- | Option | Type | Description |
270
- | ----------- | ------------------------------ | ----------------------------------------- |
271
- | `prefix` | `string` | Prefix all routes with this path |
272
- | `exclusive` | `boolean` | Only run the most specific matching route |
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 |
270
+ | Option | Type | Description |
271
+ | ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
272
+ | `prefix` | `string` | Prefix all routes with this path |
273
+ | `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. |
274
+ | `host` | `string \| string[] \| RegExp` | Match routes only for this hostname(s) |
275
+ | `methods` | `string[]` | Custom HTTP methods to support |
276
+ | `sensitive` | `boolean` | Enable case-sensitive routing |
277
+ | `strict` | `boolean` | Require trailing slashes |
277
278
 
278
279
  **Example:**
279
280
 
@@ -457,7 +458,26 @@ router.get('/users', (ctx) => {
457
458
  // Responds to /api/v1/users, /api/v2/users, etc.
458
459
  ```
459
460
 
460
- **Note:** Middleware now correctly executes when the prefix contains parameters.
461
+ **Middleware on a parameterized prefix:**
462
+
463
+ When `router.use()` is attached to a router whose prefix contains URL parameters, `ctx.params` inside the middleware will contain **only** the parameters defined by the prefix — no internal routing keys are ever added.
464
+
465
+ ```javascript
466
+ const router = new Router({ prefix: '/:tenantId' });
467
+
468
+ // Auth / authz middleware — receives only the defined prefix param
469
+ router.use(async (ctx, next) => {
470
+ console.log(ctx.params); // => { tenantId: 'acme' } (no extraneous keys)
471
+ await next();
472
+ });
473
+
474
+ router.get('/users', (ctx) => {
475
+ console.log(ctx.params); // => { tenantId: 'acme' }
476
+ ctx.body = ctx.params;
477
+ });
478
+ ```
479
+
480
+ This is particularly useful when running **strict parameter validation** inside a `router.use()` middleware — the shape of `ctx.params` is predictable and contains only what you defined.
461
481
 
462
482
  ### URL Parameters
463
483
 
@@ -565,6 +585,8 @@ router.use('/nested', nestedRouter.routes());
565
585
 
566
586
  **Note:** Middleware path boundaries are correctly enforced. Middleware scoped to `/api` will only run for routes matching `/api/*`, not for unrelated routes.
567
587
 
588
+ **Note:** When `router.use()` is used on a router with a parameterized prefix (e.g. `prefix: '/:id'`), `ctx.params` inside that middleware will contain **only** the parameters defined by the prefix and route path — no internal wildcard keys are exposed. See [Router Prefixes](#router-prefixes) for a full example.
589
+
568
590
  ### router.prefix()
569
591
 
570
592
  Set the path prefix for a Router instance after initialization.
package/dist/index.d.mts CHANGED
@@ -748,9 +748,18 @@ declare const Router: RouterConstructor;
748
748
 
749
749
  type RouterOptions = {
750
750
  /**
751
- * 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
752
761
  */
753
- exclusive?: boolean;
762
+ exclusive?: boolean | 'specificity';
754
763
  /**
755
764
  * Prefix for all routes
756
765
  */
@@ -812,6 +821,12 @@ type LayerOptions = {
812
821
  * Ignore captures in route matching
813
822
  */
814
823
  ignoreCaptures?: boolean;
824
+ /**
825
+ * Ignore a generated wildcard route parameter when populating ctx.params
826
+ *
827
+ * @internal
828
+ */
829
+ ignoreWildcardParameter?: string | number;
815
830
  /**
816
831
  * Treat path as a regular expression
817
832
  */
package/dist/index.d.ts CHANGED
@@ -748,9 +748,18 @@ declare const Router: RouterConstructor;
748
748
 
749
749
  type RouterOptions = {
750
750
  /**
751
- * 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
752
761
  */
753
- exclusive?: boolean;
762
+ exclusive?: boolean | 'specificity';
754
763
  /**
755
764
  * Prefix for all routes
756
765
  */
@@ -812,6 +821,12 @@ type LayerOptions = {
812
821
  * Ignore captures in route matching
813
822
  */
814
823
  ignoreCaptures?: boolean;
824
+ /**
825
+ * Ignore a generated wildcard route parameter when populating ctx.params
826
+ *
827
+ * @internal
828
+ */
829
+ ignoreWildcardParameter?: string | number;
815
830
  /**
816
831
  * Treat path as a regular expression
817
832
  */
package/dist/index.js CHANGED
@@ -310,6 +310,9 @@ var Layer = class {
310
310
  const parameterDefinition = this.paramNames[captureIndex];
311
311
  if (parameterDefinition && capturedValue && capturedValue.length > 0) {
312
312
  const parameterName = parameterDefinition.name;
313
+ if (this.opts.ignoreWildcardParameter === parameterName && parameterDefinition.type === "wildcard") {
314
+ continue;
315
+ }
313
316
  parameterValues[parameterName] = safeDecodeURIComponent(capturedValue);
314
317
  }
315
318
  }
@@ -942,9 +945,11 @@ var RouterImplementation = class {
942
945
  finalPath = middlewarePath;
943
946
  usePathToRegexp = false;
944
947
  }
948
+ const ignoreWildcardParameter = effectiveExplicitPath === "" && middlewarePath === "{/*rest}" ? "rest" : void 0;
945
949
  this.register(finalPath, [], middleware, {
946
950
  end: isRootPath,
947
951
  ignoreCaptures: !effectiveHasExplicitPath && !prefixHasParameters,
952
+ ignoreWildcardParameter,
948
953
  pathAsRegExp: usePathToRegexp
949
954
  });
950
955
  }
@@ -966,8 +971,11 @@ var RouterImplementation = class {
966
971
  const previousPrefix = this.opts.prefix || "";
967
972
  this.opts.prefix = normalizedPrefix;
968
973
  for (const route of this.stack) {
969
- if (previousPrefix && typeof route.path === "string" && route.path.startsWith(previousPrefix)) {
970
- route.path = route.path.slice(previousPrefix.length) || "/";
974
+ if (typeof route.path === "string" && previousPrefix) {
975
+ const stripBoundary = previousPrefix + "/";
976
+ if (route.path === previousPrefix || route.path.startsWith(stripBoundary)) {
977
+ route.path = route.path.slice(previousPrefix.length) || "/";
978
+ }
971
979
  }
972
980
  route.setPrefix(normalizedPrefix);
973
981
  }
@@ -1049,9 +1057,22 @@ var RouterImplementation = class {
1049
1057
  * @private
1050
1058
  */
1051
1059
  _buildMiddlewareChain(matchedLayers, requestPath) {
1052
- const layersToExecute = this.opts.exclusive ? [matchedLayers.at(-1)].filter(
1053
- (layer) => layer !== void 0
1054
- ) : matchedLayers;
1060
+ let layersToExecute;
1061
+ if (this.exclusive) {
1062
+ let chosenLayer;
1063
+ if (this.opts.exclusive === "specificity") {
1064
+ for (const layer of matchedLayers) {
1065
+ if (!chosenLayer || layer.paramNames.length < chosenLayer.paramNames.length) {
1066
+ chosenLayer = layer;
1067
+ }
1068
+ }
1069
+ } else {
1070
+ chosenLayer = matchedLayers.at(-1);
1071
+ }
1072
+ layersToExecute = chosenLayer ? [chosenLayer] : [];
1073
+ } else {
1074
+ layersToExecute = matchedLayers;
1075
+ }
1055
1076
  const middlewareChain = [];
1056
1077
  for (const layer of layersToExecute) {
1057
1078
  middlewareChain.push(
@@ -1381,6 +1402,7 @@ var RouterImplementation = class {
1381
1402
  strict: options.strict || false,
1382
1403
  prefix: options.prefix || "",
1383
1404
  ignoreCaptures: options.ignoreCaptures,
1405
+ ignoreWildcardParameter: options.ignoreWildcardParameter,
1384
1406
  pathAsRegExp: options.pathAsRegExp
1385
1407
  });
1386
1408
  }
package/dist/index.mjs CHANGED
@@ -271,6 +271,9 @@ var Layer = class {
271
271
  const parameterDefinition = this.paramNames[captureIndex];
272
272
  if (parameterDefinition && capturedValue && capturedValue.length > 0) {
273
273
  const parameterName = parameterDefinition.name;
274
+ if (this.opts.ignoreWildcardParameter === parameterName && parameterDefinition.type === "wildcard") {
275
+ continue;
276
+ }
274
277
  parameterValues[parameterName] = safeDecodeURIComponent(capturedValue);
275
278
  }
276
279
  }
@@ -903,9 +906,11 @@ var RouterImplementation = class {
903
906
  finalPath = middlewarePath;
904
907
  usePathToRegexp = false;
905
908
  }
909
+ const ignoreWildcardParameter = effectiveExplicitPath === "" && middlewarePath === "{/*rest}" ? "rest" : void 0;
906
910
  this.register(finalPath, [], middleware, {
907
911
  end: isRootPath,
908
912
  ignoreCaptures: !effectiveHasExplicitPath && !prefixHasParameters,
913
+ ignoreWildcardParameter,
909
914
  pathAsRegExp: usePathToRegexp
910
915
  });
911
916
  }
@@ -927,8 +932,11 @@ var RouterImplementation = class {
927
932
  const previousPrefix = this.opts.prefix || "";
928
933
  this.opts.prefix = normalizedPrefix;
929
934
  for (const route of this.stack) {
930
- if (previousPrefix && typeof route.path === "string" && route.path.startsWith(previousPrefix)) {
931
- route.path = route.path.slice(previousPrefix.length) || "/";
935
+ if (typeof route.path === "string" && previousPrefix) {
936
+ const stripBoundary = previousPrefix + "/";
937
+ if (route.path === previousPrefix || route.path.startsWith(stripBoundary)) {
938
+ route.path = route.path.slice(previousPrefix.length) || "/";
939
+ }
932
940
  }
933
941
  route.setPrefix(normalizedPrefix);
934
942
  }
@@ -1010,9 +1018,22 @@ var RouterImplementation = class {
1010
1018
  * @private
1011
1019
  */
1012
1020
  _buildMiddlewareChain(matchedLayers, requestPath) {
1013
- const layersToExecute = this.opts.exclusive ? [matchedLayers.at(-1)].filter(
1014
- (layer) => layer !== void 0
1015
- ) : matchedLayers;
1021
+ let layersToExecute;
1022
+ if (this.exclusive) {
1023
+ let chosenLayer;
1024
+ if (this.opts.exclusive === "specificity") {
1025
+ for (const layer of matchedLayers) {
1026
+ if (!chosenLayer || layer.paramNames.length < chosenLayer.paramNames.length) {
1027
+ chosenLayer = layer;
1028
+ }
1029
+ }
1030
+ } else {
1031
+ chosenLayer = matchedLayers.at(-1);
1032
+ }
1033
+ layersToExecute = chosenLayer ? [chosenLayer] : [];
1034
+ } else {
1035
+ layersToExecute = matchedLayers;
1036
+ }
1016
1037
  const middlewareChain = [];
1017
1038
  for (const layer of layersToExecute) {
1018
1039
  middlewareChain.push(
@@ -1342,6 +1363,7 @@ var RouterImplementation = class {
1342
1363
  strict: options.strict || false,
1343
1364
  prefix: options.prefix || "",
1344
1365
  ignoreCaptures: options.ignoreCaptures,
1366
+ ignoreWildcardParameter: options.ignoreWildcardParameter,
1345
1367
  pathAsRegExp: options.pathAsRegExp
1346
1368
  });
1347
1369
  }
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.4.0",
4
+ "version": "15.6.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": "^21.0.2",
35
+ "@commitlint/config-conventional": "^21.0.2",
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.3",
41
+ "@types/node": "^25.9.1",
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.60.0",
44
+ "@typescript-eslint/parser": "^8.60.0",
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.4.1",
48
+ "eslint-plugin-unicorn": "^64.0.0",
49
49
  "husky": "^9.1.7",
50
- "joi": "^18.0.2",
50
+ "joi": "^18.2.1",
51
51
  "jsonwebtoken": "^9.0.3",
52
- "koa": "^3.1.2",
53
- "lint-staged": "^16.4.0",
54
- "np": "^11.0.2",
55
- "prettier": "^3.8.1",
52
+ "koa": "^3.2.1",
53
+ "lint-staged": "^17.0.6",
54
+ "np": "^11.2.1",
55
+ "prettier": "^3.8.3",
56
56
  "rimraf": "^6.1.3",
57
57
  "supertest": "^7.2.2",
58
58
  "ts-node": "^10.9.2",