VYPR
High severity7.5NVD Advisory· Published Sep 9, 2024· Updated Apr 15, 2026

CVE-2024-45296

CVE-2024-45296

Description

path-to-regexp turns path strings into a regular expressions. In certain cases, path-to-regexp will output a regular expression that can be exploited to cause poor performance. Because JavaScript is single threaded and regex matching runs on the main thread, poor performance will block the event loop and lead to a DoS. The bad regular expression is generated any time you have two parameters within a single segment, separated by something that is not a period (.). For users of 0.1, upgrade to 0.1.10. All other users should upgrade to 8.0.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
path-to-regexpnpm
>= 0.2.0, < 1.9.01.9.0
path-to-regexpnpm
< 0.1.100.1.10
path-to-regexpnpm
>= 7.0.0, < 8.0.08.0.0
path-to-regexpnpm
>= 2.0.0, < 3.3.03.3.0
path-to-regexpnpm
>= 4.0.0, < 6.3.06.3.0

Patches

7
f1253b47b347

Add backtrack protection to 6.x (#324)

https://github.com/pillarjs/path-to-regexpBlake EmbreySep 12, 2024via ghsa
5 files changed · +184 55
  • package.json+2 1 modified
    @@ -36,6 +36,7 @@
         "@types/node": "^20.4.9",
         "@types/semver": "^7.3.1",
         "@vitest/coverage-v8": "^1.4.0",
    +    "recheck": "^4.4.5",
         "semver": "^7.3.5",
         "size-limit": "^11.1.2",
         "typescript": "^5.1.6"
    @@ -46,7 +47,7 @@
       "size-limit": [
         {
           "path": "dist.es2015/index.js",
    -      "limit": "2 kB"
    +      "limit": "2.1 kB"
         }
       ],
       "ts-scripts": {
    
  • package-lock.json+102 0 modified
    @@ -14,6 +14,7 @@
             "@types/node": "^20.4.9",
             "@types/semver": "^7.3.1",
             "@vitest/coverage-v8": "^1.4.0",
    +        "recheck": "^4.4.5",
             "semver": "^7.3.5",
             "size-limit": "^11.1.2",
             "typescript": "^5.1.6"
    @@ -2682,6 +2683,67 @@
             "node": ">=8.10.0"
           }
         },
    +    "node_modules/recheck": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck/-/recheck-4.4.5.tgz",
    +      "integrity": "sha512-J80Ykhr+xxWtvWrfZfPpOR/iw2ijvb4WY8d9AVoN8oHsPP07JT1rCAalUSACMGxM1cvSocb6jppWFjVS6eTTrA==",
    +      "dev": true,
    +      "engines": {
    +        "node": ">=14"
    +      },
    +      "optionalDependencies": {
    +        "recheck-jar": "4.4.5",
    +        "recheck-linux-x64": "4.4.5",
    +        "recheck-macos-x64": "4.4.5",
    +        "recheck-windows-x64": "4.4.5"
    +      }
    +    },
    +    "node_modules/recheck-jar": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-jar/-/recheck-jar-4.4.5.tgz",
    +      "integrity": "sha512-a2kMzcfr+ntT0bObNLY22EUNV6Z6WeZ+DybRmPOUXVWzGcqhRcrK74tpgrYt3FdzTlSh85pqoryAPmrNkwLc0g==",
    +      "dev": true,
    +      "optional": true
    +    },
    +    "node_modules/recheck-linux-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-linux-x64/-/recheck-linux-x64-4.4.5.tgz",
    +      "integrity": "sha512-s8OVPCpiSGw+tLCxH3eei7Zp2AoL22kXqLmEtWXi0AnYNwfuTjZmeLn2aQjW8qhs8ZPSkxS7uRIRTeZqR5Fv/Q==",
    +      "cpu": [
    +        "x64"
    +      ],
    +      "dev": true,
    +      "optional": true,
    +      "os": [
    +        "linux"
    +      ]
    +    },
    +    "node_modules/recheck-macos-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-macos-x64/-/recheck-macos-x64-4.4.5.tgz",
    +      "integrity": "sha512-Ouup9JwwoKCDclt3Na8+/W2pVbt8FRpzjkDuyM32qTR2TOid1NI+P1GA6/VQAKEOjvaxgGjxhcP/WqAjN+EULA==",
    +      "cpu": [
    +        "x64"
    +      ],
    +      "dev": true,
    +      "optional": true,
    +      "os": [
    +        "darwin"
    +      ]
    +    },
    +    "node_modules/recheck-windows-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-windows-x64/-/recheck-windows-x64-4.4.5.tgz",
    +      "integrity": "sha512-mkpzLHu9G9Ztjx8HssJh9k/Xm1d1d/4OoT7etHqFk+k1NGzISCRXBD22DqYF9w8+J4QEzTAoDf8icFt0IGhOEQ==",
    +      "cpu": [
    +        "x64"
    +      ],
    +      "dev": true,
    +      "optional": true,
    +      "os": [
    +        "win32"
    +      ]
    +    },
         "node_modules/restore-cursor": {
           "version": "4.0.0",
           "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
    @@ -5422,6 +5484,46 @@
             "picomatch": "^2.2.1"
           }
         },
    +    "recheck": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck/-/recheck-4.4.5.tgz",
    +      "integrity": "sha512-J80Ykhr+xxWtvWrfZfPpOR/iw2ijvb4WY8d9AVoN8oHsPP07JT1rCAalUSACMGxM1cvSocb6jppWFjVS6eTTrA==",
    +      "dev": true,
    +      "requires": {
    +        "recheck-jar": "4.4.5",
    +        "recheck-linux-x64": "4.4.5",
    +        "recheck-macos-x64": "4.4.5",
    +        "recheck-windows-x64": "4.4.5"
    +      }
    +    },
    +    "recheck-jar": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-jar/-/recheck-jar-4.4.5.tgz",
    +      "integrity": "sha512-a2kMzcfr+ntT0bObNLY22EUNV6Z6WeZ+DybRmPOUXVWzGcqhRcrK74tpgrYt3FdzTlSh85pqoryAPmrNkwLc0g==",
    +      "dev": true,
    +      "optional": true
    +    },
    +    "recheck-linux-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-linux-x64/-/recheck-linux-x64-4.4.5.tgz",
    +      "integrity": "sha512-s8OVPCpiSGw+tLCxH3eei7Zp2AoL22kXqLmEtWXi0AnYNwfuTjZmeLn2aQjW8qhs8ZPSkxS7uRIRTeZqR5Fv/Q==",
    +      "dev": true,
    +      "optional": true
    +    },
    +    "recheck-macos-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-macos-x64/-/recheck-macos-x64-4.4.5.tgz",
    +      "integrity": "sha512-Ouup9JwwoKCDclt3Na8+/W2pVbt8FRpzjkDuyM32qTR2TOid1NI+P1GA6/VQAKEOjvaxgGjxhcP/WqAjN+EULA==",
    +      "dev": true,
    +      "optional": true
    +    },
    +    "recheck-windows-x64": {
    +      "version": "4.4.5",
    +      "resolved": "https://registry.npmjs.org/recheck-windows-x64/-/recheck-windows-x64-4.4.5.tgz",
    +      "integrity": "sha512-mkpzLHu9G9Ztjx8HssJh9k/Xm1d1d/4OoT7etHqFk+k1NGzISCRXBD22DqYF9w8+J4QEzTAoDf8icFt0IGhOEQ==",
    +      "dev": true,
    +      "optional": true
    +    },
         "restore-cursor": {
           "version": "4.0.0",
           "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
    
  • redos.ts+21 0 added
    @@ -0,0 +1,21 @@
    +import { checkSync } from "recheck";
    +import { pathToRegexp } from "./src/index.js";
    +
    +let safe = 0;
    +let fail = 0;
    +
    +const tests = ["/:x{/foobar/:y}?-:z"];
    +
    +for (const path of tests) {
    +  const regexp = pathToRegexp(path);
    +  const result = checkSync(regexp.source, regexp.flags);
    +  if (result.status === "safe") {
    +    safe++;
    +    console.log("Safe:", path, String(regexp));
    +  } else {
    +    fail++;
    +    console.log("Fail:", path, String(regexp));
    +  }
    +}
    +
    +console.log("Safe:", safe, "Fail:", fail);
    
  • src/index.spec.ts+32 47 modified
    @@ -1353,7 +1353,7 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "+",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
         ],
         [
    @@ -1397,7 +1397,7 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
           ".",
         ],
    @@ -1430,13 +1430,13 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
         ],
         [
           ["/route.html", ["/route.html", "route", "html"]],
           ["/route", null],
    -      ["/route.html.json", ["/route.html.json", "route", "html.json"]],
    +      ["/route.html.json", ["/route.html.json", "route.html", "json"]],
         ],
         [
           [{}, null],
    @@ -1459,13 +1459,13 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "?",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
         ],
         [
           ["/route", ["/route", "route", undefined]],
           ["/route.json", ["/route.json", "route", "json"]],
    -      ["/route.json.html", ["/route.json.html", "route", "json.html"]],
    +      ["/route.json.html", ["/route.json.html", "route.json", "html"]],
         ],
         [
           [{ test: "route" }, "/route"],
    @@ -1491,13 +1491,13 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "?",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
         ],
         [
           ["/route", ["/route", "route", undefined]],
           ["/route.json", ["/route.json", "route", "json"]],
    -      ["/route.json.html", ["/route.json.html", "route", "json.html"]],
    +      ["/route.json.html", ["/route.json.html", "route.json", "html"]],
         ],
         [
           [{ test: "route" }, "/route"],
    @@ -2084,7 +2084,7 @@ const TESTS: Test[] = [
             prefix: "",
             suffix: "",
             modifier: "?",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\()[^\\/#\\?])+?",
           },
           ")",
         ],
    @@ -2290,7 +2290,7 @@ const TESTS: Test[] = [
             prefix: ".",
             suffix: "",
             modifier: "",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\.)[^\\/#\\?])+?",
           },
         ],
         [
    @@ -2356,14 +2356,14 @@ const TESTS: Test[] = [
         [
           {
             name: "foo",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\$)[^\\/#\\?])+?",
             prefix: "$",
             suffix: "",
             modifier: "",
           },
           {
             name: "bar",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!\\$)[^\\/#\\?])+?",
             prefix: "$",
             suffix: "",
             modifier: "?",
    @@ -2392,14 +2392,14 @@ const TESTS: Test[] = [
           },
           {
             name: "attr2",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!-)[^\\/#\\?])+?",
             prefix: "-",
             suffix: "",
             modifier: "?",
           },
           {
             name: "attr3",
    -        pattern: "[^\\/#\\?]+?",
    +        pattern: "(?:(?!-)[^\\/#\\?])+?",
             prefix: "-",
             suffix: "",
             modifier: "?",
    @@ -2597,39 +2597,6 @@ const TESTS: Test[] = [
           [{ foo: "#" }, null],
         ],
       ],
    -  /**
    -   * https://github.com/pillarjs/path-to-regexp/issues/260
    -   */
    -  [
    -    ":name*",
    -    undefined,
    -    [
    -      {
    -        name: "name",
    -        prefix: "",
    -        suffix: "",
    -        modifier: "*",
    -        pattern: "[^\\/#\\?]+?",
    -      },
    -    ],
    -    [["foobar", ["foobar", "foobar"]]],
    -    [[{ name: "foobar" }, "foobar"]],
    -  ],
    -  [
    -    ":name+",
    -    undefined,
    -    [
    -      {
    -        name: "name",
    -        prefix: "",
    -        suffix: "",
    -        modifier: "+",
    -        pattern: "[^\\/#\\?]+?",
    -      },
    -    ],
    -    [["foobar", ["foobar", "foobar"]]],
    -    [[{ name: "foobar" }, "foobar"]],
    -  ],
     ];
     
     /**
    @@ -2799,6 +2766,24 @@ describe("path-to-regexp", () => {
             pathToRegexp.pathToRegexp("/foo?");
           }).toThrow(new TypeError("Unexpected MODIFIER at 4, expected END"));
         });
    +
    +    it("should throw on parameters without text between them", () => {
    +      expect(() => {
    +        pathToRegexp.pathToRegexp("/:x:y");
    +      }).toThrow(
    +        new TypeError(
    +          `Must have text between two parameters, missing text after "x"`,
    +        ),
    +      );
    +    });
    +
    +    it("should throw on unrepeatable params", () => {
    +      expect(() => {
    +        pathToRegexp.pathToRegexp("/foo:x*");
    +      }).toThrow(
    +        new TypeError(`Can not repeat "x" without a prefix and suffix`),
    +      );
    +    });
       });
     
       describe("tokens", () => {
    
  • src/index.ts+27 7 modified
    @@ -139,8 +139,7 @@ export interface ParseOptions {
      */
     export function parse(str: string, options: ParseOptions = {}): Token[] {
       const tokens = lexer(str);
    -  const { prefixes = "./" } = options;
    -  const defaultPattern = `[^${escapeString(options.delimiter || "/#?")}]+?`;
    +  const { prefixes = "./", delimiter = "/#?" } = options;
       const result: Token[] = [];
       let key = 0;
       let i = 0;
    @@ -166,6 +165,25 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
         return result;
       };
     
    +  const isSafe = (value: string): boolean => {
    +    for (const char of delimiter) if (value.indexOf(char) > -1) return true;
    +    return false;
    +  };
    +
    +  const safePattern = (prefix: string) => {
    +    const prev = result[result.length - 1];
    +    const prevText = prefix || (prev && typeof prev === "string" ? prev : "");
    +
    +    if (prev && !prevText) {
    +      throw new TypeError(
    +        `Must have text between two parameters, missing text after "${(prev as Key).name}"`,
    +      );
    +    }
    +
    +    if (!prevText || isSafe(prevText)) return `[^${escapeString(delimiter)}]+?`;
    +    return `(?:(?!${escapeString(prevText)})[^${escapeString(delimiter)}])+?`;
    +  };
    +
       while (i < tokens.length) {
         const char = tryConsume("CHAR");
         const name = tryConsume("NAME");
    @@ -188,7 +206,7 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
             name: name || key++,
             prefix,
             suffix: "",
    -        pattern: pattern || defaultPattern,
    +        pattern: pattern || safePattern(prefix),
             modifier: tryConsume("MODIFIER") || "",
           });
           continue;
    @@ -216,7 +234,7 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
     
           result.push({
             name: name || (pattern ? key++ : ""),
    -        pattern: name && !pattern ? defaultPattern : pattern,
    +        pattern: name && !pattern ? safePattern(prefix) : pattern,
             prefix,
             suffix,
             modifier: tryConsume("MODIFIER") || "",
    @@ -564,10 +582,12 @@ export function tokensToRegexp(
               }
             } else {
               if (token.modifier === "+" || token.modifier === "*") {
    -            route += `((?:${token.pattern})${token.modifier})`;
    -          } else {
    -            route += `(${token.pattern})${token.modifier}`;
    +            throw new TypeError(
    +              `Can not repeat "${token.name}" without a prefix and suffix`,
    +            );
               }
    +
    +          route += `(${token.pattern})${token.modifier}`;
             }
           } else {
             route += `(?:${prefix}${suffix})${token.modifier}`;
    
d31670ae8f6e

Add backtrack protection to 3.x release (#321)

https://github.com/pillarjs/path-to-regexpBlake EmbreySep 10, 2024via ghsa
3 files changed · +2358 1564
  • index.js+12 1 modified
    @@ -90,6 +90,7 @@ function parse (str, options) {
         var optional = modifier === '?' || modifier === '*'
         var pattern = capture || group
         var delimiter = prev || defaultDelimiter
    +    var prevText = prev || (typeof tokens[tokens.length - 1] === 'string' ? tokens[tokens.length - 1] : '')
     
         tokens.push({
           name: name || key++,
    @@ -99,7 +100,7 @@ function parse (str, options) {
           repeat: repeat,
           pattern: pattern
             ? escapeGroup(pattern)
    -        : '[^' + escapeString(delimiter === defaultDelimiter ? delimiter : (delimiter + defaultDelimiter)) + ']+?'
    +        : restrictBacktrack(delimiter, defaultDelimiter, prevText)
         })
       }
     
    @@ -111,6 +112,16 @@ function parse (str, options) {
       return tokens
     }
     
    +function restrictBacktrack (delimiter, defaultDelimiter, prevText) {
    +  var charGroup = '[^' + escapeString(delimiter === defaultDelimiter ? delimiter : (delimiter + defaultDelimiter)) + ']'
    +
    +  if (!prevText || prevText.indexOf(delimiter) > -1 || prevText.indexOf(defaultDelimiter) > -1) {
    +    return charGroup + '+?'
    +  }
    +
    +  return escapeString(prevText) + '|(?:(?!' + escapeString(prevText) + ')' + charGroup + ')+?'
    +}
    +
     /**
      * Compile a string to a template function for the path.
      *
    
  • package-lock.json+2343 1560 modified
  • test.ts+3 3 modified
    @@ -2206,7 +2206,7 @@ var TESTS: Test[] = [
             delimiter: '/',
             optional: true,
             repeat: false,
    -        pattern: '[^\\/]+?'
    +        pattern: '\\(|(?:(?!\\()[^\\/])+?'
           },
           ')'
         ],
    @@ -2633,7 +2633,7 @@ var TESTS: Test[] = [
             delimiter: '/',
             name: 'attr2',
             optional: true,
    -        pattern: '[^\\/]+?',
    +        pattern: '-|(?:(?!-)[^\\/])+?',
             prefix: '',
             repeat: false
           }
    @@ -2642,7 +2642,7 @@ var TESTS: Test[] = [
           ['name/1', null],
           ['name/1-', ['name/1-', '1', undefined]],
           ['name/1-2', ['name/1-2', '1', '2']],
    -      ['name/1-2-3', ['name/1-2-3', '1', '2-3']],
    +      ['name/1-2-3', ['name/1-2-3', '1-2', '3']],
           ['name/foo-bar/route', null],
           ['name/test/route', null]
         ],
    
925ac8e3c578

Add backtrack protection to 1.x release (#320)

https://github.com/pillarjs/path-to-regexpBlake EmbreySep 10, 2024via ghsa
5 files changed · +4250 36
  • index.js+11 2 modified
    @@ -72,8 +72,9 @@ function parse (str, options) {
         var partial = prefix != null && next != null && next !== prefix
         var repeat = modifier === '+' || modifier === '*'
         var optional = modifier === '?' || modifier === '*'
    -    var delimiter = res[2] || defaultDelimiter
    +    var delimiter = prefix || defaultDelimiter
         var pattern = capture || group
    +    var prevText = prefix || (typeof tokens[tokens.length - 1] === 'string' ? tokens[tokens.length - 1] : '')
     
         tokens.push({
           name: name || key++,
    @@ -83,7 +84,7 @@ function parse (str, options) {
           repeat: repeat,
           partial: partial,
           asterisk: !!asterisk,
    -      pattern: pattern ? escapeGroup(pattern) : (asterisk ? '.*' : '[^' + escapeString(delimiter) + ']+?')
    +      pattern: pattern ? escapeGroup(pattern) : (asterisk ? '.*' : restrictBacktrack(delimiter, prevText))
         })
       }
     
    @@ -100,6 +101,14 @@ function parse (str, options) {
       return tokens
     }
     
    +function restrictBacktrack(delimiter, prevText) {
    +  if (!prevText || prevText.indexOf(delimiter) > -1) {
    +    return '[^' + escapeString(delimiter) + ']+?'
    +  }
    +
    +  return escapeString(prevText) + '|(?:(?!' + escapeString(prevText) + ')[^' + escapeString(delimiter) + '])+?'
    +}
    +
     /**
      * Compile a string to a template function for the path.
      *
    
  • package.json+4 6 modified
    @@ -11,10 +11,9 @@
       ],
       "scripts": {
         "lint": "standard",
    -    "test-spec": "mocha --require ts-node/register -R spec --bail test.ts",
    -    "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --require ts-node/register -R spec test.ts",
    -    "prepublish": "typings install",
    -    "test": "npm run lint && npm run test-cov"
    +    "test-spec": "mocha -R spec --bail test.js",
    +    "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec test.js",
    +    "test": "npm run test-cov"
       },
       "keywords": [
         "express",
    @@ -38,8 +37,7 @@
         "mocha": "~2.2.4",
         "standard": "~3.7.3",
         "ts-node": "^0.5.5",
    -    "typescript": "^1.8.7",
    -    "typings": "^1.0.4"
    +    "typescript": "^1.8.7"
       },
       "dependencies": {
         "isarray": "0.0.1"
    
  • package-lock.json+4226 0 added
  • test.js+9 19 renamed
    @@ -1,27 +1,17 @@
     /* global describe, it */
     
    -/// <reference path="typings/index.d.ts" />
    -
    -import util = require('util')
    -import chai = require('chai')
    -import pathToRegexp = require('./index')
    +const util = require('util')
    +const chai = require('chai')
    +const pathToRegexp = require('./index')
     
     const expect = chai.expect
     
    -type Test = [
    -  pathToRegexp.Path,
    -  pathToRegexp.RegExpOptions & pathToRegexp.ParseOptions,
    -  pathToRegexp.Token[],
    -  Array<[string, string[]]>,
    -  Array<[any, string]>
    -]
    -
     /**
      * An array of test cases with expected inputs and outputs.
      *
      * @type {Array}
      */
    -var TESTS: Test[] = [
    +var TESTS = [
       /**
        * Simple paths.
        */
    @@ -2062,7 +2052,7 @@ var TESTS: Test[] = [
             repeat: false,
             partial: false,
             asterisk: false,
    -        pattern: '[^\\/]+?'
    +        pattern: '\\(|(?:(?!\\()[^\\/])+?'
           },
           ')'
         ],
    @@ -2376,27 +2366,25 @@ describe('path-to-regexp', function () {
           })
     
           describe(util.inspect(path), function () {
    -        var re = pathToRegexp(path as string, opts)
    -
             // Parsing and compiling is only supported with string input.
             if (typeof path === 'string') {
               it('should parse', function () {
                 expect(pathToRegexp.parse(path, opts)).to.deep.equal(tokens)
               })
     
               describe('compile', function () {
    -            var toPath = pathToRegexp.compile(path as string, opts)
    -
                 compileCases.forEach(function (io) {
                   var input = io[0]
                   var output = io[1]
     
                   if (output != null) {
                     it('should compile using ' + util.inspect(input), function () {
    +                  var toPath = pathToRegexp.compile(path, opts)
                       expect(toPath(input)).to.equal(output)
                     })
                   } else {
                     it('should not compile using ' + util.inspect(input), function () {
    +                  var toPath = pathToRegexp.compile(path, opts)
                       expect(function () {
                         toPath(input)
                       }).to.throw(TypeError)
    @@ -2406,6 +2394,7 @@ describe('path-to-regexp', function () {
               })
             } else {
               it('should parse keys', function () {
    +            var re = pathToRegexp(path, opts)
                 expect(re.keys).to.deep.equal(keys)
               })
             }
    @@ -2417,6 +2406,7 @@ describe('path-to-regexp', function () {
                 var message = 'should' + (output ? ' ' : ' not ') + 'match ' + util.inspect(input)
     
                 it(message, function () {
    +              var re = pathToRegexp(path, opts)
                   expect(exec(re, input)).to.deep.equal(output)
                 })
               })
    
  • typings.json+0 9 removed
    @@ -1,9 +0,0 @@
    -{
    -  "devDependencies": {
    -    "chai": "registry:npm/chai#3.5.0+20160415060238"
    -  },
    -  "globalDevDependencies": {
    -    "mocha": "registry:env/mocha#2.2.5+20160321223601",
    -    "node": "registry:env/node#4.0.0+20160507210304"
    -  }
    -}
    
60f2121e9b66

Rewrite and simplify API

https://github.com/pillarjs/path-to-regexpBlake EmbreySep 1, 2024via ghsa
8 files changed · +819 1767
  • package.json+1 0 modified
    @@ -20,6 +20,7 @@
         "dist/"
       ],
       "scripts": {
    +    "bench": "vitest bench",
         "build": "ts-scripts build",
         "format": "ts-scripts format",
         "lint": "ts-scripts lint",
    
  • Readme.md+49 182 modified
    @@ -24,29 +24,9 @@ const { match, compile, parse } = require("path-to-regexp");
     // parse(path, options?)
     ```
     
    -### Match
    -
    -The `match` function returns a function for transforming paths into parameters:
    -
    -- **path** A string.
    -- **options** _(optional)_ (See [parse](#parse) for more options)
    -  - **sensitive** Regexp will be case sensitive. (default: `false`)
    -  - **end** Validate the match reaches the end of the string. (default: `true`)
    -  - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`)
    -
    -```js
    -const fn = match("/foo/:bar");
    -```
    -
    -**Please note:** `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc).
    -
     ### Parameters
     
    -Parameters match arbitrary strings in a path by matching up to the end of the segment, or up to any proceeding tokens.
    -
    -#### Named parameters
    -
    -Named parameters are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid unicode identifier characters, similar to JavaScript.
    +Parameters match arbitrary strings in a path by matching up to the end of the segment, or up to any proceeding tokens. They are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid JavaScript identifier, or be double quoted to use other characters (`:"param-name"`).
     
     ```js
     const fn = match("/:foo/:bar");
    @@ -55,137 +35,54 @@ fn("/test/route");
     //=> { path: '/test/route', params: { foo: 'test', bar: 'route' } }
     ```
     
    -##### Custom matching parameters
    -
    -Parameters can have a custom regexp, which overrides the default match (`[^/]+`). For example, you can match digits or names in a path:
    -
    -```js
    -const exampleNumbers = match("/icon-:foo(\\d+).png");
    -
    -exampleNumbers("/icon-123.png");
    -//=> { path: '/icon-123.png', params: { foo: '123' } }
    -
    -exampleNumbers("/icon-abc.png");
    -//=> false
    -
    -const exampleWord = pathToRegexp("/(user|u)");
    -
    -exampleWord("/u");
    -//=> { path: '/u', params: { '0': 'u' } }
    -
    -exampleWord("/users");
    -//=> false
    -```
    -
    -**Tip:** Backslashes need to be escaped with another backslash in JavaScript strings.
    -
    -#### Unnamed parameters
    -
    -It is possible to define a parameter without a name. The name will be numerically indexed:
    -
    -```js
    -const fn = match("/:foo/(.*)");
    -
    -fn("/test/route");
    -//=> { path: '/test/route', params: { '0': 'route', foo: 'test' } }
    -```
    -
    -#### Custom prefix and suffix
    -
    -Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment:
    -
    -```js
    -const fn = match("{/:attr1}?{-:attr2}?{-:attr3}?");
    -
    -fn("/test");
    -//=> { path: '/test', params: { attr1: 'test' } }
    -
    -fn("/test-test");
    -//=> { path: '/test-test', params: { attr1: 'test', attr2: 'test' } }
    -```
    -
    -#### Modifiers
    -
    -Modifiers are used after parameters with custom prefixes and suffixes (`{}`).
    -
    -##### Optional
    -
    -Parameters can be suffixed with a question mark (`?`) to make the parameter optional.
    -
    -```js
    -const fn = match("/:foo{/:bar}?");
    -
    -fn("/test");
    -//=> { path: '/test', params: { foo: 'test' } }
    -
    -fn("/test/route");
    -//=> { path: '/test/route', params: { foo: 'test', bar: 'route' } }
    -```
    -
    -##### Zero or more
    +### Wildcard
     
    -Parameters can be suffixed with an asterisk (`*`) to denote a zero or more parameter matches.
    +Wildcard parameters match one or more characters across multiple segments. They are defined the same way as regular parameters, but are prefixed with an asterisk (`*foo`).
     
     ```js
    -const fn = match("{/:foo}*");
    -
    -fn("/foo");
    -//=> { path: '/foo', params: { foo: [ 'foo' ] } }
    +const fn = match("/*splat");
     
     fn("/bar/baz");
    -//=> { path: '/bar/baz', params: { foo: [ 'bar', 'baz' ] } }
    +//=> { path: '/bar/baz', params: { splat: [ 'bar', 'baz' ] } }
     ```
     
    -##### One or more
    +### Optional
     
    -Parameters can be suffixed with a plus sign (`+`) to denote a one or more parameter matches.
    +Braces can be used to define parts of the path that are optional.
     
     ```js
    -const fn = match("{/:foo}+");
    +const fn = match("/users{/:id}/delete");
     
    -fn("/");
    -//=> false
    +fn("/users/delete");
    +//=> { path: '/users/delete', params: {} }
     
    -fn("/bar/baz");
    -//=> { path: '/bar/baz', params: { foo: [ 'bar', 'baz' ] } }
    +fn("/users/123/delete");
    +//=> { path: '/users/123/delete', params: { id: '123' } }
     ```
     
    -##### Custom separator
    -
    -By default, parameters set the separator as the `prefix + suffix` of the token. Using `;` you can modify this:
    -
    -```js
    -const fn = match("/name{/:parts;-}+");
    +## Match
     
    -fn("/name");
    -//=> false
    +The `match` function returns a function for matching strings against a path:
     
    -fn("/bar/1-2-3");
    -//=> { path: '/name/1-2-3', params: { parts: [ '1', '2', '3' ] } }
    -```
    -
    -#### Wildcard
    -
    -A wildcard is also supported. It is roughly equivalent to `(.*)`.
    +- **path** String or array of strings.
    +- **options** _(optional)_ (See [parse](#parse) for more options)
    +  - **sensitive** Regexp will be case sensitive. (default: `false`)
    +  - **end** Validate the match reaches the end of the string. (default: `true`)
    +  - **trailing** Allows optional trailing delimiter to match. (default: `true`)
    +  - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`)
     
     ```js
    -const fn = match("/*");
    -
    -fn("/");
    -//=> { path: '/', params: {} }
    -
    -fn("/bar/baz");
    -//=> { path: '/bar/baz', params: { '0': [ 'bar', 'baz' ] } }
    +const fn = match("/foo/:bar");
     ```
     
    -### Compile ("Reverse" Path-To-RegExp)
    +**Please note:** `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc).
    +
    +## Compile ("Reverse" Path-To-RegExp)
     
     The `compile` function will return a function for transforming parameters into a valid path:
     
     - **path** A string.
     - **options** (See [parse](#parse) for more options)
    -  - **sensitive** Regexp will be case sensitive. (default: `false`)
    -  - **validate** When `false` the function can produce an invalid (unmatched) path. (default: `true`)
       - **encode** Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`)
     
     ```js
    @@ -194,46 +91,34 @@ const toPath = compile("/user/:id");
     toPath({ id: "name" }); //=> "/user/name"
     toPath({ id: "café" }); //=> "/user/caf%C3%A9"
     
    -// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted.
    -const toPathRaw = compile("/user/:id", { encode: false });
    -
    -toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F"
    -toPathRaw({ id: ":/" }); //=> Throws, "/user/:/" when `validate` is `false`.
    -
    -const toPathRepeated = compile("{/:segment}+");
    +const toPathRepeated = compile("/*segment");
     
     toPathRepeated({ segment: ["foo"] }); //=> "/foo"
     toPathRepeated({ segment: ["a", "b", "c"] }); //=> "/a/b/c"
     
    -const toPathRegexp = compile("/user/:id(\\d+)");
    +// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted.
    +const toPathRaw = compile("/user/:id", { encode: false });
     
    -toPathRegexp({ id: "123" }); //=> "/user/123"
    +toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F"
     ```
     
     ## Developers
     
     - If you are rewriting paths with match and compile, consider using `encode: false` and `decode: false` to keep raw paths passed around.
    -- To ensure matches work on paths containing characters usually encoded, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`.
    +- To ensure matches work on paths containing characters usually encoded, such as emoji, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`.
     
     ### Parse
     
    -The `parse` function accepts a string and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can used with `$match` and `$compile`.
    +The `parse` function accepts a string and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can used with `match` and `compile`.
     
     - **path** A string.
     - **options** _(optional)_
       - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`)
    -  - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl) for unicode encoding)
    +  - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl))
     
     ### Tokens
     
    -The `tokens` returned by `TokenData` is an array of strings or keys, represented as objects, with the following properties:
    -
    -- `name` The name of the token
    -- `prefix` _(optional)_ The prefix string for the segment (e.g. `"/"`)
    -- `suffix` _(optional)_ The suffix string for the segment (e.g. `""`)
    -- `pattern` _(optional)_ The pattern defined to match this token
    -- `modifier` _(optional)_ The modifier character used for the segment (e.g. `?`)
    -- `separator` _(optional)_ The string used to separate repeated parameters
    +`TokenData` is a sequence of tokens, currently of types `text`, `parameter`, `wildcard`, or `group`.
     
     ### Custom path
     
    @@ -242,9 +127,12 @@ In some applications, you may not be able to use the `path-to-regexp` syntax, bu
     ```js
     import { TokenData, match } from "path-to-regexp";
     
    -const tokens = ["/", { name: "foo" }];
    -const path = new TokenData(tokens, "/");
    -const fn = $match(path);
    +const tokens = [
    +  { type: "text", value: "/" },
    +  { type: "parameter", name: "foo" },
    +];
    +const path = new TokenData(tokens);
    +const fn = match(path);
     
     fn("/test"); //=> { path: '/test', index: 0, params: { foo: 'test' } }
     ```
    @@ -253,55 +141,34 @@ fn("/test"); //=> { path: '/test', index: 0, params: { foo: 'test' } }
     
     An effort has been made to ensure ambiguous paths from previous releases throw an error. This means you might be seeing an error when things worked before.
     
    -### Unexpected `?`, `*`, or `+`
    -
    -In previous major versions `/` and `.` were used as implicit prefixes of parameters. So `/:key?` was implicitly `{/:key}?`. For example:
    -
    -- `/:key?` → `{/:key}?` or `/:key*` → `{/:key}*` or `/:key+` → `{/:key}+`
    -- `.:key?` → `{.:key}?` or `.:key*` → `{.:key}*` or `.:key+` → `{.:key}+`
    -- `:key?` → `{:key}?` or `:key*` → `{:key}*` or `:key+` → `{:key}+`
    +### Unexpected `?` or `+`
     
    -### Unexpected `;`
    +In past releases, `?`, `*`, and `+` were used to denote optional or repeating parameters. As an alternative, try these:
     
    -Used as a [custom separator](#custom-separator) for repeated parameters.
    +- For optional (`?`), use an empty segment in a group such as `/:file{.:ext}`.
    +- For repeating (`+`), only wildcard matching is supported, such as `/*path`.
    +- For optional repeating (`*`), use a group and a wildcard parameter such as `/files{/*path}`.
     
    -### Unexpected `!`, `@`, or `,`
    +### Unexpected `(`, `)`, `[`, `]`, etc.
     
    -These characters have been reserved for future use.
    -
    -### Missing separator
    -
    -Repeated parameters must have a separator to be valid. For example, `{:foo}*` can't be used. Separators can be defined manually, such as `{:foo;/}*`, or they default to the suffix and prefix with the parameter, such as `{/:foo}*`.
    +Previous versions of Path-to-RegExp used these for RegExp features. This version no longer supports them so they've been reserved to avoid ambiguity. To use these characters literally, escape them with a backslash, e.g. `"\\("`.
     
     ### Missing parameter name
     
    -Parameter names, the part after `:`, must be a valid JavaScript identifier. For example, it cannot start with a number or dash. If you want a parameter name that uses these characters you can wrap the name in quotes, e.g. `:"my-name"`.
    +Parameter names, the part after `:` or `*`, must be a valid JavaScript identifier. For example, it cannot start with a number or contain a dash. If you want a parameter name that uses these characters you can wrap the name in quotes, e.g. `:"my-name"`.
     
     ### Unterminated quote
     
     Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character.
     
    -### Pattern cannot start with "?"
    -
    -Parameters in `path-to-regexp` must be basic groups. However, you can use features that require the `?` nested within the pattern. For example, `:foo((?!login)[^/]+)` is valid, but `:foo(?!login)` is not.
    -
    -### Capturing groups are not allowed
    -
    -A parameter pattern can not contain nested capturing groups.
    -
    -### Unbalanced or missing pattern
    -
    -A parameter pattern must have the expected number of parentheses. An unbalanced amount, such as `((?!login)` implies something has been written that is invalid. Check you didn't forget any parentheses.
    -
     ### Express <= 4.x
     
     Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways:
     
    -- The only part of the string that is a regex is within `()`.
    -  - In Express.js 4.x, everything was passed as-is after a simple replacement, so you could write `/[a-z]+` to match `/test`.
    -- The `?` optional character must be used after `{}`.
    +- Regexp characters can no longer be provided.
    +- The optional character `?` is no longer supported, use braces instead: `/:file{.:ext}`.
     - Some characters have new meaning or have been reserved (`{}?*+@!;`).
    -- The parameter name now supports all unicode identifier characters, previously it was only `[a-z0-9]`.
    +- The parameter name now supports all JavaScript identifier characters, previously it was only `[a-z0-9]`.
     
     ## License
     
    
  • scripts/redos.ts+1 7 modified
    @@ -5,13 +5,7 @@ import { MATCH_TESTS } from "../src/cases.spec.js";
     let safe = 0;
     let fail = 0;
     
    -const TESTS = new Set(MATCH_TESTS.map((test) => test.path));
    -// const TESTS = [
    -//   ":path([^\\.]+).:ext",
    -//   ":path.:ext(\\w+)",
    -//   ":path{.:ext([^\\.]+)}",
    -//   "/:path.:ext(\\\\w+)",
    -// ];
    +const TESTS = MATCH_TESTS.map((x) => x.path);
     
     for (const path of TESTS) {
       const { re } = match(path) as any;
    
  • src/cases.spec.ts+329 1036 modified
    @@ -34,31 +34,56 @@ export interface MatchTestSet {
     export const PARSER_TESTS: ParserTestSet[] = [
       {
         path: "/",
    -    expected: ["/"],
    +    expected: [{ type: "text", value: "/" }],
       },
       {
         path: "/:test",
    -    expected: ["/", { name: "test" }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: "test" },
    +    ],
       },
       {
         path: '/:"0"',
    -    expected: ["/", { name: "0" }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: "0" },
    +    ],
       },
       {
         path: "/:_",
    -    expected: ["/", { name: "_" }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: "_" },
    +    ],
       },
       {
         path: "/:café",
    -    expected: ["/", { name: "café" }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: "café" },
    +    ],
       },
       {
         path: '/:"123"',
    -    expected: ["/", { name: "123" }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: "123" },
    +    ],
       },
       {
         path: '/:"1\\"\\2\\"3"',
    -    expected: ["/", { name: '1"2"3' }],
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "param", name: '1"2"3' },
    +    ],
    +  },
    +  {
    +    path: "/*path",
    +    expected: [
    +      { type: "text", value: "/" },
    +      { type: "wildcard", name: "path" },
    +    ],
       },
     ];
     
    @@ -106,7 +131,6 @@ export const COMPILE_TESTS: CompileTestSet[] = [
       },
       {
         path: "/:test",
    -    options: { validate: false },
         tests: [
           { input: undefined, expected: null },
           { input: {}, expected: null },
    @@ -116,24 +140,14 @@ export const COMPILE_TESTS: CompileTestSet[] = [
       },
       {
         path: "/:test",
    -    options: { validate: false, encode: false },
    +    options: { encode: false },
         tests: [
           { input: undefined, expected: null },
           { input: {}, expected: null },
           { input: { test: "123" }, expected: "/123" },
           { input: { test: "123/xyz" }, expected: "/123/xyz" },
         ],
       },
    -  {
    -    path: "/:test",
    -    options: { encode: encodeURIComponent },
    -    tests: [
    -      { input: undefined, expected: null },
    -      { input: {}, expected: null },
    -      { input: { test: "123" }, expected: "/123" },
    -      { input: { test: "123/xyz" }, expected: "/123%2Fxyz" },
    -    ],
    -  },
       {
         path: "/:test",
         options: { encode: () => "static" },
    @@ -145,56 +159,32 @@ export const COMPILE_TESTS: CompileTestSet[] = [
         ],
       },
       {
    -    path: "{/:test}?",
    +    path: "{/:test}",
         options: { encode: false },
         tests: [
           { input: undefined, expected: "" },
           { input: {}, expected: "" },
           { input: { test: undefined }, expected: "" },
           { input: { test: "123" }, expected: "/123" },
    -      { input: { test: "123/xyz" }, expected: null },
    +      { input: { test: "123/xyz" }, expected: "/123/xyz" },
         ],
       },
       {
    -    path: "/:test(.*)",
    -    options: { encode: false },
    +    path: "/*test",
         tests: [
           { input: undefined, expected: null },
           { input: {}, expected: null },
    -      { input: { test: "" }, expected: "/" },
    -      { input: { test: "123" }, expected: "/123" },
    -      { input: { test: "123/xyz" }, expected: "/123/xyz" },
    -    ],
    -  },
    -  {
    -    path: "{/:test}*",
    -    tests: [
    -      { input: undefined, expected: "" },
    -      { input: {}, expected: "" },
    -      { input: { test: [] }, expected: "" },
    -      { input: { test: [""] }, expected: null },
    +      { input: { test: [] }, expected: null },
           { input: { test: ["123"] }, expected: "/123" },
    -      { input: { test: "123/xyz" }, expected: null },
           { input: { test: ["123", "xyz"] }, expected: "/123/xyz" },
         ],
       },
       {
    -    path: "{/:test}*",
    +    path: "/*test",
         options: { encode: false },
         tests: [
    -      { input: undefined, expected: "" },
    -      { input: {}, expected: "" },
    -      { input: { test: "" }, expected: null },
           { input: { test: "123" }, expected: "/123" },
           { input: { test: "123/xyz" }, expected: "/123/xyz" },
    -      { input: { test: ["123", "xyz"] }, expected: null },
    -    ],
    -  },
    -  {
    -    path: "/{<:foo>}+",
    -    tests: [
    -      { input: undefined, expected: null },
    -      { input: { foo: ["x", "y", "z"] }, expected: "/<x><y><z>" },
         ],
       },
     ];
    @@ -227,7 +217,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           { input: "/test/route", expected: false },
           {
             input: "/test/",
    -        expected: false,
    +        expected: { path: "/test/", params: {} },
           },
         ],
       },
    @@ -242,7 +232,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           { input: "/test", expected: false },
           {
             input: "/test//",
    -        expected: false,
    +        expected: { path: "/test//", params: {} },
           },
         ],
       },
    @@ -255,7 +245,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/route/",
    -        expected: false,
    +        expected: { path: "/route/", params: { test: "route" } },
           },
           {
             input: "/route.json",
    @@ -266,7 +256,10 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/route.json/",
    -        expected: false,
    +        expected: {
    +          path: "/route.json/",
    +          params: { test: "route.json" },
    +        },
           },
           {
             input: "/route/test",
    @@ -341,7 +334,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/test/",
    -        expected: { path: "/test", params: {} },
    +        expected: { path: "/test/", params: {} },
           },
           {
             input: "/test////",
    @@ -377,7 +370,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/test//",
    -        expected: { path: "/test/", params: {} },
    +        expected: { path: "/test//", params: {} },
           },
           {
             input: "/test/route",
    @@ -401,7 +394,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/route/",
    -        expected: { path: "/route", params: { test: "route" } },
    +        expected: { path: "/route/", params: { test: "route" } },
           },
           {
             input: "/route.json",
    @@ -413,7 +406,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           {
             input: "/route.json/",
             expected: {
    -          path: "/route.json",
    +          path: "/route.json/",
               params: { test: "route.json" },
             },
           },
    @@ -477,7 +470,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/",
    -        expected: { path: "", params: {} },
    +        expected: { path: "/", params: {} },
           },
           {
             input: "route",
    @@ -498,7 +491,28 @@ export const MATCH_TESTS: MatchTestSet[] = [
        * Optional.
        */
       {
    -    path: "{/:test}?",
    +    path: "{/route}",
    +    tests: [
    +      {
    +        input: "",
    +        expected: { path: "", params: {} },
    +      },
    +      {
    +        input: "/",
    +        expected: { path: "/", params: {} },
    +      },
    +      {
    +        input: "/foo",
    +        expected: false,
    +      },
    +      {
    +        input: "/route",
    +        expected: { path: "/route", params: {} },
    +      },
    +    ],
    +  },
    +  {
    +    path: "{/:test}",
         tests: [
           {
             input: "/route",
    @@ -510,12 +524,12 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/",
    -        expected: false,
    +        expected: { path: "/", params: {} },
           },
         ],
       },
       {
    -    path: "{/:test}?/bar",
    +    path: "{/:test}/bar",
         tests: [
           {
             input: "/bar",
    @@ -527,12 +541,12 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/foo/bar/",
    -        expected: false,
    +        expected: { path: "/foo/bar/", params: { test: "foo" } },
           },
         ],
       },
       {
    -    path: "{/:test}?-bar",
    +    path: "{/:test}-bar",
         tests: [
           {
             input: "-bar",
    @@ -544,12 +558,12 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/foo-bar/",
    -        expected: false,
    +        expected: { path: "/foo-bar/", params: { test: "foo" } },
           },
         ],
       },
       {
    -    path: "/{:test}?-bar",
    +    path: "/{:test}-bar",
         tests: [
           {
             input: "/-bar",
    @@ -561,810 +575,266 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/foo-bar/",
    -        expected: false,
    +        expected: { path: "/foo-bar/", params: { test: "foo" } },
           },
         ],
       },
     
       /**
    -   * Zero or more times.
    +   * No prefix characters.
        */
       {
    -    path: "{/:test}*",
    +    path: "test",
         tests: [
           {
    -        input: "/",
    -        expected: false,
    +        input: "test",
    +        expected: { path: "test", params: {} },
           },
           {
    -        input: "//",
    +        input: "/test",
             expected: false,
           },
    -      {
    -        input: "/route",
    -        expected: { path: "/route", params: { test: ["route"] } },
    -      },
    -      {
    -        input: "/some/basic/route",
    -        expected: {
    -          path: "/some/basic/route",
    -          params: { test: ["some", "basic", "route"] },
    -        },
    -      },
         ],
       },
       {
    -    path: "{/:test}*-bar",
    +    path: ":test",
         tests: [
           {
    -        input: "-bar",
    -        expected: { path: "-bar", params: {} },
    +        input: "route",
    +        expected: { path: "route", params: { test: "route" } },
           },
           {
    -        input: "/-bar",
    +        input: "/route",
             expected: false,
           },
           {
    -        input: "/foo-bar",
    -        expected: { path: "/foo-bar", params: { test: ["foo"] } },
    +        input: "route/",
    +        expected: { path: "route/", params: { test: "route" } },
    +      },
    +    ],
    +  },
    +  {
    +    path: "{:test}",
    +    tests: [
    +      {
    +        input: "test",
    +        expected: { path: "test", params: { test: "test" } },
           },
           {
    -        input: "/foo/baz-bar",
    -        expected: {
    -          path: "/foo/baz-bar",
    -          params: { test: ["foo", "baz"] },
    -        },
    +        input: "",
    +        expected: { path: "", params: {} },
           },
         ],
       },
     
       /**
    -   * One or more times.
    +   * Formats.
        */
       {
    -    path: "{/:test}+",
    +    path: "/test.json",
         tests: [
           {
    -        input: "/",
    -        expected: false,
    +        input: "/test.json",
    +        expected: { path: "/test.json", params: {} },
           },
           {
    -        input: "//",
    +        input: "/test",
             expected: false,
           },
    -      {
    -        input: "/route",
    -        expected: { path: "/route", params: { test: ["route"] } },
    -      },
    -      {
    -        input: "/some/basic/route",
    -        expected: {
    -          path: "/some/basic/route",
    -          params: { test: ["some", "basic", "route"] },
    -        },
    -      },
         ],
       },
       {
    -    path: "{/:test}+-bar",
    +    path: "/:test.json",
         tests: [
           {
    -        input: "-bar",
    +        input: "/.json",
             expected: false,
           },
           {
    -        input: "/-bar",
    -        expected: false,
    +        input: "/test.json",
    +        expected: { path: "/test.json", params: { test: "test" } },
           },
           {
    -        input: "/foo-bar",
    -        expected: { path: "/foo-bar", params: { test: ["foo"] } },
    +        input: "/route.json",
    +        expected: { path: "/route.json", params: { test: "route" } },
           },
           {
    -        input: "/foo/baz-bar",
    -        expected: {
    -          path: "/foo/baz-bar",
    -          params: { test: ["foo", "baz"] },
    -        },
    +        input: "/route.json.json",
    +        expected: { path: "/route.json.json", params: { test: "route.json" } },
           },
         ],
       },
     
       /**
    -   * Custom parameters.
    +   * Format and path params.
        */
       {
    -    path: String.raw`/:test(\d+)`,
    +    path: "/:test.:format",
         tests: [
           {
    -        input: "/123",
    -        expected: { path: "/123", params: { test: "123" } },
    +        input: "/route.html",
    +        expected: {
    +          path: "/route.html",
    +          params: { test: "route", format: "html" },
    +        },
           },
           {
    -        input: "/abc",
    +        input: "/route",
             expected: false,
           },
           {
    -        input: "/123/abc",
    -        expected: false,
    +        input: "/route.html.json",
    +        expected: {
    +          path: "/route.html.json",
    +          params: { test: "route.html", format: "json" },
    +        },
           },
         ],
       },
       {
    -    path: String.raw`/:test(\d+)-bar`,
    +    path: "/:test{.:format}",
         tests: [
           {
    -        input: "-bar",
    -        expected: false,
    +        input: "/route",
    +        expected: { path: "/route", params: { test: "route" } },
           },
           {
    -        input: "/-bar",
    -        expected: false,
    +        input: "/route.json",
    +        expected: {
    +          path: "/route.json",
    +          params: { test: "route", format: "json" },
    +        },
           },
           {
    -        input: "/abc-bar",
    -        expected: false,
    +        input: "/route.json.html",
    +        expected: {
    +          path: "/route.json.html",
    +          params: { test: "route.json", format: "html" },
    +        },
           },
    +    ],
    +  },
    +  {
    +    path: "/:test.:format\\z",
    +    tests: [
           {
    -        input: "/123-bar",
    -        expected: { path: "/123-bar", params: { test: "123" } },
    +        input: "/route.htmlz",
    +        expected: {
    +          path: "/route.htmlz",
    +          params: { test: "route", format: "html" },
    +        },
           },
           {
    -        input: "/123/456-bar",
    +        input: "/route.html",
             expected: false,
           },
         ],
       },
    +
    +  /**
    +   * Escaped characters.
    +   */
       {
    -    path: "/:test(.*)",
    +    path: "/\\(testing\\)",
         tests: [
           {
    -        input: "/",
    -        expected: { path: "/", params: { test: "" } },
    +        input: "/testing",
    +        expected: false,
           },
           {
    -        input: "/route",
    -        expected: { path: "/route", params: { test: "route" } },
    +        input: "/(testing)",
    +        expected: { path: "/(testing)", params: {} },
           },
    +    ],
    +  },
    +  {
    +    path: "/.\\+\\*\\?\\{\\}=^\\!\\:$\\[\\]\\|",
    +    tests: [
           {
    -        input: "/route/123",
    -        expected: {
    -          path: "/route/123",
    -          params: { test: "route/123" },
    -        },
    +        input: "/.+*?{}=^!:$[]|",
    +        expected: { path: "/.+*?{}=^!:$[]|", params: {} },
           },
    +    ],
    +  },
    +
    +  /**
    +   * Random examples.
    +   */
    +  {
    +    path: "/:foo/:bar",
    +    tests: [
           {
    -        input: "/;,:@&=/+$-_.!/~*()",
    +        input: "/match/route",
             expected: {
    -          path: "/;,:@&=/+$-_.!/~*()",
    -          params: { test: ";,:@&=/+$-_.!/~*()" },
    +          path: "/match/route",
    +          params: { foo: "match", bar: "route" },
             },
           },
         ],
       },
       {
    -    path: "/:test([a-z]+)",
    +    path: "/:foo\\(test\\)/bar",
         tests: [
           {
    -        input: "/abc",
    -        expected: { path: "/abc", params: { test: "abc" } },
    -      },
    -      {
    -        input: "/123",
    -        expected: false,
    +        input: "/foo(test)/bar",
    +        expected: { path: "/foo(test)/bar", params: { foo: "foo" } },
           },
           {
    -        input: "/abc/123",
    +        input: "/foo/bar",
             expected: false,
           },
         ],
       },
       {
    -    path: "/:test(this|that)",
    +    path: "/:foo\\?",
         tests: [
           {
    -        input: "/this",
    -        expected: { path: "/this", params: { test: "this" } },
    -      },
    -      {
    -        input: "/that",
    -        expected: { path: "/that", params: { test: "that" } },
    +        input: "/route?",
    +        expected: { path: "/route?", params: { foo: "route" } },
           },
           {
    -        input: "/foo",
    +        input: "/route",
             expected: false,
           },
         ],
       },
       {
    -    path: "{/:test(abc|xyz)}*",
    +    path: "/{:pre}baz",
         tests: [
           {
    -        input: "/",
    -        expected: false,
    +        input: "/foobaz",
    +        expected: { path: "/foobaz", params: { pre: "foo" } },
           },
           {
    -        input: "/abc",
    -        expected: { path: "/abc", params: { test: ["abc"] } },
    +        input: "/baz",
    +        expected: { path: "/baz", params: { pre: undefined } },
           },
    +    ],
    +  },
    +  {
    +    path: "/:foo\\(:bar\\)",
    +    tests: [
           {
    -        input: "/abc/abc",
    +        input: "/hello(world)",
             expected: {
    -          path: "/abc/abc",
    -          params: { test: ["abc", "abc"] },
    +          path: "/hello(world)",
    +          params: { foo: "hello", bar: "world" },
             },
           },
           {
    -        input: "/xyz/xyz",
    -        expected: {
    -          path: "/xyz/xyz",
    -          params: { test: ["xyz", "xyz"] },
    -        },
    +        input: "/hello()",
    +        expected: false,
           },
    +    ],
    +  },
    +  {
    +    path: "/:foo\\({:bar}\\)",
    +    tests: [
           {
    -        input: "/abc/xyz",
    +        input: "/hello(world)",
             expected: {
    -          path: "/abc/xyz",
    -          params: { test: ["abc", "xyz"] },
    -        },
    -      },
    -      {
    -        input: "/abc/xyz/abc/xyz",
    -        expected: {
    -          path: "/abc/xyz/abc/xyz",
    -          params: { test: ["abc", "xyz", "abc", "xyz"] },
    -        },
    -      },
    -      {
    -        input: "/xyzxyz",
    -        expected: false,
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * No prefix characters.
    -   */
    -  {
    -    path: "test",
    -    tests: [
    -      {
    -        input: "test",
    -        expected: { path: "test", params: {} },
    -      },
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: ":test",
    -    tests: [
    -      {
    -        input: "route",
    -        expected: { path: "route", params: { test: "route" } },
    -      },
    -      {
    -        input: "/route",
    -        expected: false,
    -      },
    -      {
    -        input: "route/",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "{:test}?",
    -    tests: [
    -      {
    -        input: "test",
    -        expected: { path: "test", params: { test: "test" } },
    -      },
    -      {
    -        input: "",
    -        expected: { path: "", params: {} },
    -      },
    -    ],
    -  },
    -  {
    -    path: "{:test/}+",
    -    tests: [
    -      {
    -        input: "route/",
    -        expected: { path: "route/", params: { test: ["route"] } },
    -      },
    -      {
    -        input: "/route",
    -        expected: false,
    -      },
    -      {
    -        input: "",
    -        expected: false,
    -      },
    -      {
    -        input: "foo/bar/",
    -        expected: {
    -          path: "foo/bar/",
    -          params: { test: ["foo", "bar"] },
    -        },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Formats.
    -   */
    -  {
    -    path: "/test.json",
    -    tests: [
    -      {
    -        input: "/test.json",
    -        expected: { path: "/test.json", params: {} },
    -      },
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:test.json",
    -    tests: [
    -      {
    -        input: "/.json",
    -        expected: false,
    -      },
    -      {
    -        input: "/test.json",
    -        expected: { path: "/test.json", params: { test: "test" } },
    -      },
    -      {
    -        input: "/route.json",
    -        expected: { path: "/route.json", params: { test: "route" } },
    -      },
    -      {
    -        input: "/route.json.json",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:test([^/]+).json",
    -    tests: [
    -      {
    -        input: "/route.json.json",
    -        expected: {
    -          path: "/route.json.json",
    -          params: { test: "route.json" },
    -        },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Format params.
    -   */
    -  {
    -    path: "/test.:format(\\w+)",
    -    tests: [
    -      {
    -        input: "/test.html",
    -        expected: { path: "/test.html", params: { format: "html" } },
    -      },
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test.:format(\\w+).:format(\\w+)",
    -    tests: [
    -      {
    -        input: "/test.html.json",
    -        expected: {
    -          path: "/test.html.json",
    -          params: { format: "json" },
    -        },
    -      },
    -      {
    -        input: "/test.html",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test{.:format(\\w+)}?",
    -    tests: [
    -      {
    -        input: "/test",
    -        expected: { path: "/test", params: { format: undefined } },
    -      },
    -      {
    -        input: "/test.html",
    -        expected: { path: "/test.html", params: { format: "html" } },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test{.:format(\\w+)}+",
    -    tests: [
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -      {
    -        input: "/test.html",
    -        expected: {
    -          path: "/test.html",
    -          params: { format: ["html"] },
    -        },
    -      },
    -      {
    -        input: "/test.html.json",
    -        expected: {
    -          path: "/test.html.json",
    -          params: { format: ["html", "json"] },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test{.:format}+",
    -    tests: [
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -      {
    -        input: "/test.html",
    -        expected: {
    -          path: "/test.html",
    -          params: { format: ["html"] },
    -        },
    -      },
    -      {
    -        input: "/test.hbs.html",
    -        expected: {
    -          path: "/test.hbs.html",
    -          params: { format: ["hbs", "html"] },
    -        },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Format and path params.
    -   */
    -  {
    -    path: "/:test.:format",
    -    tests: [
    -      {
    -        input: "/route.html",
    -        expected: {
    -          path: "/route.html",
    -          params: { test: "route", format: "html" },
    -        },
    -      },
    -      {
    -        input: "/route",
    -        expected: false,
    -      },
    -      {
    -        input: "/route.html.json",
    -        expected: {
    -          path: "/route.html.json",
    -          params: { test: "route", format: "html.json" },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:test{.:format}?",
    -    tests: [
    -      {
    -        input: "/route",
    -        expected: { path: "/route", params: { test: "route" } },
    -      },
    -      {
    -        input: "/route.json",
    -        expected: {
    -          path: "/route.json",
    -          params: { test: "route", format: "json" },
    -        },
    -      },
    -      {
    -        input: "/route.json.html",
    -        expected: {
    -          path: "/route.json.html",
    -          params: { test: "route", format: "json.html" },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:test.:format\\z",
    -    tests: [
    -      {
    -        input: "/route.htmlz",
    -        expected: {
    -          path: "/route.htmlz",
    -          params: { test: "route", format: "html" },
    -        },
    -      },
    -      {
    -        input: "/route.html",
    -        expected: false,
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Unnamed params.
    -   */
    -  {
    -    path: "/(\\d+)",
    -    tests: [
    -      {
    -        input: "/123",
    -        expected: { path: "/123", params: { "0": "123" } },
    -      },
    -      {
    -        input: "/abc",
    -        expected: false,
    -      },
    -      {
    -        input: "/123/abc",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "{/(\\d+)}?",
    -    tests: [
    -      {
    -        input: "/",
    -        expected: false,
    -      },
    -      {
    -        input: "/123",
    -        expected: { path: "/123", params: { "0": "123" } },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/route\\(\\\\(\\d+\\\\)\\)",
    -    tests: [
    -      {
    -        input: "/route(\\123\\)",
    -        expected: {
    -          path: "/route(\\123\\)",
    -          params: { "0": "123\\" },
    -        },
    -      },
    -      {
    -        input: "/route(\\123)",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "{/route}?",
    -    tests: [
    -      {
    -        input: "",
    -        expected: { path: "", params: {} },
    -      },
    -      {
    -        input: "/",
    -        expected: false,
    -      },
    -      {
    -        input: "/foo",
    -        expected: false,
    -      },
    -      {
    -        input: "/route",
    -        expected: { path: "/route", params: {} },
    -      },
    -    ],
    -  },
    -  {
    -    path: "{/(.*)}",
    -    tests: [
    -      {
    -        input: "/",
    -        expected: { path: "/", params: { "0": "" } },
    -      },
    -      {
    -        input: "/login",
    -        expected: { path: "/login", params: { "0": "login" } },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Escaped characters.
    -   */
    -  {
    -    path: "/\\(testing\\)",
    -    tests: [
    -      {
    -        input: "/testing",
    -        expected: false,
    -      },
    -      {
    -        input: "/(testing)",
    -        expected: { path: "/(testing)", params: {} },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/.\\+\\*\\?\\{\\}=^\\!\\:$[]\\|",
    -    tests: [
    -      {
    -        input: "/.+*?{}=^!:$[]|",
    -        expected: { path: "/.+*?{}=^!:$[]|", params: {} },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test/{:uid(u\\d+)}?{:cid(c\\d+)}?",
    -    tests: [
    -      {
    -        input: "/test/u123",
    -        expected: { path: "/test/u123", params: { uid: "u123" } },
    -      },
    -      {
    -        input: "/test/c123",
    -        expected: { path: "/test/c123", params: { cid: "c123" } },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Unnamed group prefix.
    -   */
    -  {
    -    path: "/{apple-}?icon-:res(\\d+).png",
    -    tests: [
    -      {
    -        input: "/icon-240.png",
    -        expected: { path: "/icon-240.png", params: { res: "240" } },
    -      },
    -      {
    -        input: "/apple-icon-240.png",
    -        expected: {
    -          path: "/apple-icon-240.png",
    -          params: { res: "240" },
    -        },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Random examples.
    -   */
    -  {
    -    path: "/:foo/:bar",
    -    tests: [
    -      {
    -        input: "/match/route",
    -        expected: {
    -          path: "/match/route",
    -          params: { foo: "match", bar: "route" },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:foo\\(test\\)/bar",
    -    tests: [
    -      {
    -        input: "/foo(test)/bar",
    -        expected: { path: "/foo(test)/bar", params: { foo: "foo" } },
    -      },
    -      {
    -        input: "/foo/bar",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:remote([\\w\\-\\.]+)/:user([\\w-]+)",
    -    tests: [
    -      {
    -        input: "/endpoint/user",
    -        expected: {
    -          path: "/endpoint/user",
    -          params: { remote: "endpoint", user: "user" },
    -        },
    -      },
    -      {
    -        input: "/endpoint/user-name",
    -        expected: {
    -          path: "/endpoint/user-name",
    -          params: { remote: "endpoint", user: "user-name" },
    -        },
    -      },
    -      {
    -        input: "/foo.bar/user-name",
    -        expected: {
    -          path: "/foo.bar/user-name",
    -          params: { remote: "foo.bar", user: "user-name" },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:foo\\?",
    -    tests: [
    -      {
    -        input: "/route?",
    -        expected: { path: "/route?", params: { foo: "route" } },
    -      },
    -      {
    -        input: "/route",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "{/:foo}+bar",
    -    tests: [
    -      {
    -        input: "/foobar",
    -        expected: { path: "/foobar", params: { foo: ["foo"] } },
    -      },
    -      {
    -        input: "/foo/bar",
    -        expected: false,
    -      },
    -      {
    -        input: "/foo/barbar",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/{:pre}?baz",
    -    tests: [
    -      {
    -        input: "/foobaz",
    -        expected: { path: "/foobaz", params: { pre: "foo" } },
    -      },
    -      {
    -        input: "/baz",
    -        expected: { path: "/baz", params: { pre: undefined } },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:foo\\(:bar\\)",
    -    tests: [
    -      {
    -        input: "/hello(world)",
    -        expected: {
    -          path: "/hello(world)",
    -          params: { foo: "hello", bar: "world" },
    -        },
    -      },
    -      {
    -        input: "/hello()",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:foo\\({:bar}?\\)",
    -    tests: [
    -      {
    -        input: "/hello(world)",
    -        expected: {
    -          path: "/hello(world)",
    -          params: { foo: "hello", bar: "world" },
    +          path: "/hello(world)",
    +          params: { foo: "hello", bar: "world" },
             },
           },
           {
    @@ -1377,27 +847,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "/:postType(video|audio|text){(\\+.+)}?",
    -    tests: [
    -      {
    -        input: "/video",
    -        expected: { path: "/video", params: { postType: "video" } },
    -      },
    -      {
    -        input: "/video+test",
    -        expected: {
    -          path: "/video+test",
    -          params: { 0: "+test", postType: "video" },
    -        },
    -      },
    -      {
    -        input: "/video+",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "{/:foo}?{/:bar}?-ext",
    +    path: "{/:foo}{/:bar}-ext",
         tests: [
           {
             input: "/-ext",
    @@ -1428,7 +878,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "/:required{/:optional}?-ext",
    +    path: "/:required{/:optional}-ext",
         tests: [
           {
             input: "/foo-ext",
    @@ -1545,7 +995,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "mail{.:domain}?.com",
    +    path: "mail{.:domain}.com",
         options: {
           delimiter: ".",
         },
    @@ -1608,7 +1058,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
        * Prefixes.
        */
       {
    -    path: "{$:foo}{$:bar}?",
    +    path: "$:foo{$:bar}",
         tests: [
           {
             input: "$x",
    @@ -1621,20 +1071,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "{$:foo}+",
    -    tests: [
    -      {
    -        input: "$x",
    -        expected: { path: "$x", params: { foo: ["x"] } },
    -      },
    -      {
    -        input: "$x$y",
    -        expected: { path: "$x$y", params: { foo: ["x", "y"] } },
    -      },
    -    ],
    -  },
    -  {
    -    path: "name{/:attr1}?{-:attr2}?{-:attr3}?",
    +    path: "name{/:attr1}{-:attr2}{-:attr3}",
         tests: [
           {
             input: "name",
    @@ -1678,108 +1115,12 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
         ],
       },
    -  {
    -    path: "name{/:attrs;-}*",
    -    tests: [
    -      {
    -        input: "name",
    -        expected: { path: "name", params: {} },
    -      },
    -      {
    -        input: "name/1",
    -        expected: {
    -          path: "name/1",
    -          params: { attrs: ["1"] },
    -        },
    -      },
    -      {
    -        input: "name/1-2",
    -        expected: {
    -          path: "name/1-2",
    -          params: { attrs: ["1", "2"] },
    -        },
    -      },
    -      {
    -        input: "name/1-2-3",
    -        expected: {
    -          path: "name/1-2-3",
    -          params: { attrs: ["1", "2", "3"] },
    -        },
    -      },
    -      {
    -        input: "name/foo-bar/route",
    -        expected: false,
    -      },
    -      {
    -        input: "name/test/route",
    -        expected: false,
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Nested parentheses.
    -   */
    -  {
    -    path: "/:test(\\d+(?:\\.\\d+)?)",
    -    tests: [
    -      {
    -        input: "/123",
    -        expected: { path: "/123", params: { test: "123" } },
    -      },
    -      {
    -        input: "/abc",
    -        expected: false,
    -      },
    -      {
    -        input: "/123/abc",
    -        expected: false,
    -      },
    -      {
    -        input: "/123.123",
    -        expected: { path: "/123.123", params: { test: "123.123" } },
    -      },
    -      {
    -        input: "/123.abc",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:test((?!login)[^/]+)",
    -    tests: [
    -      {
    -        input: "/route",
    -        expected: { path: "/route", params: { test: "route" } },
    -      },
    -      {
    -        input: "/login",
    -        expected: false,
    -      },
    -    ],
    -  },
     
       /**
        * https://github.com/pillarjs/path-to-regexp/issues/206
        */
       {
    -    path: "/user{(s)}?/:user",
    -    tests: [
    -      {
    -        input: "/user/123",
    -        expected: { path: "/user/123", params: { user: "123" } },
    -      },
    -      {
    -        input: "/users/123",
    -        expected: {
    -          path: "/users/123",
    -          params: { 0: "s", user: "123" },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/user{s}?/:user",
    +    path: "/user{s}/:user",
         tests: [
           {
             input: "/user/123",
    @@ -1793,250 +1134,176 @@ export const MATCH_TESTS: MatchTestSet[] = [
       },
     
       /**
    -   * https://github.com/pillarjs/path-to-regexp/pull/270
    -   */
    -  {
    -    path: "/files{/:path}*{.:ext}*",
    -    tests: [
    -      {
    -        input: "/files/hello/world.txt",
    -        expected: {
    -          path: "/files/hello/world.txt",
    -          params: { path: ["hello", "world"], ext: ["txt"] },
    -        },
    -      },
    -      {
    -        input: "/files/hello/world.txt.png",
    -        expected: {
    -          path: "/files/hello/world.txt.png",
    -          params: { path: ["hello", "world"], ext: ["txt", "png"] },
    -        },
    -      },
    -      {
    -        input: "/files/my/photo.jpg/gif",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/files{/:path}*{.:ext}?",
    -    tests: [
    -      {
    -        input: "/files/hello/world.txt",
    -        expected: {
    -          path: "/files/hello/world.txt",
    -          params: { path: ["hello", "world"], ext: "txt" },
    -        },
    -      },
    -      {
    -        input: "/files/my/photo.jpg/gif",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "#/*",
    -    tests: [
    -      {
    -        input: "#/",
    -        expected: { path: "#/", params: {} },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/foo{/:bar}*",
    -    tests: [
    -      {
    -        input: "/foo/test1/test2",
    -        expected: {
    -          path: "/foo/test1/test2",
    -          params: { bar: ["test1", "test2"] },
    -        },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/entity/:id/*",
    -    tests: [
    -      {
    -        input: "/entity/foo",
    -        expected: false,
    -      },
    -      {
    -        input: "/entity/foo/",
    -        expected: { path: "/entity/foo/", params: { id: "foo" } },
    -      },
    -    ],
    -  },
    -  {
    -    path: "/test/*",
    -    tests: [
    -      {
    -        input: "/test",
    -        expected: false,
    -      },
    -      {
    -        input: "/test/",
    -        expected: { path: "/test/", params: {} },
    -      },
    -      {
    -        input: "/test/route",
    -        expected: { path: "/test/route", params: { "0": ["route"] } },
    -      },
    -      {
    -        input: "/test/route/nested",
    -        expected: {
    -          path: "/test/route/nested",
    -          params: { "0": ["route", "nested"] },
    -        },
    -      },
    -    ],
    -  },
    -
    -  /**
    -   * Asterisk wildcard.
    +   * Wildcard.
        */
       {
    -    path: "/*",
    +    path: "/*path",
         tests: [
           {
             input: "/",
    -        expected: { path: "/", params: { "0": undefined } },
    +        expected: false,
           },
           {
             input: "/route",
    -        expected: { path: "/route", params: { "0": ["route"] } },
    +        expected: { path: "/route", params: { path: ["route"] } },
           },
           {
             input: "/route/nested",
             expected: {
               path: "/route/nested",
    -          params: { "0": ["route", "nested"] },
    +          params: { path: ["route", "nested"] },
             },
           },
         ],
       },
       {
    -    path: "*",
    +    path: "*path",
         tests: [
           {
             input: "/",
    -        expected: { path: "/", params: { "0": ["", ""] } },
    +        expected: { path: "/", params: { path: ["", ""] } },
           },
           {
             input: "/test",
    -        expected: { path: "/test", params: { "0": ["", "test"] } },
    +        expected: { path: "/test", params: { path: ["", "test"] } },
           },
         ],
       },
       {
    -    path: "*",
    +    path: "*path",
         options: { decode: false },
         tests: [
           {
             input: "/",
    -        expected: { path: "/", params: { "0": "/" } },
    +        expected: { path: "/", params: { path: "/" } },
           },
           {
             input: "/test",
    -        expected: { path: "/test", params: { "0": "/test" } },
    +        expected: { path: "/test", params: { path: "/test" } },
           },
         ],
       },
       {
    -    path: "/*.:ext",
    +    path: "/*path.:ext",
         tests: [
           {
             input: "/test.html",
             expected: {
               path: "/test.html",
    -          params: { "0": ["test"], ext: "html" },
    +          params: { path: ["test"], ext: "html" },
             },
           },
           {
             input: "/test.html/nested",
             expected: false,
           },
    +      {
    +        input: "/test.html/nested.json",
    +        expected: {
    +          path: "/test.html/nested.json",
    +          params: { path: ["test.html", "nested"], ext: "json" },
    +        },
    +      },
         ],
       },
       {
    -    path: "/*{.:ext}?",
    +    path: "/:path.*ext",
         tests: [
           {
             input: "/test.html",
             expected: {
               path: "/test.html",
    -          params: { "0": ["test.html"], ext: undefined },
    +          params: { path: "test", ext: ["html"] },
             },
           },
           {
             input: "/test.html/nested",
             expected: {
    -          params: {
    -            "0": ["test.html", "nested"],
    -          },
               path: "/test.html/nested",
    +          params: { path: "test", ext: ["html", "nested"] },
    +        },
    +      },
    +      {
    +        input: "/test.html/nested.json",
    +        expected: {
    +          path: "/test.html/nested.json",
    +          params: { path: "test", ext: ["html", "nested.json"] },
             },
           },
         ],
       },
       {
    -    path: "/*{.:ext}*",
    +    path: "/*path{.:ext}",
         tests: [
           {
             input: "/test.html",
             expected: {
               path: "/test.html",
    -          params: { "0": ["test.html"], ext: undefined },
    +          params: { path: ["test"], ext: "html" },
             },
           },
           {
             input: "/test.html/nested",
             expected: {
               params: {
    -            "0": ["test.html", "nested"],
    +            path: ["test.html", "nested"],
               },
               path: "/test.html/nested",
             },
           },
         ],
       },
    -
    -  /**
    -   * Longer prefix.
    -   */
       {
    -    path: "/:foo{/test/:bar}?",
    +    path: "/entity/:id/*path",
         tests: [
           {
    -        input: "/route",
    -        expected: { path: "/route", params: { foo: "route" } },
    +        input: "/entity/foo",
    +        expected: false,
           },
           {
    -        input: "/route/test/again",
    +        input: "/entity/foo/path",
             expected: {
    -          path: "/route/test/again",
    -          params: { foo: "route", bar: "again" },
    +          path: "/entity/foo/path",
    +          params: { id: "foo", path: ["path"] },
    +        },
    +      },
    +    ],
    +  },
    +  {
    +    path: "/*foo/:bar/*baz",
    +    tests: [
    +      {
    +        input: "/x/y/z",
    +        expected: {
    +          path: "/x/y/z",
    +          params: { foo: ["x"], bar: "y", baz: ["z"] },
    +        },
    +      },
    +      {
    +        input: "/1/2/3/4/5",
    +        expected: {
    +          path: "/1/2/3/4/5",
    +          params: { foo: ["1", "2", "3"], bar: "4", baz: ["5"] },
             },
           },
         ],
       },
     
       /**
    -   * Prefix and suffix as separator.
    +   * Longer prefix.
        */
       {
    -    path: "/{<:foo>}+",
    +    path: "/:foo{/test/:bar}",
         tests: [
           {
    -        input: "/<test>",
    -        expected: { path: "/<test>", params: { foo: ["test"] } },
    +        input: "/route",
    +        expected: { path: "/route", params: { foo: "route" } },
           },
           {
    -        input: "/<test><again>",
    +        input: "/route/test/again",
             expected: {
    -          path: "/<test><again>",
    -          params: { foo: ["test", "again"] },
    +          path: "/route/test/again",
    +          params: { foo: "route", bar: "again" },
             },
           },
         ],
    @@ -2046,7 +1313,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
        * Backtracking tests.
        */
       {
    -    path: "{:foo/}?{:bar.}?",
    +    path: "{:foo/}{:bar.}",
         tests: [
           {
             input: "",
    @@ -2066,7 +1333,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "/abc{abc:foo}?",
    +    path: "/abc{abc:foo}",
         tests: [
           {
             input: "/abc",
    @@ -2094,19 +1361,28 @@ export const MATCH_TESTS: MatchTestSet[] = [
         ],
       },
       {
    -    path: "/:foo{abc:bar}?",
    +    path: "/:foo{abc:bar}",
         tests: [
           {
             input: "/abc",
    -        expected: false,
    +        expected: {
    +          params: { foo: "abc" },
    +          path: "/abc",
    +        },
           },
           {
             input: "/abcabc",
    -        expected: false,
    +        expected: {
    +          params: { foo: "abcabc" },
    +          path: "/abcabc",
    +        },
           },
           {
             input: "/abcabc123",
    -        expected: false,
    +        expected: {
    +          params: { foo: "abc", bar: "123" },
    +          path: "/abcabc123",
    +        },
           },
           {
             input: "/acb",
    @@ -2116,10 +1392,17 @@ export const MATCH_TESTS: MatchTestSet[] = [
             },
           },
           {
    -        input: "/acbabc123",
    +        input: "/123",
    +        expected: {
    +          path: "/123",
    +          params: { foo: "123" },
    +        },
    +      },
    +      {
    +        input: "/123abcabc",
             expected: {
    -          path: "/acbabc123",
    -          params: { foo: "acb", bar: "123" },
    +          path: "/123abcabc",
    +          params: { foo: "123abcabc" },
             },
           },
         ],
    @@ -2137,20 +1420,14 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
           {
             input: "/abcabc123",
    -        expected: false,
    -      },
    -    ],
    -  },
    -  {
    -    path: "/:foo(.*){.:ext}?",
    -    tests: [
    -      {
    -        input: "/abc",
    -        expected: { path: "/abc", params: { foo: "abc" } },
    +        expected: {
    +          path: "/abcabc123",
    +          params: { foo: "abc", bar: "123" },
    +        },
           },
           {
    -        input: "/abc.txt",
    -        expected: { path: "/abc.txt", params: { foo: "abc.txt" } },
    +        input: "/123abcabc",
    +        expected: false,
           },
         ],
       },
    @@ -2186,6 +1463,22 @@ export const MATCH_TESTS: MatchTestSet[] = [
           },
         ],
       },
    +  {
    +    path: "/:foo{|:bar|}",
    +    tests: [
    +      {
    +        input: "/hello|world|",
    +        expected: {
    +          path: "/hello|world|",
    +          params: { foo: "hello", bar: "world" },
    +        },
    +      },
    +      {
    +        input: "/hello||",
    +        expected: { path: "/hello||", params: { foo: "hello||" } },
    +      },
    +    ],
    +  },
       {
         path: ":foo\\@:bar",
         tests: [
    @@ -2204,7 +1497,7 @@ export const MATCH_TESTS: MatchTestSet[] = [
        * Multi character delimiters.
        */
       {
    -    path: "%25:foo{%25:bar}?",
    +    path: "%25:foo{%25:bar}",
         options: {
           delimiter: "%25",
         },
    
  • src/index.bench.ts+42 0 added
    @@ -0,0 +1,42 @@
    +import { bench } from "vitest";
    +import { match } from "./index.js";
    +
    +const PATHS: string[] = [
    +  "/xyz",
    +  "/user",
    +  "/user/123",
    +  "/" + "a".repeat(32_000),
    +  "/-" + "-a".repeat(8_000) + "/-",
    +  "/||||\x00|" + "||".repeat(27387) + "|\x00".repeat(27387) + "/||/",
    +];
    +
    +const STATIC_PATH_MATCH = match("/user");
    +const SIMPLE_PATH_MATCH = match("/user/:id");
    +const MULTI_SEGMENT_MATCH = match("/:x/:y");
    +const MULTI_PATTERN_MATCH = match("/:x-:y");
    +const TRICKY_PATTERN_MATCH = match("/:foo|:bar|");
    +const ASTERISK_MATCH = match("/*foo");
    +
    +bench("static path", () => {
    +  for (const path of PATHS) STATIC_PATH_MATCH(path);
    +});
    +
    +bench("simple path", () => {
    +  for (const path of PATHS) SIMPLE_PATH_MATCH(path);
    +});
    +
    +bench("multi segment", () => {
    +  for (const path of PATHS) MULTI_SEGMENT_MATCH(path);
    +});
    +
    +bench("multi pattern", () => {
    +  for (const path of PATHS) MULTI_PATTERN_MATCH(path);
    +});
    +
    +bench("tricky pattern", () => {
    +  for (const path of PATHS) TRICKY_PATTERN_MATCH(path);
    +});
    +
    +bench("asterisk", () => {
    +  for (const path of PATHS) ASTERISK_MATCH(path);
    +});
    
  • src/index.spec.ts+50 124 modified
    @@ -6,89 +6,86 @@ import { PARSER_TESTS, COMPILE_TESTS, MATCH_TESTS } from "./cases.spec.js";
      * Dynamically generate the entire test suite.
      */
     describe("path-to-regexp", () => {
    -  describe("arguments", () => {
    -    it("should throw on non-capturing pattern", () => {
    -      expect(() => match("/:foo(?:\\d+(\\.\\d+)?)")).toThrow(
    +  describe("parse errors", () => {
    +    it("should throw on unbalanced group", () => {
    +      expect(() => parse("/{:foo,")).toThrow(
             new TypeError(
    -          'Pattern cannot start with "?" at 6: https://git.new/pathToRegexpError',
    +          "Unexpected END at 7, expected }: https://git.new/pathToRegexpError",
             ),
           );
         });
    -
    -    it("should throw on nested capturing group", () => {
    -      expect(() => match("/:foo(\\d+(\\.\\d+)?)")).toThrow(
    +    it("should throw on nested unbalanced group", () => {
    +      expect(() => parse("/{:foo/{x,y}")).toThrow(
             new TypeError(
    -          "Capturing groups are not allowed at 9: https://git.new/pathToRegexpError",
    +          "Unexpected END at 12, expected }: https://git.new/pathToRegexpError",
             ),
           );
         });
     
    -    it("should throw on unbalanced pattern", () => {
    -      expect(() => match("/:foo(abc")).toThrow(
    +    it("should throw on missing param name", () => {
    +      expect(() => parse("/:/")).toThrow(
             new TypeError(
    -          "Unbalanced pattern at 5: https://git.new/pathToRegexpError",
    +          "Missing parameter name at 2: https://git.new/pathToRegexpError",
             ),
           );
         });
     
    -    it("should throw on unmatched )", function () {
    -      expect(() => match("/:fooab)c")).toThrow(
    -        new TypeError("Unmatched ) at 7: https://git.new/pathToRegexpError"),
    -      );
    -    });
    -
    -    it("should throw on unmatched ) after other patterns", function () {
    -      expect(() => match("/:test(\\w+)/:foo(\\d+))")).toThrow(
    -        new TypeError("Unmatched ) at 21: https://git.new/pathToRegexpError"),
    -      );
    -    });
    -
    -    it("should throw on missing pattern", () => {
    -      expect(() => match("/:foo()")).toThrow(
    +    it("should throw on missing wildcard name", () => {
    +      expect(() => parse("/*/")).toThrow(
             new TypeError(
    -          "Missing pattern at 5: https://git.new/pathToRegexpError",
    +          "Missing parameter name at 2: https://git.new/pathToRegexpError",
             ),
           );
         });
     
    -    it("should throw on missing name", () => {
    -      expect(() => match("/:(test)")).toThrow(
    +    it("should throw on unterminated quote", () => {
    +      expect(() => parse('/:"foo')).toThrow(
             new TypeError(
    -          "Missing parameter name at 2: https://git.new/pathToRegexpError",
    +          "Unterminated quote at 2: https://git.new/pathToRegexpError",
             ),
           );
         });
    +  });
     
    -    it("should throw on nested groups", () => {
    -      expect(() => match("/{a{b:foo}}")).toThrow(
    -        new TypeError(
    -          "Unexpected { at 3, expected }: https://git.new/pathToRegexpError",
    -        ),
    -      );
    +  describe("compile errors", () => {
    +    it("should throw when a param is missing", () => {
    +      const toPath = compile("/a/:b/c");
    +
    +      expect(() => {
    +        toPath();
    +      }).toThrow(new TypeError("Missing parameters: b"));
         });
     
    -    it("should throw on repeat parameters without a separator", () => {
    -      expect(() => match("{:x}*")).toThrow(
    -        new TypeError(
    -          `Missing separator for "x": https://git.new/pathToRegexpError`,
    -        ),
    -      );
    +    it("should throw when expecting a repeated value", () => {
    +      const toPath = compile("/*foo");
    +
    +      expect(() => {
    +        toPath({ foo: [] });
    +      }).toThrow(new TypeError('Expected "foo" to be a non-empty array'));
         });
     
    -    it("should throw on unterminated quote", () => {
    -      expect(() => match('/:"foo')).toThrow(
    -        new TypeError(
    -          "Unterminated quote at 2: https://git.new/pathToRegexpError",
    -        ),
    -      );
    +    it("should throw when param gets an array", () => {
    +      const toPath = compile("/:foo");
    +
    +      expect(() => {
    +        toPath({ foo: [] });
    +      }).toThrow(new TypeError('Expected "foo" to be a string'));
         });
     
    -    it("should throw on invalid *", () => {
    -      expect(() => match("/:foo*")).toThrow(
    -        new TypeError(
    -          "Unexpected * at 5, you probably want `/*` or `{/:foo}*`: https://git.new/pathToRegexpError",
    -        ),
    -      );
    +    it("should throw when a wildcard is not an array", () => {
    +      const toPath = compile("/*foo");
    +
    +      expect(() => {
    +        toPath({ foo: "a" });
    +      }).toThrow(new TypeError('Expected "foo" to be a non-empty array'));
    +    });
    +
    +    it("should throw when a wildcard array value is not a string", () => {
    +      const toPath = compile("/*foo");
    +
    +      expect(() => {
    +        toPath({ foo: [1, "a"] as any });
    +      }).toThrow(new TypeError('Expected "foo/0" to be a string'));
         });
       });
     
    @@ -126,75 +123,4 @@ describe("path-to-regexp", () => {
           });
         },
       );
    -
    -  describe("compile errors", () => {
    -    it("should throw when a required param is undefined", () => {
    -      const toPath = compile("/a/:b/c");
    -
    -      expect(() => {
    -        toPath();
    -      }).toThrow(new TypeError('Expected "b" to be a string'));
    -    });
    -
    -    it("should throw when it does not match the pattern", () => {
    -      const toPath = compile("/:foo(\\d+)");
    -
    -      expect(() => {
    -        toPath({ foo: "abc" });
    -      }).toThrow(new TypeError('Invalid value for "foo": "abc"'));
    -    });
    -
    -    it("should throw when expecting a repeated value", () => {
    -      const toPath = compile("{/:foo}+");
    -
    -      expect(() => {
    -        toPath({ foo: [] });
    -      }).toThrow(new TypeError('Invalid value for "foo": ""'));
    -    });
    -
    -    it("should throw when not expecting a repeated value", () => {
    -      const toPath = compile("/:foo");
    -
    -      expect(() => {
    -        toPath({ foo: [] });
    -      }).toThrow(new TypeError('Expected "foo" to be a string'));
    -    });
    -
    -    it("should throw when a repeated param is not an array", () => {
    -      const toPath = compile("{/:foo}+");
    -
    -      expect(() => {
    -        toPath({ foo: "a" });
    -      }).toThrow(new TypeError('Expected "foo" to be an array'));
    -    });
    -
    -    it("should throw when an array value is not a string", () => {
    -      const toPath = compile("{/:foo}+");
    -
    -      expect(() => {
    -        toPath({ foo: [1, "a"] as any });
    -      }).toThrow(new TypeError('Expected "foo/0" to be a string'));
    -    });
    -
    -    it("should throw when repeated value does not match", () => {
    -      const toPath = compile("{/:foo(\\d+)}+");
    -
    -      expect(() => {
    -        toPath({ foo: ["1", "2", "3", "a"] });
    -      }).toThrow(new TypeError('Invalid value for "foo": "/1/2/3/a"'));
    -    });
    -  });
     });
    -
    -/**
    - * Execute a regular expression and return a flat array for comparison.
    - *
    - * @param  {RegExp} re
    - * @param  {String} str
    - * @return {Array}
    - */
    -function exec(re: RegExp, str: string) {
    -  const match = re.exec(str);
    -
    -  return match && Array.prototype.slice.call(match);
    -}
    
  • src/index.ts+346 417 modified
    @@ -15,24 +15,13 @@ export type Encode = (value: string) => string;
     export type Decode = (value: string) => string;
     
     export interface ParseOptions {
    -  /**
    -   * The default delimiter for segments. (default: `'/'`)
    -   */
    -  delimiter?: string;
       /**
        * A function for encoding input strings.
        */
       encodePath?: Encode;
     }
     
    -export interface PathOptions {
    -  /**
    -   * Regexp will be case sensitive. (default: `false`)
    -   */
    -  sensitive?: boolean;
    -}
    -
    -export interface MatchOptions extends PathOptions {
    +export interface MatchOptions {
       /**
        * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`)
        */
    @@ -41,35 +30,47 @@ export interface MatchOptions extends PathOptions {
        * Matches the path completely without trailing characters. (default: `true`)
        */
       end?: boolean;
    -}
    -
    -export interface CompileOptions extends PathOptions {
       /**
    -   * Verifies the function is producing a valid path. (default: `true`)
    +   * Allows optional trailing delimiter to match. (default: `true`)
    +   */
    +  trailing?: boolean;
    +  /**
    +   * Match will be case sensitive. (default: `false`)
    +   */
    +  sensitive?: boolean;
    +  /**
    +   * The default delimiter for segments. (default: `'/'`)
        */
    -  validate?: boolean;
    +  delimiter?: string;
    +}
    +
    +export interface CompileOptions {
       /**
        * Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`)
        */
       encode?: Encode | false;
    +  /**
    +   * The default delimiter for segments. (default: `'/'`)
    +   */
    +  delimiter?: string;
     }
     
     type TokenType =
       | "{"
       | "}"
    -  | ";"
    -  | "*"
    -  | "+"
    -  | "?"
    -  | "NAME"
    -  | "PATTERN"
    +  | "WILDCARD"
    +  | "PARAM"
       | "CHAR"
       | "ESCAPED"
       | "END"
    -  // Reserved for use.
    -  | "!"
    -  | "@"
    -  | ",";
    +  // Reserved for use or ambiguous due to past use.
    +  | "("
    +  | ")"
    +  | "["
    +  | "]"
    +  | "+"
    +  | "?"
    +  | "!";
     
     /**
      * Tokenizer results.
    @@ -81,153 +82,120 @@ interface LexToken {
     }
     
     const SIMPLE_TOKENS: Record<string, TokenType> = {
    -  "!": "!",
    -  "@": "@",
    -  ";": ";",
    -  ",": ",",
    -  "*": "*",
    -  "+": "+",
    -  "?": "?",
    +  // Groups.
       "{": "{",
       "}": "}",
    +  // Reserved.
    +  "(": "(",
    +  ")": ")",
    +  "[": "[",
    +  "]": "]",
    +  "+": "+",
    +  "?": "?",
    +  "!": "!",
     };
     
    +/**
    + * Escape a regular expression string.
    + */
    +function escape(str: string) {
    +  return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&");
    +}
    +
    +/**
    + * Get the flags for a regexp from the options.
    + */
    +function toFlags(options: { sensitive?: boolean }) {
    +  return options.sensitive ? "s" : "is";
    +}
    +
     /**
      * Tokenize input string.
      */
    -function lexer(str: string) {
    +function* lexer(str: string): Generator<LexToken, LexToken> {
       const chars = [...str];
    -  const tokens: LexToken[] = [];
       let i = 0;
     
    -  while (i < chars.length) {
    -    const value = chars[i];
    -    const type = SIMPLE_TOKENS[value];
    -
    -    if (type) {
    -      tokens.push({ type, index: i++, value });
    -      continue;
    -    }
    +  function name() {
    +    let value = "";
     
    -    if (value === "\\") {
    -      tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] });
    -      continue;
    -    }
    -
    -    if (value === ":") {
    -      let name = "";
    -
    -      if (ID_START.test(chars[++i])) {
    -        name += chars[i];
    -        while (ID_CONTINUE.test(chars[++i])) {
    -          name += chars[i];
    -        }
    -      } else if (chars[i] === '"') {
    -        let pos = i;
    -
    -        while (i < chars.length) {
    -          if (chars[++i] === '"') {
    -            i++;
    -            pos = 0;
    -            break;
    -          }
    -
    -          if (chars[i] === "\\") {
    -            name += chars[++i];
    -          } else {
    -            name += chars[i];
    -          }
    -        }
    -
    -        if (pos) {
    -          throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`);
    -        }
    -      }
    -
    -      if (!name) {
    -        throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`);
    -      }
    -
    -      tokens.push({ type: "NAME", index: i, value: name });
    -      continue;
    -    }
    -
    -    if (value === "(") {
    -      const pos = i++;
    -      let count = 1;
    -      let pattern = "";
    -
    -      if (chars[i] === "?") {
    -        throw new TypeError(
    -          `Pattern cannot start with "?" at ${i}: ${DEBUG_URL}`,
    -        );
    +    if (ID_START.test(chars[++i])) {
    +      value += chars[i];
    +      while (ID_CONTINUE.test(chars[++i])) {
    +        value += chars[i];
           }
    +    } else if (chars[i] === '"') {
    +      let pos = i;
     
           while (i < chars.length) {
    -        if (chars[i] === "\\") {
    -          pattern += chars[i++] + chars[i++];
    -          continue;
    +        if (chars[++i] === '"') {
    +          i++;
    +          pos = 0;
    +          break;
             }
     
    -        if (chars[i] === ")") {
    -          count--;
    -          if (count === 0) {
    -            i++;
    -            break;
    -          }
    -        } else if (chars[i] === "(") {
    -          count++;
    -          if (chars[i + 1] !== "?") {
    -            throw new TypeError(
    -              `Capturing groups are not allowed at ${i}: ${DEBUG_URL}`,
    -            );
    -          }
    +        if (chars[i] === "\\") {
    +          value += chars[++i];
    +        } else {
    +          value += chars[i];
             }
    -
    -        pattern += chars[i++];
           }
     
    -      if (count) {
    -        throw new TypeError(`Unbalanced pattern at ${pos}: ${DEBUG_URL}`);
    +      if (pos) {
    +        throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`);
           }
    -
    -      if (!pattern) {
    -        throw new TypeError(`Missing pattern at ${pos}: ${DEBUG_URL}`);
    -      }
    -
    -      tokens.push({ type: "PATTERN", index: i, value: pattern });
    -      continue;
         }
     
    -    if (value === ")") {
    -      throw new TypeError(`Unmatched ) at ${i}: ${DEBUG_URL}`);
    +    if (!value) {
    +      throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`);
         }
     
    -    tokens.push({ type: "CHAR", index: i, value: chars[i++] });
    +    return value;
       }
     
    -  tokens.push({ type: "END", index: i, value: "" });
    +  while (i < chars.length) {
    +    const value = chars[i];
    +    const type = SIMPLE_TOKENS[value];
     
    -  return new Iter(tokens);
    +    if (type) {
    +      yield { type, index: i++, value };
    +    } else if (value === "\\") {
    +      yield { type: "ESCAPED", index: i++, value: chars[i++] };
    +    } else if (value === ":") {
    +      const value = name();
    +      yield { type: "PARAM", index: i, value };
    +    } else if (value === "*") {
    +      const value = name();
    +      yield { type: "WILDCARD", index: i, value };
    +    } else {
    +      yield { type: "CHAR", index: i, value: chars[i++] };
    +    }
    +  }
    +
    +  return { type: "END", index: i, value: "" };
     }
     
     class Iter {
    -  index = 0;
    +  #peek?: LexToken;
     
    -  constructor(private tokens: LexToken[]) {}
    +  constructor(private tokens: Generator<LexToken, LexToken>) {}
     
       peek(): LexToken {
    -    return this.tokens[this.index];
    +    if (!this.#peek) {
    +      const next = this.tokens.next();
    +      this.#peek = next.value;
    +    }
    +    return this.#peek;
       }
     
    -  tryConsume(type: LexToken["type"]): string | undefined {
    +  tryConsume(type: TokenType): string | undefined {
         const token = this.peek();
         if (token.type !== type) return;
    -    this.index++;
    +    this.#peek = undefined; // Reset after consumed.
         return token.value;
       }
     
    -  consume(type: LexToken["type"]): string {
    +  consume(type: TokenType): string {
         const value = this.tryConsume(type);
         if (value !== undefined) return value;
         const { type: nextType, index } = this.peek();
    @@ -244,223 +212,216 @@ class Iter {
         }
         return result;
       }
    +}
     
    -  modifier(): string | undefined {
    -    return this.tryConsume("?") || this.tryConsume("*") || this.tryConsume("+");
    -  }
    +/**
    + * Plain text.
    + */
    +export interface Text {
    +  type: "text";
    +  value: string;
     }
     
     /**
    - * Tokenized path instance. Can we passed around instead of string.
    + * A parameter designed to match arbitrary text within a segment.
    + */
    +export interface Parameter {
    +  type: "param";
    +  name: string;
    +}
    +
    +/**
    + * A wildcard parameter designed to match multiple segments.
    + */
    +export interface Wildcard {
    +  type: "wildcard";
    +  name: string;
    +}
    +
    +/**
    + * A set of possible tokens to expand when matching.
    + */
    +export interface Group {
    +  type: "group";
    +  tokens: Token[];
    +}
    +
    +/**
    + * A sequence of path match characters.
    + */
    +export type Token = Text | Parameter | Wildcard | Group;
    +
    +/**
    + * Tokenized path instance.
      */
     export class TokenData {
    -  constructor(
    -    public readonly tokens: Token[],
    -    public readonly delimiter: string,
    -  ) {}
    +  constructor(public readonly tokens: Token[]) {}
     }
     
     /**
      * Parse a string for the raw tokens.
      */
     export function parse(str: string, options: ParseOptions = {}): TokenData {
    -  const { encodePath = NOOP_VALUE, delimiter = encodePath(DEFAULT_DELIMITER) } =
    -    options;
    -  const tokens: Token[] = [];
    -  const it = lexer(str);
    -  let key = 0;
    -
    -  do {
    -    const path = it.text();
    -    if (path) tokens.push(encodePath(path));
    -
    -    const name = it.tryConsume("NAME");
    -    const pattern = it.tryConsume("PATTERN");
    -
    -    if (name || pattern) {
    -      tokens.push({
    -        name: name || String(key++),
    -        pattern,
    -      });
    -
    -      const next = it.peek();
    -      if (next.type === "*") {
    -        throw new TypeError(
    -          `Unexpected * at ${next.index}, you probably want \`/*\` or \`{/:foo}*\`: ${DEBUG_URL}`,
    -        );
    +  const { encodePath = NOOP_VALUE } = options;
    +  const it = new Iter(lexer(str));
    +
    +  function consume(endType: TokenType): Token[] {
    +    const tokens: Token[] = [];
    +
    +    while (true) {
    +      const path = it.text();
    +      if (path) tokens.push({ type: "text", value: encodePath(path) });
    +
    +      const param = it.tryConsume("PARAM");
    +      if (param) {
    +        tokens.push({
    +          type: "param",
    +          name: param,
    +        });
    +        continue;
           }
     
    -      continue;
    -    }
    +      const wildcard = it.tryConsume("WILDCARD");
    +      if (wildcard) {
    +        tokens.push({
    +          type: "wildcard",
    +          name: wildcard,
    +        });
    +        continue;
    +      }
     
    -    const asterisk = it.tryConsume("*");
    -    if (asterisk) {
    -      tokens.push({
    -        name: String(key++),
    -        pattern: `${negate(delimiter)}*`,
    -        modifier: "*",
    -        separator: delimiter,
    -      });
    -      continue;
    -    }
    +      const open = it.tryConsume("{");
    +      if (open) {
    +        tokens.push({
    +          type: "group",
    +          tokens: consume("}"),
    +        });
    +        continue;
    +      }
     
    -    const open = it.tryConsume("{");
    -    if (open) {
    -      const prefix = it.text();
    -      const name = it.tryConsume("NAME");
    -      const pattern = it.tryConsume("PATTERN");
    -      const suffix = it.text();
    -      const separator = it.tryConsume(";") && it.text();
    -
    -      it.consume("}");
    -
    -      const modifier = it.modifier();
    -
    -      tokens.push({
    -        name: name || (pattern ? String(key++) : ""),
    -        prefix: encodePath(prefix),
    -        suffix: encodePath(suffix),
    -        pattern,
    -        modifier,
    -        separator,
    -      });
    -      continue;
    +      it.consume(endType);
    +      return tokens;
         }
    +  }
     
    -    it.consume("END");
    -    break;
    -  } while (true);
    +  const tokens = consume("END");
    +  return new TokenData(tokens);
    +}
    +
    +/**
    + * Transform tokens into a path building function.
    + */
    +function $compile<P extends ParamData>(
    +  data: TokenData,
    +  options: CompileOptions,
    +): PathFunction<P> {
    +  const { encode = encodeURIComponent, delimiter = DEFAULT_DELIMITER } =
    +    options;
    +  const fn = tokensToFunction(data.tokens, delimiter, encode);
     
    -  return new TokenData(tokens, delimiter);
    +  return function path(data: P = {} as P) {
    +    const [path, ...missing] = fn(data);
    +    if (missing.length) {
    +      throw new TypeError(`Missing parameters: ${missing.join(", ")}`);
    +    }
    +    return path;
    +  };
     }
     
     /**
      * Compile a string to a template function for the path.
      */
     export function compile<P extends ParamData = ParamData>(
    -  path: string,
    +  path: Path,
       options: CompileOptions & ParseOptions = {},
     ) {
    -  return $compile<P>(parse(path, options), options);
    +  return $compile<P>(
    +    path instanceof TokenData ? path : parse(path, options),
    +    options,
    +  );
     }
     
     export type ParamData = Partial<Record<string, string | string[]>>;
     export type PathFunction<P extends ParamData> = (data?: P) => string;
     
    -/**
    - * Check if a key repeats.
    - */
    -export function isRepeat(key: Key) {
    -  return key.modifier === "+" || key.modifier === "*";
    -}
    +function tokensToFunction(
    +  tokens: Token[],
    +  delimiter: string,
    +  encode: Encode | false,
    +) {
    +  const encoders = tokens.map((token) =>
    +    tokenToFunction(token, delimiter, encode),
    +  );
     
    -/**
    - * Check if a key is optional.
    - */
    -export function isOptional(key: Key) {
    -  return key.modifier === "?" || key.modifier === "*";
    +  return (data: ParamData) => {
    +    const result: string[] = [""];
    +
    +    for (const encoder of encoders) {
    +      const [value, ...extras] = encoder(data);
    +      result[0] += value;
    +      result.push(...extras);
    +    }
    +
    +    return result;
    +  };
     }
     
     /**
      * Convert a single token into a path building function.
      */
    -function keyToFunction(
    -  key: Key,
    +function tokenToFunction(
    +  token: Token,
    +  delimiter: string,
       encode: Encode | false,
    -): (data: ParamData) => string {
    -  const encodeValue = encode || NOOP_VALUE;
    -  const { prefix = "", suffix = "", separator = suffix + prefix } = key;
    -
    -  if (encode && isRepeat(key)) {
    -    const stringify = (value: string, index: number) => {
    -      if (typeof value !== "string") {
    -        throw new TypeError(`Expected "${key.name}/${index}" to be a string`);
    -      }
    -      return encodeValue(value);
    -    };
    -
    -    const compile = (value: unknown) => {
    -      if (!Array.isArray(value)) {
    -        throw new TypeError(`Expected "${key.name}" to be an array`);
    -      }
    +): (data: ParamData) => string[] {
    +  if (token.type === "text") return () => [token.value];
     
    -      if (value.length === 0) return "";
    +  if (token.type === "group") {
    +    const fn = tokensToFunction(token.tokens, delimiter, encode);
     
    -      return prefix + value.map(stringify).join(separator) + suffix;
    +    return (data) => {
    +      const [value, ...missing] = fn(data);
    +      if (!missing.length) return [value];
    +      return [""];
         };
    +  }
     
    -    if (isOptional(key)) {
    -      return (data): string => {
    -        const value = data[key.name];
    -        if (value == null) return "";
    -        return value.length ? compile(value) : "";
    -      };
    -    }
    +  const encodeValue = encode || NOOP_VALUE;
     
    -    return (data): string => {
    -      const value = data[key.name];
    -      return compile(value);
    -    };
    -  }
    +  if (token.type === "wildcard" && encode !== false) {
    +    return (data) => {
    +      const value = data[token.name];
    +      if (value == null) return ["", token.name];
     
    -  const stringify = (value: unknown) => {
    -    if (typeof value !== "string") {
    -      throw new TypeError(`Expected "${key.name}" to be a string`);
    -    }
    -    return prefix + encodeValue(value) + suffix;
    -  };
    +      if (!Array.isArray(value) || value.length === 0) {
    +        throw new TypeError(`Expected "${token.name}" to be a non-empty array`);
    +      }
     
    -  if (isOptional(key)) {
    -    return (data): string => {
    -      const value = data[key.name];
    -      if (value == null) return "";
    -      return stringify(value);
    +      return [
    +        value
    +          .map((value, index) => {
    +            if (typeof value !== "string") {
    +              throw new TypeError(
    +                `Expected "${token.name}/${index}" to be a string`,
    +              );
    +            }
    +
    +            return encodeValue(value);
    +          })
    +          .join(delimiter),
    +      ];
         };
       }
     
    -  return (data): string => {
    -    const value = data[key.name];
    -    return stringify(value);
    -  };
    -}
    +  return (data) => {
    +    const value = data[token.name];
    +    if (value == null) return ["", token.name];
     
    -/**
    - * Transform tokens into a path building function.
    - */
    -export function $compile<P extends ParamData>(
    -  data: TokenData,
    -  options: CompileOptions,
    -): PathFunction<P> {
    -  const { encode = encodeURIComponent, validate = true } = options;
    -  const flags = toFlags(options);
    -  const sources = toRegExpSource(data, []);
    -
    -  // Compile all the tokens into regexps.
    -  const encoders: Array<(data: ParamData) => string> = data.tokens.map(
    -    (token, index) => {
    -      if (typeof token === "string") return () => token;
    -
    -      const fn = keyToFunction(token, encode);
    -      if (!validate) return fn;
    -
    -      const validRe = new RegExp(`^${sources[index]}$`, flags);
    -
    -      return (data) => {
    -        const value = fn(data);
    -        if (!validRe.test(value)) {
    -          throw new TypeError(
    -            `Invalid value for "${token.name}": ${JSON.stringify(value)}`,
    -          );
    -        }
    -        return value;
    -      };
    -    },
    -  );
    +    if (typeof value !== "string") {
    +      throw new TypeError(`Expected "${token.name}" to be a string`);
    +    }
     
    -  return function path(data: Record<string, any> = {}) {
    -    let path = "";
    -    for (const encoder of encoders) path += encoder(data);
    -    return path;
    +    return [encodeValue(value)];
       };
     }
     
    @@ -485,42 +446,45 @@ export type MatchFunction<P extends ParamData> = (path: string) => Match<P>;
     /**
      * Create path match function from `path-to-regexp` spec.
      */
    -export function $match<P extends ParamData>(
    -  data: TokenData,
    +function $match<P extends ParamData>(
    +  data: TokenData[],
       options: MatchOptions = {},
     ): MatchFunction<P> {
    -  const { decode = decodeURIComponent, end = true } = options;
    -  const { delimiter } = data;
    -  const keys: Key[] = [];
    +  const {
    +    decode = decodeURIComponent,
    +    delimiter = DEFAULT_DELIMITER,
    +    end = true,
    +    trailing = true,
    +  } = options;
       const flags = toFlags(options);
    -  const sources = toRegExpSource(data, keys);
    -  const re = new RegExp(
    -    `^${sources.join("")}(?=${escape(delimiter)}|$)`,
    -    flags,
    -  );
    -
    -  const decoders = keys.map((key) => {
    -    if (!decode) return NOOP_VALUE;
    +  const sources: string[] = [];
    +  const keys: Array<Parameter | Wildcard> = [];
     
    -    if (isRepeat(key)) {
    -      const { prefix = "", suffix = "", separator = suffix + prefix } = key;
    -      return (value: string) => value.split(separator).map(decode);
    +  for (const { tokens } of data) {
    +    for (const seq of flatten(tokens, 0, [])) {
    +      const regexp = sequenceToRegExp(seq, delimiter, keys);
    +      sources.push(regexp);
         }
    +  }
     
    -    return decode;
    -  });
    +  let pattern = `^(?:${sources.join("|")})`;
    +  if (trailing) pattern += `(?:${escape(delimiter)}$)?`;
    +  pattern += end ? "$" : `(?=${escape(delimiter)}|$)`;
     
    -  const isValid = end
    -    ? (a: string, b: string) => a.length === b.length
    -    : () => true;
    +  const re = new RegExp(pattern, flags);
    +
    +  const decoders = keys.map((key) => {
    +    if (decode === false) return NOOP_VALUE;
    +    if (key.type === "param") return decode;
    +    return (value: string) => value.split(delimiter).map(decode);
    +  });
     
       return Object.assign(
         function match(input: string) {
           const m = re.exec(input);
           if (!m) return false;
     
           const { 0: path } = m;
    -      if (!isValid(input, path)) return false;
           const params = Object.create(null);
     
           for (let i = 1; i < m.length; i++) {
    @@ -537,132 +501,97 @@ export function $match<P extends ParamData>(
       );
     }
     
    +export type Path = string | TokenData;
    +
     export function match<P extends ParamData>(
    -  path: string,
    +  path: Path | Path[],
       options: MatchOptions & ParseOptions = {},
     ): MatchFunction<P> {
    -  return $match(parse(path, options), options);
    -}
    +  const paths = Array.isArray(path) ? path : [path];
    +  const items = paths.map((path) =>
    +    path instanceof TokenData ? path : parse(path, options),
    +  );
     
    -/**
    - * Escape a regular expression string.
    - */
    -function escape(str: string) {
    -  return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&");
    +  return $match(items, options);
     }
     
     /**
    - * Get the flags for a regexp from the options.
    + * Flattened token set.
      */
    -function toFlags(options: { sensitive?: boolean }) {
    -  return options.sensitive ? "s" : "is";
    -}
    +type Flattened = Text | Parameter | Wildcard;
     
     /**
    - * A key is a capture group in the regex.
    + * Generate a flat list of sequence tokens from the given tokens.
      */
    -export interface Key {
    -  name: string;
    -  prefix?: string;
    -  suffix?: string;
    -  pattern?: string;
    -  modifier?: string;
    -  separator?: string;
    -}
    +function* flatten(
    +  tokens: Token[],
    +  index: number,
    +  init: Flattened[],
    +): Generator<Flattened[]> {
    +  if (index === tokens.length) {
    +    return yield init;
    +  }
     
    -/**
    - * A token is a string (nothing special) or key metadata (capture group).
    - */
    -export type Token = string | Key;
    +  const token = tokens[index];
    +
    +  if (token.type === "group") {
    +    const fork = init.slice();
    +    for (const seq of flatten(token.tokens, 0, fork)) {
    +      yield* flatten(tokens, index + 1, seq);
    +    }
    +  } else {
    +    init.push(token);
    +  }
    +
    +  yield* flatten(tokens, index + 1, init);
    +}
     
     /**
    - * Convert a token into a regexp string (re-used for path validation).
    + * Transform a flat sequence of tokens into a regular expression.
      */
    -function toRegExpSource(data: TokenData, keys: Key[]): string[] {
    -  const sources = Array(data.tokens.length);
    +function sequenceToRegExp(
    +  tokens: Flattened[],
    +  delimiter: string,
    +  keys: Array<Parameter | Wildcard>,
    +): string {
    +  let result = "";
       let backtrack = "";
    +  let isSafeSegmentParam = true;
     
    -  let i = data.tokens.length;
    +  for (let i = 0; i < tokens.length; i++) {
    +    const token = tokens[i];
     
    -  while (i--) {
    -    const token = data.tokens[i];
    -
    -    if (typeof token === "string") {
    -      backtrack = token;
    -      sources[i] = escape(token);
    +    if (token.type === "text") {
    +      result += escape(token.value);
    +      backtrack = token.value;
    +      isSafeSegmentParam ||= token.value.includes(delimiter);
           continue;
         }
     
    -    const {
    -      prefix = "",
    -      suffix = "",
    -      separator = suffix + prefix,
    -      modifier = "",
    -    } = token;
    -
    -    const pre = escape(prefix);
    -    const post = escape(suffix);
    -
    -    if (token.name) {
    -      backtrack = suffix || backtrack;
    -      keys.unshift(token);
    -
    -      if (isRepeat(token)) {
    -        if (!separator) {
    -          throw new TypeError(
    -            `Missing separator for "${token.name}": ${DEBUG_URL}`,
    -          );
    -        }
    +    if (token.type === "param" || token.type === "wildcard") {
    +      if (!isSafeSegmentParam && !backtrack) {
    +        throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`);
    +      }
     
    -        const mod = modifier === "*" ? "?" : "";
    -        const sep = escape(separator);
    -        const pattern =
    -          token.pattern || `${negate(data.delimiter, separator, backtrack)}+`;
    -
    -        sources[i] = wrap(
    -          pre,
    -          `(?:${pattern})(?:${sep}(?:${pattern}))*`,
    -          post,
    -          mod,
    -        );
    +      if (token.type === "param") {
    +        result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`;
           } else {
    -        sources[i] = wrap(
    -          pre,
    -          token.pattern || `${negate(data.delimiter, backtrack)}+`,
    -          post,
    -          modifier,
    -        );
    +        result += `(.+)`;
           }
     
    -      backtrack = prefix;
    -    } else {
    -      sources[i] = `(?:${pre}${post})${modifier}`;
    -      backtrack = `${prefix}${suffix}`;
    +      keys.push(token);
    +      backtrack = "";
    +      isSafeSegmentParam = false;
    +      continue;
         }
       }
     
    -  return sources;
    +  return result;
     }
     
    -function negate(...args: string[]) {
    -  const values = args.sort().filter((value, index, array) => {
    -    for (let i = 0; i < index; i++) {
    -      const v = array[i];
    -      if (v.length && value.startsWith(v)) return false;
    -    }
    -    return value.length > 0;
    -  });
    -
    +function negate(delimiter: string, backtrack: string) {
    +  const values = [delimiter, backtrack].filter(Boolean);
       const isSimple = values.every((value) => value.length === 1);
       if (isSimple) return `[^${escape(values.join(""))}]`;
    -
       return `(?:(?!${values.map(escape).join("|")}).)`;
     }
    -
    -function wrap(pre: string, pattern: string, post: string, modifier: string) {
    -  if (pre || post) {
    -    return `(?:${pre}(${pattern})${post})${modifier}`;
    -  }
    -
    -  return `(${pattern})${modifier}`;
    -}
    
  • tsconfig.build.json+1 1 modified
    @@ -3,5 +3,5 @@
       "compilerOptions": {
         "types": []
       },
    -  "exclude": ["src/**/*.spec.ts"]
    +  "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"]
     }
    
29b96b4a1de5

Add backtrack protection to parameters

https://github.com/pillarjs/path-to-regexpBlake EmbreyAug 28, 2024via ghsa
2 files changed · +90 40
  • index.js+51 39 modified
    @@ -1,13 +1,13 @@
     /**
    - * Expose `pathtoRegexp`.
    + * Expose `pathToRegexp`.
      */
     
    -module.exports = pathtoRegexp;
    +module.exports = pathToRegexp;
     
     /**
      * Match matching groups in a regular expression.
      */
    -var MATCHING_GROUP_REGEXP = /\((?:\?<(.*?)>)?(?!\?)/g;
    +var MATCHING_GROUP_REGEXP = /\\.|\((?:\?<(.*?)>)?(?!\?)/g;
     
     /**
      * Normalize the given path string,
    @@ -25,7 +25,7 @@ var MATCHING_GROUP_REGEXP = /\((?:\?<(.*?)>)?(?!\?)/g;
      * @api private
      */
     
    -function pathtoRegexp(path, keys, options) {
    +function pathToRegexp(path, keys, options) {
       options = options || {};
       keys = keys || [];
       var strict = options.strict;
    @@ -36,10 +36,14 @@ function pathtoRegexp(path, keys, options) {
       var keysOffset = keys.length;
       var i = 0;
       var name = 0;
    +  var pos = 0;
    +  var backtrack = '';
       var m;
     
       if (path instanceof RegExp) {
         while (m = MATCHING_GROUP_REGEXP.exec(path.source)) {
    +      if (m[0][0] === '\\') continue;
    +
           keys.push({
             name: m[1] || name++,
             optional: false,
    @@ -55,62 +59,68 @@ function pathtoRegexp(path, keys, options) {
         // the same keys and options instance into every generation to get
         // consistent matching groups before we join the sources together.
         path = path.map(function (value) {
    -      return pathtoRegexp(value, keys, options).source;
    +      return pathToRegexp(value, keys, options).source;
         });
     
    -    return new RegExp('(?:' + path.join('|') + ')', flags);
    +    return new RegExp(path.join('|'), flags);
       }
     
    -  path = ('^' + path + (strict ? '' : path[path.length - 1] === '/' ? '?' : '/?'))
    -    .replace(/\/\(/g, '/(?:')
    -    .replace(/([\/\.])/g, '\\$1')
    -    .replace(/(\\\/)?(\\\.)?:(\w+)(\(.*?\))?(\*)?(\?)?/g, function (match, slash, format, key, capture, star, optional, offset) {
    +  path = path.replace(
    +    /\\.|(\/)?(\.)?:(\w+)(\(.*?\))?(\*)?(\?)?|[.*]|\/\(/g,
    +    function (match, slash, format, key, capture, star, optional, offset) {
    +      pos = offset + match.length;
    +
    +      if (match[0] === '\\') {
    +        backtrack += match;
    +        return match;
    +      }
    +
    +      if (match === '.') {
    +        backtrack += '\\.';
    +        extraOffset += 1;
    +        return '\\.';
    +      }
    +
    +      backtrack = slash || format ? '' : path.slice(pos, offset);
    +
    +      if (match === '*') {
    +        extraOffset += 3;
    +        return '(.*)';
    +      }
    +
    +      if (match === '/(') {
    +        backtrack += '/';
    +        extraOffset += 2;
    +        return '/(?:';
    +      }
    +
           slash = slash || '';
    -      format = format || '';
    -      capture = capture || '([^\\/' + format + ']+?)';
    +      format = format ? '\\.' : '';
           optional = optional || '';
    +      capture = capture ?
    +        capture.replace(/\\.|\*/, function (m) { return m === '*' ? '(.*)' : m; }) :
    +        (backtrack ? '((?:(?!/|' + backtrack + ').)+?)' : '([^/' + format + ']+?)');
     
           keys.push({
             name: key,
             optional: !!optional,
             offset: offset + extraOffset
           });
     
    -      var result = ''
    -        + (optional ? '' : slash)
    -        + '(?:'
    -        + format + (optional ? slash : '') + capture
    -        + (star ? '((?:[\\/' + format + '].+?)?)' : '')
    +      var result = '(?:'
    +        + format + slash + capture
    +        + (star ? '((?:[/' + format + '].+?)?)' : '')
             + ')'
             + optional;
     
           extraOffset += result.length - match.length;
     
           return result;
    -    })
    -    .replace(/\*/g, function (star, index) {
    -      var len = keys.length
    -
    -      while (len-- > keysOffset && keys[len].offset > index) {
    -        keys[len].offset += 3; // Replacement length minus asterisk length.
    -      }
    -
    -      return '(.*)';
         });
     
       // This is a workaround for handling unnamed matching groups.
       while (m = MATCHING_GROUP_REGEXP.exec(path)) {
    -    var escapeCount = 0;
    -    var index = m.index;
    -
    -    while (path.charAt(--index) === '\\') {
    -      escapeCount++;
    -    }
    -
    -    // It's possible to escape the bracket.
    -    if (escapeCount % 2 === 1) {
    -      continue;
    -    }
    +    if (m[0][0] === '\\') continue;
     
         if (keysOffset + i === keys.length || keys[keysOffset + i].offset > m.index) {
           keys.splice(keysOffset + i, 0, {
    @@ -123,12 +133,14 @@ function pathtoRegexp(path, keys, options) {
         i++;
       }
     
    +  path += strict ? '' : path[path.length - 1] === '/' ? '?' : '/?';
    +
       // If the path is non-ending, match until the end or a slash.
       if (end) {
         path += '$';
       } else if (path[path.length - 1] !== '/') {
    -    path += lookahead ? '(?=\\/|$)' : '(?:\/|$)';
    +    path += lookahead ? '(?=/|$)' : '(?:/|$)';
       }
     
    -  return new RegExp(path, flags);
    +  return new RegExp('^' + path, flags);
     };
    
  • test.js+39 1 modified
    @@ -763,6 +763,44 @@ describe('path-to-regexp', function () {
           assert.equal(m[0], '/test.json');
           assert.equal(m[1], 'test.json');
         });
    +
    +    it('should match after a non-slash or format character', function () {
    +      var params = [];
    +      var re = pathToRegExp('/:x-:y', params);
    +      var m;
    +
    +      assert.equal(params.length, 2);
    +      assert.equal(params[0].name, 'x');
    +      assert.equal(params[0].optional, false);
    +      assert.equal(params[1].name, 'y');
    +      assert.equal(params[1].optional, false);
    +
    +      m = re.exec('/1-2');
    +
    +      assert.equal(m.length, 3);
    +      assert.equal(m[0], '/1-2');
    +      assert.equal(m[1], '1');
    +      assert.equal(m[2], '2');
    +    });
    +
    +    it('should replace asterisk in capture group', function () {
    +      var params = [];
    +      var re = pathToRegExp('/files/:file(*)', params);
    +      var m;
    +
    +      assert.equal(params.length, 2);
    +      assert.equal(params[0].name, 'file');
    +      assert.equal(params[0].optional, false);
    +      assert.equal(params[1].name, 0);
    +      assert.equal(params[1].optional, false);
    +
    +      m = re.exec('/files/test');
    +
    +      assert.equal(m.length, 3);
    +      assert.equal(m[0], '/files/test');
    +      assert.equal(m[1], 'test');
    +      assert.equal(m[2], 'test');
    +    })
       });
     
       describe('regexps', function () {
    @@ -812,7 +850,7 @@ describe('path-to-regexp', function () {
           assert.equal(m[1], 'foo');
           assert.equal(m[2], 'bar');
           assert.equal(m[3], 'baz');
    -    })
    +    });
       });
     
       describe('arrays', function () {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

11

News mentions

0

No linked articles in our index yet.