fast-uri 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,52 @@ version: 2
2
2
  updates:
3
3
  - package-ecosystem: "github-actions"
4
4
  directory: "/"
5
+ commit-message:
6
+ # Prefix all commit messages with "chore: "
7
+ prefix: "chore"
5
8
  schedule:
6
- interval: "monthly"
9
+ interval: "weekly"
10
+ cooldown:
11
+ default-days: 7
12
+ allow:
13
+ - dependency-name: "*"
14
+ update-types:
15
+ - "version-update:semver-major"
7
16
  open-pull-requests-limit: 10
8
17
 
9
18
  - package-ecosystem: "npm"
10
19
  directory: "/"
20
+ commit-message:
21
+ # Prefix all commit messages with "chore: "
22
+ prefix: "chore"
11
23
  schedule:
12
- interval: "monthly"
24
+ interval: "weekly"
25
+ cooldown:
26
+ default-days: 7
27
+ versioning-strategy: "increase-if-necessary"
28
+ allow:
29
+ - dependency-name: "*"
30
+ update-types:
31
+ - "version-update:semver-major"
32
+ ignore:
33
+ # TODO: remove ignore until neostandard support ESLint 10
34
+ - dependency-name: "eslint"
35
+ - dependency-name: "neostandard"
36
+ - dependency-name: "@stylistic/*"
13
37
  open-pull-requests-limit: 10
38
+ groups:
39
+ # Production dependencies with breaking changes
40
+ dependencies:
41
+ dependency-type: "production"
42
+ # ESLint related dependencies
43
+ dev-dependencies-eslint:
44
+ patterns:
45
+ - "eslint"
46
+ - "neostandard"
47
+ - "@stylistic/*"
48
+ # TypeScript related dependencies
49
+ dev-dependencies-typescript:
50
+ patterns:
51
+ - "@types/*"
52
+ - "tstyche"
53
+ - "typescript"
@@ -30,7 +30,7 @@ jobs:
30
30
  matrix:
31
31
  node-version: ['20', '22', '24', '26']
32
32
  steps:
33
- - uses: actions/checkout@v6
33
+ - uses: actions/checkout@v7
34
34
  with:
35
35
  persist-credentials: false
36
36
 
@@ -54,7 +54,7 @@ jobs:
54
54
  matrix:
55
55
  node-version: ['16', '18', '20', '22', '24', '26']
56
56
  steps:
57
- - uses: actions/checkout@v6
57
+ - uses: actions/checkout@v7
58
58
  with:
59
59
  persist-credentials: false
60
60
 
@@ -77,7 +77,7 @@ jobs:
77
77
  permissions:
78
78
  contents: read
79
79
  steps:
80
- - uses: actions/checkout@v6
80
+ - uses: actions/checkout@v7
81
81
  with:
82
82
  persist-credentials: false
83
83
 
@@ -119,7 +119,7 @@ jobs:
119
119
  permissions:
120
120
  contents: read
121
121
  steps:
122
- - uses: actions/checkout@v6
122
+ - uses: actions/checkout@v7
123
123
  with:
124
124
  persist-credentials: false
125
125
 
package/README.md CHANGED
@@ -27,10 +27,10 @@ Malformed authorities and out-of-range ports are reported through the parsed com
27
27
  If set to `true`, the serializer will not resolve a relative `path` component.
28
28
 
29
29
  * `unicodeSupport` (boolean, false)
30
- If set to `true`, the parser will unescape non-ASCII characters in the parsed output as per [RFC 3987](http://www.ietf.org/rfc/rfc3987.txt).
30
+ If set to `true`, the parser will unescape non-ASCII characters in the parsed output as per [RFC 3987](https://www.ietf.org/rfc/rfc3987.txt).
31
31
 
32
32
  * `domainHost` (boolean, false)
33
- If set to `true`, the library will treat the `host` component as a domain name, and convert IDNs (International Domain Names) as per [RFC 5891](http://www.ietf.org/rfc/rfc5891.txt).
33
+ If set to `true`, the library will treat the `host` component as a domain name, and convert IDNs (International Domain Names) as per [RFC 5891](https://www.ietf.org/rfc/rfc5891.txt).
34
34
 
35
35
  ### Parse
36
36
 
@@ -90,14 +90,14 @@ true
90
90
 
91
91
  ## Scheme supports
92
92
 
93
- fast-uri supports inserting custom [scheme](http://en.wikipedia.org/wiki/URI_scheme)-dependent processing rules. Currently, fast-uri has built-in support for the following schemes:
93
+ fast-uri supports inserting custom [scheme](https://en.wikipedia.org/wiki/URI_scheme)-dependent processing rules. Currently, fast-uri has built-in support for the following schemes:
94
94
 
95
- * http \[[RFC 2616](http://www.ietf.org/rfc/rfc2616.txt)\]
96
- * https \[[RFC 2818](http://www.ietf.org/rfc/rfc2818.txt)\]
97
- * ws \[[RFC 6455](http://www.ietf.org/rfc/rfc6455.txt)\]
98
- * wss \[[RFC 6455](http://www.ietf.org/rfc/rfc6455.txt)\]
99
- * urn \[[RFC 2141](http://www.ietf.org/rfc/rfc2141.txt)\]
100
- * urn:uuid \[[RFC 4122](http://www.ietf.org/rfc/rfc4122.txt)\]
95
+ * http \[[RFC 2616](https://www.ietf.org/rfc/rfc2616.txt)\]
96
+ * https \[[RFC 2818](https://www.ietf.org/rfc/rfc2818.txt)\]
97
+ * ws \[[RFC 6455](https://www.ietf.org/rfc/rfc6455.txt)\]
98
+ * wss \[[RFC 6455](https://www.ietf.org/rfc/rfc6455.txt)\]
99
+ * urn \[[RFC 2141](https://www.ietf.org/rfc/rfc2141.txt)\]
100
+ * urn:uuid \[[RFC 4122](https://www.ietf.org/rfc/rfc4122.txt)\]
101
101
 
102
102
 
103
103
  ## Benchmarks
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, normalizeQueryFragmentEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
5
5
 
6
6
  /**
@@ -252,7 +252,7 @@ function parseWithStatus (uri, opts) {
252
252
 
253
253
  if (matches) {
254
254
  // store each component
255
- parsed.scheme = matches[1]
255
+ parsed.scheme = matches[1] === undefined ? undefined : matches[1].toLowerCase()
256
256
  parsed.userinfo = matches[3]
257
257
  parsed.host = matches[4]
258
258
  parsed.port = parseInt(matches[5], 10)
@@ -305,7 +305,7 @@ function parseWithStatus (uri, opts) {
305
305
  if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) {
306
306
  // convert Unicode IDN -> ASCII IDN
307
307
  try {
308
- parsed.host = URL.domainToASCII(parsed.host.toLowerCase())
308
+ parsed.host = new URL('http://' + parsed.host).hostname
309
309
  } catch (e) {
310
310
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
311
311
  }
@@ -325,12 +325,11 @@ function parseWithStatus (uri, opts) {
325
325
  if (parsed.path) {
326
326
  parsed.path = normalizePathEncoding(parsed.path)
327
327
  }
328
+ if (parsed.query) {
329
+ parsed.query = normalizeQueryFragmentEncoding(parsed.query)
330
+ }
328
331
  if (parsed.fragment) {
329
- try {
330
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))
331
- } catch {
332
- parsed.error = parsed.error || 'URI malformed'
333
- }
332
+ parsed.fragment = normalizeQueryFragmentEncoding(parsed.fragment)
334
333
  }
335
334
  }
336
335
 
package/lib/utils.js CHANGED
@@ -15,6 +15,9 @@ const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
15
15
  /** @type {(value: string) => boolean} */
16
16
  const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
17
17
 
18
+ /** @type {(value: string) => boolean} */
19
+ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
20
+
18
21
  /**
19
22
  * @param {Array<string>} input
20
23
  * @returns {string}
@@ -218,7 +221,7 @@ function removeDotSegments (path) {
218
221
  continue
219
222
  }
220
223
  } else if (input[0] === '/') {
221
- if (input[1] === '.' || input[1] === '/') {
224
+ if (input[1] === '.') {
222
225
  output.push('/')
223
226
  break
224
227
  }
@@ -435,6 +438,63 @@ function normalizePathEncoding (input) {
435
438
  return output
436
439
  }
437
440
 
441
+ /**
442
+ * Normalizes the percent-encoding of a query or fragment component.
443
+ *
444
+ * Like `normalizePathEncoding`, but uses the query/fragment character set
445
+ * (which additionally allows `?`) and decodes `.` since it has no dot-segment
446
+ * meaning outside of a path.
447
+ *
448
+ * @param {string} input
449
+ * @returns {string}
450
+ */
451
+ function normalizeQueryFragmentEncoding (input) {
452
+ let output = ''
453
+
454
+ for (let i = 0; i < input.length; i++) {
455
+ const ch = input[i]
456
+ if (ch === '%' && i + 2 < input.length) {
457
+ const hex = input.slice(i + 1, i + 3)
458
+ if (isHexPair(hex)) {
459
+ const normalizedHex = hex.toUpperCase()
460
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
461
+
462
+ if (isUnreserved(decoded)) {
463
+ output += decoded
464
+ } else {
465
+ output += '%' + normalizedHex
466
+ }
467
+
468
+ i += 2
469
+ continue
470
+ }
471
+ }
472
+
473
+ if (isQueryFragmentCharacter(ch)) {
474
+ output += ch
475
+ } else {
476
+ const code = input.charCodeAt(i)
477
+ if (code < 0x80) {
478
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
479
+ } else if (code < 0xD800 || code > 0xDFFF) {
480
+ output += percentEncodeNonAscii(code)
481
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
482
+ const low = input.charCodeAt(i + 1)
483
+ if (low >= 0xDC00 && low <= 0xDFFF) {
484
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
485
+ i++
486
+ } else {
487
+ output += percentEncodeNonAscii(0xFFFD)
488
+ }
489
+ } else {
490
+ output += percentEncodeNonAscii(0xFFFD)
491
+ }
492
+ }
493
+ }
494
+
495
+ return output
496
+ }
497
+
438
498
  /**
439
499
  * Escapes a component while preserving existing valid percent escapes.
440
500
  *
@@ -515,6 +575,7 @@ module.exports = {
515
575
  reescapeHostDelimiters,
516
576
  normalizePercentEncoding,
517
577
  normalizePathEncoding,
578
+ normalizeQueryFragmentEncoding,
518
579
  escapePreservingEscapes,
519
580
  removeDotSegments,
520
581
  isIPv4,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fast-uri",
3
3
  "description": "Dependency-free RFC 3986 URI toolbox",
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -150,10 +150,12 @@ test('URI parse', (t) => {
150
150
  t.equal(components.query, undefined, 'query')
151
151
  t.equal(components.fragment, '%0D', 'fragment')
152
152
 
153
- // malformed percent-encoded fragment must not throw
153
+ // a fragment whose decoded bytes are not valid UTF-8 is still valid
154
+ // percent-encoding (RFC 3986 §2.1 is byte-level), so it is preserved as-is
155
+ // rather than being flagged as malformed
154
156
  components = fastURI.parse('http://example.com/#%E0%A4A')
155
- t.equal(components.error, 'URI malformed', 'malformed fragment errors')
156
- t.equal(components.fragment, '%E0%A4A', 'malformed fragment is preserved')
157
+ t.equal(components.error, undefined, 'valid percent-encoding is not flagged as malformed')
158
+ t.equal(components.fragment, '%E0%A4A', 'fragment is preserved')
157
159
 
158
160
  // all
159
161
  components = fastURI.parse('uri://user:pass@example.com:123/one/two.three?q1=a1&q2=a2#body')
@@ -0,0 +1,33 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ test('query percent-encoding is normalized (RFC 3986 §6.2.2)', (t) => {
7
+ // hex digits are uppercased, like the path component already does
8
+ t.equal(fastURI.normalize('x://h/p?a=%2a'), 'x://h/p?a=%2A', 'uppercases hex in query')
9
+ // unreserved characters are decoded
10
+ t.equal(fastURI.normalize('x://h/p?a=%7e%2e'), 'x://h/p?a=~.', 'decodes unreserved in query')
11
+ // reserved characters stay percent-encoded
12
+ t.equal(fastURI.normalize('x://h/p?a=%2F'), 'x://h/p?a=%2F', 'keeps reserved encoded in query')
13
+ // raw characters that are not allowed are percent-encoded
14
+ t.equal(fastURI.normalize('x://h/p?a b'), 'x://h/p?a%20b', 'encodes a raw space in query')
15
+ t.equal(fastURI.normalize('x://h/p?café'), 'x://h/p?caf%C3%A9', 'encodes raw non-ASCII in query')
16
+ // `?` is allowed unencoded in a query
17
+ t.equal(fastURI.normalize('x://h/p?a?b'), 'x://h/p?a?b', 'keeps `?` raw in query')
18
+
19
+ t.end()
20
+ })
21
+
22
+ test('fragment percent-encoding is normalized without decoding reserved characters', (t) => {
23
+ // reserved characters must NOT be decoded — `%2F` is not `/`
24
+ t.equal(fastURI.normalize('x://h/p#a%2Fb%2A'), 'x://h/p#a%2Fb%2A', 'keeps reserved encoded in fragment')
25
+ // hex is uppercased and unreserved is decoded
26
+ t.equal(fastURI.normalize('x://h/p#a%2a%7e'), 'x://h/p#a%2A~', 'uppercases hex and decodes unreserved in fragment')
27
+ // raw characters are still encoded
28
+ t.equal(fastURI.normalize('x://h/p#a b'), 'x://h/p#a%20b', 'encodes a raw space in fragment')
29
+ // `?` and `/` are allowed unencoded in a fragment
30
+ t.equal(fastURI.normalize('x://h/p#f?x/y'), 'x://h/p#f?x/y', 'keeps `?` and `/` raw in fragment')
31
+
32
+ t.end()
33
+ })
@@ -0,0 +1,16 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ test('scheme is normalized to lowercase (RFC 3986 §6.2.2.1)', (t) => {
7
+ // parse lowercases the scheme, consistent with how it already lowercases the host
8
+ t.equal(fastURI.parse('HTTP://EXAMPLE.com/Path').scheme, 'http', 'parse lowercases an uppercase scheme')
9
+ t.equal(fastURI.parse('HtTpS://example.com').scheme, 'https', 'parse lowercases a mixed-case scheme')
10
+
11
+ // normalize emits a lowercase scheme (matches the already-lowercased host)
12
+ t.equal(fastURI.normalize('HTTP://EXAMPLE.com/p'), 'http://example.com/p', 'normalize lowercases the scheme')
13
+ t.equal(fastURI.normalize('HtTp://Example.COM/'), 'http://example.com/', 'normalize lowercases a mixed-case scheme')
14
+
15
+ t.end()
16
+ })
@@ -131,3 +131,31 @@ test('normalize does not double-decode %2540 into a live @', (t) => {
131
131
  t.plan(1)
132
132
  t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/')
133
133
  })
134
+
135
+ test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => {
136
+ const cases = [
137
+ {
138
+ input: 'http://127。0。0。1/',
139
+ expectedHost: '127.0.0.1',
140
+ description: 'full-width ideographic stops as octet separators'
141
+ },
142
+ {
143
+ input: 'http://example.com/',
144
+ expectedHost: 'example.com',
145
+ description: 'fullwidth e as first letter'
146
+ },
147
+ {
148
+ input: 'http://納豆.example.org/',
149
+ expectedHost: 'xn--99zt52a.example.org',
150
+ description: 'CJK label requiring punycode'
151
+ }
152
+ ]
153
+
154
+ t.plan(cases.length * 2)
155
+
156
+ cases.forEach(({ input, expectedHost, description }) => {
157
+ const parsed = fastURI.parse(input)
158
+ t.notOk(parsed.error, `parse should not set error: ${description}`)
159
+ t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`)
160
+ })
161
+ })
@@ -8,7 +8,7 @@ const fastURI = require('..')
8
8
  *
9
9
  * @fileoverview An RFC 3986 compliant, scheme extendable URI parsing/normalizing/resolving/serializing library for JavaScript.
10
10
  * @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
11
- * @see http://github.com/garycourt/uri-js
11
+ * @see https://github.com/garycourt/uri-js
12
12
  */
13
13
 
14
14
  /**
package/test/util.test.js CHANGED
@@ -29,6 +29,15 @@ test('removeDotSegments', (t) => {
29
29
  // https://github.com/fastify/fast-uri/issues/139
30
30
  testCases.push(['WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a',
31
31
  'WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a'])
32
+ // trailing empty segments must be preserved: RFC 3986 5.2.4 only removes
33
+ // complete "." / ".." segments, never an empty segment
34
+ testCases.push(['//', '//'])
35
+ testCases.push(['/foo//', '/foo//'])
36
+ testCases.push(['/a/b//', '/a/b//'])
37
+ testCases.push(['/x//y//z', '/x//y//z'])
38
+ // "." / ".." segments still collapse
39
+ testCases.push(['/a/b/c/./../../g', '/a/g'])
40
+ testCases.push(['/.', '/'])
32
41
 
33
42
  t.plan(testCases.length)
34
43