From c7474e04798d42eb2e5a59f97fd7696be7a01c60 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 10 Apr 2026 23:39:15 +0200 Subject: [PATCH] fix: support ipv6 addresses --- src/index.ts | 26 +++++++++++++-- tests/ipv6.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/ipv6.test.ts diff --git a/src/index.ts b/src/index.ts index bb628ee..5876d41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ const QUESTION_MARK = 63 const PLUS = 43 const BACKSLASH = 92 const UNDERSCORE = 95 +const OPEN_BRACKET = 91 +const CLOSE_BRACKET = 93 const enum TokenType { Literal, @@ -95,6 +97,12 @@ function parsePattern(pattern: string): Array { const tokens: Array = [] let i = 0 const length = pattern.length + // Tracks whether the cursor is inside a `[...]` group, used for IPv6 + // host literals like `http://[2001:db8::1]/path`. Inside brackets, + // `:` and `*` are treated as literal characters so that hextets + // beginning with hex letters (e.g. `:db8`) are not mis-parsed as + // path parameters. + let inBrackets = false while (i < length) { const code = pattern.charCodeAt(i) @@ -103,7 +111,7 @@ function parsePattern(pattern: string): Array { // Escaped character — consume the backslash and the next character // as a literal. Fall through to the literal branch below by not // advancing `i` here (the literal branch handles it). - } else if (code === ASTERISK) { + } else if (code === ASTERISK && !inBrackets) { tokens.push({ type: TokenType.Wildcard, nextLiteral: undefined, @@ -112,6 +120,7 @@ function parsePattern(pattern: string): Array { continue } else if ( code === COLON && + !inBrackets && i + 1 < length && isIdentStartCode(pattern.charCodeAt(i + 1)) ) { @@ -157,12 +166,25 @@ function parsePattern(pattern: string): Array { continue } - if (charCode === ASTERISK) { + if (charCode === OPEN_BRACKET) { + inBrackets = true + i++ + continue + } + + if (charCode === CLOSE_BRACKET) { + inBrackets = false + i++ + continue + } + + if (charCode === ASTERISK && !inBrackets) { break } if ( charCode === COLON && + !inBrackets && i + 1 < length && isIdentStartCode(pattern.charCodeAt(i + 1)) ) { diff --git a/tests/ipv6.test.ts b/tests/ipv6.test.ts new file mode 100644 index 0000000..abc9352 --- /dev/null +++ b/tests/ipv6.test.ts @@ -0,0 +1,82 @@ +import { matchPattern, MatchResult } from '#src/index.js' +import { + NO_MATCH, + MATCHES_WITH_PARAMS, + MATCHES_WITHOUT_PARAMS, +} from '#tests/utils.js' + +it.each< + [ + Parameters[0], + Parameters[1], + MatchResult, + ] +>([ + /* Literal IPv6 addresses */ + ['http://[::1]/', 'http://[::1]/', MATCHES_WITHOUT_PARAMS], + ['http://[::1]/', new URL('http://[::1]/'), MATCHES_WITHOUT_PARAMS], + ['http://[::1]', new URL('http://[::1]'), MATCHES_WITHOUT_PARAMS], + [ + 'http://[2001:db8::1]/path', + 'http://[2001:db8::1]/path', + MATCHES_WITHOUT_PARAMS, + ], + [ + 'http://[2001:db8::a1]/x', + 'http://[2001:db8::a1]/x', + MATCHES_WITHOUT_PARAMS, + ], + ['http://[fe80::abcd]/', 'http://[fe80::abcd]/', MATCHES_WITHOUT_PARAMS], + [ + 'http://[2001:db8:85a3::8a2e:370:7334]/', + 'http://[2001:db8:85a3::8a2e:370:7334]/', + MATCHES_WITHOUT_PARAMS, + ], + + /* IPv6 with port */ + ['http://[::1]:8080/path', 'http://[::1]:8080/path', MATCHES_WITHOUT_PARAMS], + [ + 'http://[::1]:*/path', + 'http://[::1]:8080/path', + MATCHES_WITH_PARAMS({ '0': '8080' }), + ], + + /* IPv6 zone identifier (URL-encoded `%`) */ + [ + 'http://[fe80::1%25eth0]/', + 'http://[fe80::1%25eth0]/', + MATCHES_WITHOUT_PARAMS, + ], + + /* Path parameters after an IPv6 host */ + [ + 'http://[::1]/user/:id', + new URL('http://[::1]/user/123'), + MATCHES_WITH_PARAMS({ id: '123' }), + ], + [ + 'http://[::1]/:resource/:id', + 'http://[::1]/user/123', + MATCHES_WITH_PARAMS({ resource: 'user', id: '123' }), + ], + [ + 'http://[2001:db8::1]/user/:id', + 'http://[2001:db8::1]/user/456', + MATCHES_WITH_PARAMS({ id: '456' }), + ], + + /* Wildcards alongside an IPv6 host */ + [ + 'http://[::1]/*', + new URL('http://[::1]/foo/bar'), + MATCHES_WITH_PARAMS({ '0': 'foo/bar' }), + ], + ['*://[::1]/path', 'http://[::1]/path', MATCHES_WITH_PARAMS({ '0': 'http' })], + ['http://*/path', 'http://[::1]/path', MATCHES_WITH_PARAMS({ '0': '[::1]' })], + + /* Mismatches */ + ['http://[::1]/path', 'http://[::2]/path', NO_MATCH], + ['http://[::1]/path', 'http://[::1]/other', NO_MATCH], +])('matches %j against %j', (pattern, input, expectedResult) => { + expect(matchPattern(pattern, input)).toEqual(expectedResult) +})