fast-uri 3.1.1 → 4.0.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.
@@ -23,6 +23,54 @@ permissions:
23
23
  contents: read
24
24
 
25
25
  jobs:
26
+ type-tests:
27
+ name: Type Tests
28
+ runs-on: ubuntu-latest
29
+ strategy:
30
+ matrix:
31
+ node-version: ['20', '22', '24', '26']
32
+ steps:
33
+ - uses: actions/checkout@v6
34
+ with:
35
+ persist-credentials: false
36
+
37
+ - uses: actions/setup-node@v6
38
+ with:
39
+ node-version: ${{ matrix.node-version }}
40
+ cache: 'npm'
41
+ cache-dependency-path: package.json
42
+ check-latest: true
43
+
44
+ - name: Install dependencies
45
+ run: npm install --ignore-scripts
46
+
47
+ - name: Run type tests
48
+ run: npm run test:typescript
49
+
50
+ unit-tests:
51
+ name: Unit Tests
52
+ runs-on: ubuntu-latest
53
+ strategy:
54
+ matrix:
55
+ node-version: ['16', '18', '20', '22', '24', '26']
56
+ steps:
57
+ - uses: actions/checkout@v6
58
+ with:
59
+ persist-credentials: false
60
+
61
+ - uses: actions/setup-node@v6
62
+ with:
63
+ node-version: ${{ matrix.node-version }}
64
+ cache: 'npm'
65
+ cache-dependency-path: package.json
66
+ check-latest: true
67
+
68
+ - name: Install dependencies
69
+ run: npm install --ignore-scripts
70
+
71
+ - name: Run unit tests
72
+ run: npm test
73
+
26
74
  test-regression-check-node10:
27
75
  name: Test compatibility with Node.js 10
28
76
  runs-on: ubuntu-latest
@@ -103,4 +151,4 @@ jobs:
103
151
  with:
104
152
  license-check: true
105
153
  lint: true
106
- node-versions: '["16", "18", "20", "22", "24"]'
154
+ node-versions: '["16", "18", "20", "22", "24", "26"]'
package/README.md CHANGED
@@ -12,6 +12,8 @@ Dependency-free RFC 3986 URI toolbox.
12
12
 
13
13
  All of the above functions can accept an additional options argument that is an object that can contain one or more of the following properties:
14
14
 
15
+ Malformed authorities and out-of-range ports are reported through the parsed component's `error` field. `normalize()` leaves malformed string inputs unchanged, and `equal()` returns `false` when either string input is malformed.
16
+
15
17
  * `scheme` (string)
16
18
  Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
17
19
 
@@ -0,0 +1,54 @@
1
+ import { Bench } from 'tinybench'
2
+ import { fastUri } from '../index.js'
3
+
4
+ const { serialize, normalize, parse } = fastUri
5
+
6
+ const asciiPath = '/users/profile/settings/account'
7
+ const asciiPathWithSpaces = '/users/john doe/profile page'
8
+ const cjkPath = '/ユーザー/プロフィール/設定'
9
+ const mixedPath = '/users/日本/profile-📱-emoji/設定'
10
+ const emojiPath = '/🚀/🌍/🎉/end'
11
+ const longAsciiPath = '/' + 'a/b/c/d/e/f/g/h/'.repeat(20)
12
+ const longUtf8Path = '/' + '日本/中文/한국/'.repeat(20)
13
+
14
+ const bench = new Bench({ name: 'encoder bench', time: 1000 })
15
+
16
+ bench.add('serialize ascii path', () => {
17
+ serialize({ scheme: 'http', host: 'example.com', path: asciiPath })
18
+ })
19
+
20
+ bench.add('serialize ascii path with spaces', () => {
21
+ serialize({ scheme: 'http', host: 'example.com', path: asciiPathWithSpaces })
22
+ })
23
+
24
+ bench.add('serialize cjk path', () => {
25
+ serialize({ scheme: 'http', host: 'example.com', path: cjkPath })
26
+ })
27
+
28
+ bench.add('serialize mixed path', () => {
29
+ serialize({ scheme: 'http', host: 'example.com', path: mixedPath })
30
+ })
31
+
32
+ bench.add('serialize emoji path', () => {
33
+ serialize({ scheme: 'http', host: 'example.com', path: emojiPath })
34
+ })
35
+
36
+ bench.add('serialize long ascii path', () => {
37
+ serialize({ scheme: 'http', host: 'example.com', path: longAsciiPath })
38
+ })
39
+
40
+ bench.add('serialize long utf8 path', () => {
41
+ serialize({ scheme: 'http', host: 'example.com', path: longUtf8Path })
42
+ })
43
+
44
+ bench.add('normalize utf8 uri', () => {
45
+ normalize('http://example.com/日本/profile')
46
+ })
47
+
48
+ bench.add('parse utf8 uri', () => {
49
+ parse('http://example.com/日本/profile')
50
+ })
51
+
52
+ await bench.run()
53
+ console.log(bench.name)
54
+ console.table(bench.table())
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
5
5
 
6
6
  /**
@@ -11,7 +11,7 @@ const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
11
11
  */
12
12
  function normalize (uri, options) {
13
13
  if (typeof uri === 'string') {
14
- uri = /** @type {T} */ (serialize(parse(uri, options), options))
14
+ uri = /** @type {T} */ (normalizeString(uri, options))
15
15
  } else if (typeof uri === 'object') {
16
16
  uri = /** @type {T} */ (parse(serialize(uri, options), options))
17
17
  }
@@ -106,19 +106,10 @@ function resolveComponent (base, relative, options, skipNormalization) {
106
106
  * @returns {boolean}
107
107
  */
108
108
  function equal (uriA, uriB, options) {
109
- if (typeof uriA === 'string') {
110
- uriA = serialize(parse(uriA, options), options)
111
- } else if (typeof uriA === 'object') {
112
- uriA = serialize(uriA, options)
113
- }
114
-
115
- if (typeof uriB === 'string') {
116
- uriB = serialize(parse(uriB, options), options)
117
- } else if (typeof uriB === 'object') {
118
- uriB = serialize(uriB, options)
119
- }
109
+ const normalizedA = normalizeComparableURI(uriA, options)
110
+ const normalizedB = normalizeComparableURI(uriB, options)
120
111
 
121
- return uriA.toLowerCase() === uriB.toLowerCase()
112
+ return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase()
122
113
  }
123
114
 
124
115
  /**
@@ -211,12 +202,29 @@ function serialize (cmpts, opts) {
211
202
 
212
203
  const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u
213
204
 
205
+ /**
206
+ * @param {import('./types/index').URIComponent} parsed
207
+ * @param {RegExpMatchArray} matches
208
+ * @returns {string|undefined}
209
+ */
210
+ function getParseError (parsed, matches) {
211
+ if (matches[2] !== undefined && parsed.path && parsed.path[0] !== '/') {
212
+ return 'URI path must start with "/" when authority is present.'
213
+ }
214
+
215
+ if (typeof parsed.port === 'number' && (parsed.port < 0 || parsed.port > 65535)) {
216
+ return 'URI port is malformed.'
217
+ }
218
+
219
+ return undefined
220
+ }
221
+
214
222
  /**
215
223
  * @param {string} uri
216
224
  * @param {import('./types/index').Options} [opts]
217
- * @returns
225
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }}
218
226
  */
219
- function parse (uri, opts) {
227
+ function parseWithStatus (uri, opts) {
220
228
  const options = Object.assign({}, opts)
221
229
  /** @type {import('./types/index').URIComponent} */
222
230
  const parsed = {
@@ -229,6 +237,8 @@ function parse (uri, opts) {
229
237
  fragment: undefined
230
238
  }
231
239
 
240
+ let malformedAuthorityOrPort = false
241
+
232
242
  let isIP = false
233
243
  if (options.reference === 'suffix') {
234
244
  if (options.scheme) {
@@ -254,6 +264,13 @@ function parse (uri, opts) {
254
264
  if (isNaN(parsed.port)) {
255
265
  parsed.port = matches[5]
256
266
  }
267
+
268
+ const parseError = getParseError(parsed, matches)
269
+ if (parseError !== undefined) {
270
+ parsed.error = parsed.error || parseError
271
+ malformedAuthorityOrPort = true
272
+ }
273
+
257
274
  if (parsed.host) {
258
275
  const ipv4result = isIPv4(parsed.host)
259
276
  if (ipv4result === false) {
@@ -302,14 +319,18 @@ function parse (uri, opts) {
302
319
  parsed.scheme = unescape(parsed.scheme)
303
320
  }
304
321
  if (parsed.host !== undefined) {
305
- parsed.host = unescape(parsed.host)
322
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP)
306
323
  }
307
324
  }
308
325
  if (parsed.path) {
309
326
  parsed.path = normalizePathEncoding(parsed.path)
310
327
  }
311
328
  if (parsed.fragment) {
312
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))
329
+ try {
330
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))
331
+ } catch {
332
+ parsed.error = parsed.error || 'URI malformed'
333
+ }
313
334
  }
314
335
  }
315
336
 
@@ -320,7 +341,54 @@ function parse (uri, opts) {
320
341
  } else {
321
342
  parsed.error = parsed.error || 'URI can not be parsed.'
322
343
  }
323
- return parsed
344
+ return { parsed, malformedAuthorityOrPort }
345
+ }
346
+
347
+ /**
348
+ * @param {string} uri
349
+ * @param {import('./types/index').Options} [opts]
350
+ * @returns
351
+ */
352
+ function parse (uri, opts) {
353
+ return parseWithStatus(uri, opts).parsed
354
+ }
355
+
356
+ /**
357
+ * @param {string} uri
358
+ * @param {import('./types/index').Options} [opts]
359
+ * @returns {string}
360
+ */
361
+ function normalizeString (uri, opts) {
362
+ return normalizeStringWithStatus(uri, opts).normalized
363
+ }
364
+
365
+ /**
366
+ * @param {string} uri
367
+ * @param {import('./types/index').Options} [opts]
368
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
369
+ */
370
+ function normalizeStringWithStatus (uri, opts) {
371
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
372
+ return {
373
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
374
+ malformedAuthorityOrPort
375
+ }
376
+ }
377
+
378
+ /**
379
+ * @param {import ('./types/index').URIComponent|string} uri
380
+ * @param {import('./types/index').Options} [opts]
381
+ * @returns {string|undefined}
382
+ */
383
+ function normalizeComparableURI (uri, opts) {
384
+ if (typeof uri === 'string') {
385
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
386
+ return malformedAuthorityOrPort ? undefined : normalized
387
+ }
388
+
389
+ if (typeof uri === 'object') {
390
+ return serialize(uri, opts)
391
+ }
324
392
  }
325
393
 
326
394
  const fastUri = {
package/lib/utils.js CHANGED
@@ -272,6 +272,26 @@ function removeDotSegments (path) {
272
272
  return output.join('')
273
273
  }
274
274
 
275
+ /**
276
+ * Re-escape RFC 3986 gen-delims that must not appear literally in the host.
277
+ * After the URI regex parses, these characters cannot be literal in the host
278
+ * field, so any that appear after decoding came from percent-encoding and
279
+ * must be restored to prevent authority structure changes.
280
+ *
281
+ * @param {string} host
282
+ * @param {boolean} isIP - true for IPv4/IPv6 hosts (skip colon re-escaping)
283
+ * @returns {string}
284
+ */
285
+ const HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23', ':': '%3A' }
286
+ const HOST_DELIM_RE = /[@/?#:]/g
287
+ const HOST_DELIM_NO_COLON_RE = /[@/?#]/g
288
+
289
+ function reescapeHostDelimiters (host, isIP) {
290
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE
291
+ re.lastIndex = 0
292
+ return host.replace(re, (ch) => HOST_DELIMS[ch])
293
+ }
294
+
275
295
  /**
276
296
  * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
277
297
  * Reserved delimiters such as `%2F` and `%2E` stay escaped.
@@ -311,6 +331,55 @@ function normalizePercentEncoding (input, decodeUnreserved = false) {
311
331
  return output
312
332
  }
313
333
 
334
+ /** Pre-built `%XX` strings for every byte. Avoids per-call hex math/concat. */
335
+ const BYTE_HEX = new Array(256)
336
+ {
337
+ const HEX_DIGITS = '0123456789ABCDEF'
338
+ for (let i = 0; i < 256; i++) {
339
+ BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Mirrors the pass-through set of the deprecated `escape()` global:
345
+ * `A-Z a-z 0-9 * + - . / @ _`. Keeps drop-in semantics for ASCII input.
346
+ *
347
+ * @param {number} cp
348
+ * @returns {boolean}
349
+ */
350
+ function isEscapeSafe (cp) {
351
+ return (
352
+ (cp >= 0x30 && cp <= 0x39) ||
353
+ (cp >= 0x41 && cp <= 0x5A) ||
354
+ (cp >= 0x61 && cp <= 0x7A) ||
355
+ cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
356
+ cp === 0x2F || cp === 0x40 || cp === 0x5F
357
+ )
358
+ }
359
+
360
+ /**
361
+ * RFC 3986 percent-encode a non-ASCII Unicode code point as UTF-8 bytes.
362
+ * Caller handles the ASCII branch inline for speed.
363
+ *
364
+ * @param {number} cp - Unicode code point (>= 0x80, <= 0x10FFFF)
365
+ * @returns {string}
366
+ */
367
+ function percentEncodeNonAscii (cp) {
368
+ if (cp < 0x800) {
369
+ return BYTE_HEX[0xC0 | (cp >> 6)] +
370
+ BYTE_HEX[0x80 | (cp & 0x3F)]
371
+ }
372
+ if (cp < 0x10000) {
373
+ return BYTE_HEX[0xE0 | (cp >> 12)] +
374
+ BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
375
+ BYTE_HEX[0x80 | (cp & 0x3F)]
376
+ }
377
+ return BYTE_HEX[0xF0 | (cp >> 18)] +
378
+ BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
379
+ BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
380
+ BYTE_HEX[0x80 | (cp & 0x3F)]
381
+ }
382
+
314
383
  /**
315
384
  * Normalizes path data without turning reserved escapes into live path syntax.
316
385
  * Valid escapes are uppercased, raw unsafe characters are escaped, and only
@@ -323,7 +392,8 @@ function normalizePathEncoding (input) {
323
392
  let output = ''
324
393
 
325
394
  for (let i = 0; i < input.length; i++) {
326
- if (input[i] === '%' && i + 2 < input.length) {
395
+ const ch = input[i]
396
+ if (ch === '%' && i + 2 < input.length) {
327
397
  const hex = input.slice(i + 1, i + 3)
328
398
  if (isHexPair(hex)) {
329
399
  const normalizedHex = hex.toUpperCase()
@@ -340,10 +410,25 @@ function normalizePathEncoding (input) {
340
410
  }
341
411
  }
342
412
 
343
- if (isPathCharacter(input[i])) {
344
- output += input[i]
413
+ if (isPathCharacter(ch)) {
414
+ output += ch
345
415
  } else {
346
- output += escape(input[i])
416
+ const code = input.charCodeAt(i)
417
+ if (code < 0x80) {
418
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
419
+ } else if (code < 0xD800 || code > 0xDFFF) {
420
+ output += percentEncodeNonAscii(code)
421
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
422
+ const low = input.charCodeAt(i + 1)
423
+ if (low >= 0xDC00 && low <= 0xDFFF) {
424
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
425
+ i++
426
+ } else {
427
+ output += percentEncodeNonAscii(0xFFFD)
428
+ }
429
+ } else {
430
+ output += percentEncodeNonAscii(0xFFFD)
431
+ }
347
432
  }
348
433
  }
349
434
 
@@ -360,7 +445,8 @@ function escapePreservingEscapes (input) {
360
445
  let output = ''
361
446
 
362
447
  for (let i = 0; i < input.length; i++) {
363
- if (input[i] === '%' && i + 2 < input.length) {
448
+ const ch = input[i]
449
+ if (ch === '%' && i + 2 < input.length) {
364
450
  const hex = input.slice(i + 1, i + 3)
365
451
  if (isHexPair(hex)) {
366
452
  output += '%' + hex.toUpperCase()
@@ -369,7 +455,22 @@ function escapePreservingEscapes (input) {
369
455
  }
370
456
  }
371
457
 
372
- output += escape(input[i])
458
+ const code = input.charCodeAt(i)
459
+ if (code < 0x80) {
460
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
461
+ } else if (code < 0xD800 || code > 0xDFFF) {
462
+ output += percentEncodeNonAscii(code)
463
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
464
+ const low = input.charCodeAt(i + 1)
465
+ if (low >= 0xDC00 && low <= 0xDFFF) {
466
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
467
+ i++
468
+ } else {
469
+ output += percentEncodeNonAscii(0xFFFD)
470
+ }
471
+ } else {
472
+ output += percentEncodeNonAscii(0xFFFD)
473
+ }
373
474
  }
374
475
 
375
476
  return output
@@ -394,7 +495,7 @@ function recomposeAuthority (component) {
394
495
  if (ipV6res.isIPV6 === true) {
395
496
  host = `[${ipV6res.escapedHost}]`
396
497
  } else {
397
- host = component.host
498
+ host = reescapeHostDelimiters(host, false)
398
499
  }
399
500
  }
400
501
  uriTokens.push(host)
@@ -411,6 +512,7 @@ function recomposeAuthority (component) {
411
512
  module.exports = {
412
513
  nonSimpleDomain,
413
514
  recomposeAuthority,
515
+ reescapeHostDelimiters,
414
516
  normalizePercentEncoding,
415
517
  normalizePathEncoding,
416
518
  escapePreservingEscapes,
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": "3.1.1",
4
+ "version": "4.0.0",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -48,14 +48,14 @@
48
48
  "scripts": {
49
49
  "lint": "eslint",
50
50
  "lint:fix": "eslint --fix",
51
- "test": "npm run test:unit && npm run test:typescript",
51
+ "test": "npm run test:unit",
52
52
  "test:browser:chromium": "playwright-test ./test/* --runner tape --browser=chromium",
53
53
  "test:browser:firefox": "playwright-test ./test/* --runner tape --browser=firefox",
54
54
  "test:browser:webkit": "playwright-test ./test/* --runner tape --browser=webkit",
55
55
  "test:browser": "npm run test:browser:chromium && npm run test:browser:firefox && npm run test:browser:webkit",
56
56
  "test:unit": "tape test/**/*.js",
57
57
  "test:unit:dev": "npm run test:unit -- --coverage-report=html",
58
- "test:typescript": "tsd"
58
+ "test:typescript": "tstyche"
59
59
  },
60
60
  "devDependencies": {
61
61
  "ajv": "^8.16.0",
@@ -63,6 +63,6 @@
63
63
  "neostandard": "^0.13.0",
64
64
  "playwright-test": "^14.1.12",
65
65
  "tape": "^5.8.1",
66
- "tsd": "^0.33.0"
66
+ "tstyche": "^7.0.0"
67
67
  }
68
68
  }
@@ -106,3 +106,63 @@ test('WSS Equal', (t) => {
106
106
  runTest(t, suite)
107
107
  t.end()
108
108
  })
109
+
110
+ test('URI Equals tolerates malformed fragments', (t) => {
111
+ t.equal(
112
+ fastURI.equal('http://example.com/#%E0%A4A', 'http://example.com/#%E0%A4A'),
113
+ true,
114
+ 'malformed fragment does not throw during equality checks'
115
+ )
116
+ t.end()
117
+ })
118
+
119
+ test('URI Equals — raw UTF-8 char equals its percent-encoded UTF-8 form', (t) => {
120
+ const suite = [
121
+ // BMP characters
122
+ { pair: ['http://example.com/日本', 'http://example.com/%E6%97%A5%E6%9C%AC'], result: true },
123
+ { pair: ['http://example.com/café', 'http://example.com/caf%C3%A9'], result: true },
124
+ { pair: ['http://example.com/€', 'http://example.com/%E2%82%AC'], result: true },
125
+ { pair: ['http://example.com/中文', 'http://example.com/%E4%B8%AD%E6%96%87'], result: true },
126
+ // supplementary plane (emoji, surrogate pairs)
127
+ { pair: ['http://example.com/🚀', 'http://example.com/%F0%9F%9A%80'], result: true },
128
+ { pair: ['http://example.com/😀', 'http://example.com/%F0%9F%98%80'], result: true },
129
+ // mixed encoded + raw in same path
130
+ { pair: ['http://example.com/users/日本/profile', 'http://example.com/users/%E6%97%A5%E6%9C%AC/profile'], result: true },
131
+ { pair: ['http://example.com/a/€/b', 'http://example.com/a/%E2%82%AC/b'], result: true }
132
+ ]
133
+ runTest(t, suite)
134
+ t.end()
135
+ })
136
+
137
+ test('URI Equals — percent-encoded UTF-8 is case-insensitive in hex digits', (t) => {
138
+ const suite = [
139
+ { pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%c3%a9'], result: true },
140
+ { pair: ['http://example.com/%E6%97%A5', 'http://example.com/%e6%97%a5'], result: true },
141
+ { pair: ['http://example.com/%F0%9F%9A%80', 'http://example.com/%f0%9f%9a%80'], result: true }
142
+ ]
143
+ runTest(t, suite)
144
+ t.end()
145
+ })
146
+
147
+ test('URI Equals — different Unicode code points are not equal', (t) => {
148
+ const suite = [
149
+ { pair: ['http://example.com/日本', 'http://example.com/中文'], result: false },
150
+ { pair: ['http://example.com/café', 'http://example.com/cafe'], result: false },
151
+ { pair: ['http://example.com/🚀', 'http://example.com/🎉'], result: false }
152
+ ]
153
+ runTest(t, suite)
154
+ t.end()
155
+ })
156
+
157
+ test('URI Equals — Latin-1 byte is not equal to UTF-8 multi-byte encoding', (t) => {
158
+ // Parser-differential guard: %C3%A9 (UTF-8 for "é") must NOT equal %E9
159
+ // (raw Latin-1 byte). The old escape() emitted single Latin-1 bytes for
160
+ // non-ASCII, which would have made these incorrectly compare equal in a
161
+ // round-tripped URI.
162
+ const suite = [
163
+ { pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%E9'], result: false },
164
+ { pair: ['http://example.com/%E6%97%A5', 'http://example.com/%E6'], result: false }
165
+ ]
166
+ runTest(t, suite)
167
+ t.end()
168
+ })
@@ -150,6 +150,11 @@ 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
154
+ 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
+
153
158
  // all
154
159
  components = fastURI.parse('uri://user:pass@example.com:123/one/two.three?q1=a1&q2=a2#body')
155
160
  t.equal(components.error, undefined, 'all errors')
@@ -76,3 +76,12 @@ test('URN Resolving', (t) => {
76
76
  t.equal(fastURI.resolve('urn:some:other:prop', 'urn:some:ip:prop'), 'urn:some:ip:prop', 'urn:some:ip:prop')
77
77
  t.end()
78
78
  })
79
+
80
+ test('URI Resolving tolerates malformed fragments', (t) => {
81
+ t.equal(
82
+ fastURI.resolve('http://base.com/', 'http://example.com/#%E0%A4A'),
83
+ 'http://example.com/#%E0%A4A',
84
+ 'malformed fragment does not throw during resolve'
85
+ )
86
+ t.end()
87
+ })
@@ -0,0 +1,133 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ test('parse marks malformed authority and port inputs as errors', (t) => {
7
+ const malformedCases = [
8
+ {
9
+ input: 'http://[::1]foo',
10
+ expectedError: 'URI path must start with "/" when authority is present.'
11
+ },
12
+ {
13
+ input: 'http://[::1]:80abc/path',
14
+ expectedError: 'URI path must start with "/" when authority is present.'
15
+ },
16
+ {
17
+ input: 'http://example.com:80abc/path',
18
+ expectedError: 'URI path must start with "/" when authority is present.'
19
+ },
20
+ {
21
+ input: 'http://[::1]:65536',
22
+ expectedError: 'URI port is malformed.'
23
+ }
24
+ ]
25
+
26
+ t.plan(malformedCases.length)
27
+
28
+ malformedCases.forEach(({ input, expectedError }) => {
29
+ t.equal(fastURI.parse(input).error, expectedError, input)
30
+ })
31
+ })
32
+
33
+ test('normalize does not canonicalize malformed URLs into different valid URLs', (t) => {
34
+ const malformedCases = [
35
+ 'http://[::1]foo',
36
+ 'http://[::1]:80abc/path',
37
+ 'http://example.com:80abc/path',
38
+ 'http://[::1]:65536'
39
+ ]
40
+
41
+ t.plan(malformedCases.length)
42
+
43
+ malformedCases.forEach((input) => {
44
+ t.equal(fastURI.normalize(input), input, input)
45
+ })
46
+ })
47
+
48
+ test('equal returns false when either side is malformed', (t) => {
49
+ const malformedPairs = [
50
+ ['http://[::1]foo', 'http://[::1]/foo'],
51
+ ['http://[::1]:80abc/path', 'http://[::1]/abc/path'],
52
+ ['http://example.com:80abc/path', 'http://example.com/abc/path'],
53
+ ['http://[::1]:65536', 'http://[::1]:65536/']
54
+ ]
55
+
56
+ t.plan(malformedPairs.length)
57
+
58
+ malformedPairs.forEach(([left, right]) => {
59
+ t.equal(fastURI.equal(left, right), false, `${left} != ${right}`)
60
+ })
61
+ })
62
+
63
+ test('normalize preserves encoded authority delimiters in host', (t) => {
64
+ const cases = [
65
+ ['http://trusted.com%40evil.com/', 'http://trusted.com%40evil.com/'],
66
+ ['http://example.com%3A8080/', 'http://example.com%3A8080/'],
67
+ ['http://example.com%2Fevil.com/path', 'http://example.com%2Fevil.com/path'],
68
+ ['http://example.com%23fragment/path', 'http://example.com%23fragment/path'],
69
+ ['http://example.com%3Fq=evil/path', 'http://example.com%3Fq=evil/path'],
70
+ ['http://user%3Apass%40evil.com/', 'http://user%3Apass%40evil.com/'],
71
+ ['http://user@trusted.com%40evil.com/', 'http://user@trusted.com%40evil.com/'],
72
+ ['https://trusted.com%40evil.com/', 'https://trusted.com%40evil.com/'],
73
+ ['ws://trusted.com%40evil.com/chat', 'ws://trusted.com%40evil.com/chat'],
74
+ ['wss://trusted.com%40evil.com/chat', 'wss://trusted.com%40evil.com/chat']
75
+ ]
76
+
77
+ t.plan(cases.length)
78
+
79
+ cases.forEach(([input, expected]) => {
80
+ t.equal(fastURI.normalize(input), expected, input)
81
+ })
82
+ })
83
+
84
+ test('parse preserves encoded authority delimiters in host', (t) => {
85
+ const cases = [
86
+ ['http://trusted.com%40evil.com/', 'trusted.com%40evil.com'],
87
+ ['http://example.com%3A8080/', 'example.com%3A8080'],
88
+ ['http://user%3Apass%40evil.com/', 'user%3Apass%40evil.com']
89
+ ]
90
+
91
+ t.plan(cases.length)
92
+
93
+ cases.forEach(([input, expectedHost]) => {
94
+ t.equal(fastURI.parse(input).host, expectedHost, input)
95
+ })
96
+ })
97
+
98
+ test('equal returns false when encoded delimiters differ from live delimiters', (t) => {
99
+ const pairs = [
100
+ ['http://trusted.com%40evil.com/', 'http://trusted.com@evil.com/'],
101
+ ['http://example.com%3A8080/', 'http://example.com:8080/']
102
+ ]
103
+
104
+ t.plan(pairs.length)
105
+
106
+ pairs.forEach(([left, right]) => {
107
+ t.equal(fastURI.equal(left, right, {}), false, `${left} != ${right}`)
108
+ })
109
+ })
110
+
111
+ test('resolve preserves encoded authority delimiters', (t) => {
112
+ const result = fastURI.resolve('http://base.com/', '//trusted.com%40evil.com/path')
113
+ const parsed = fastURI.parse(result)
114
+
115
+ t.plan(1)
116
+ t.notEqual(parsed.host, 'evil.com', '//trusted.com%40evil.com/path')
117
+ })
118
+
119
+ test('serialize escapes authority delimiters in host field', (t) => {
120
+ const result = fastURI.serialize({ scheme: 'http', host: 'trusted.com@evil.com', path: '/' })
121
+ const parsed = fastURI.parse(result)
122
+
123
+ t.plan(1)
124
+ t.notEqual(parsed.host, 'evil.com', 'host: trusted.com@evil.com')
125
+ })
126
+
127
+ test('normalize does not double-decode %2540 into a live @', (t) => {
128
+ const result = fastURI.normalize('http://trusted.com%2540evil.com/')
129
+ const parsed = fastURI.parse(result)
130
+
131
+ t.plan(1)
132
+ t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/')
133
+ })
package/tsconfig.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
- "compilerOptions": {
3
- "allowJs": true,
4
- "checkJs": true,
5
- "strict": true,
6
- "noImplicitAny": true,
7
- "target": "es2015"
8
- }
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": true,
5
+ "strict": true,
6
+ "noImplicitAny": true,
7
+ "target": "es2022",
8
+ "noEmit": true
9
+ }
9
10
  }
package/types/index.d.ts CHANGED
@@ -28,15 +28,6 @@ declare namespace fastUri {
28
28
  nid?: string;
29
29
  }
30
30
 
31
- /**
32
- * @deprecated Use Options instead
33
- */
34
- export type options = Options
35
- /**
36
- * @deprecated Use URIComponent instead
37
- */
38
- export type URIComponents = URIComponent
39
-
40
31
  export function normalize (uri: string, opts?: Options): string
41
32
  export function normalize (uri: URIComponent, opts?: Options): URIComponent
42
33
  export function normalize (uri: any, opts?: Options): any
@@ -0,0 +1,15 @@
1
+ import uri, {
2
+ type URIComponent,
3
+ } from '..'
4
+ import { expect } from 'tstyche'
5
+
6
+ const parsed = uri.parse('foo')
7
+ expect(parsed).type.toBe<URIComponent>()
8
+
9
+ const parsed2 = uri.parse('foo', {
10
+ domainHost: true,
11
+ scheme: 'https',
12
+ unicodeSupport: false
13
+ })
14
+
15
+ expect(parsed2).type.toBe<URIComponent>()
@@ -1,24 +0,0 @@
1
- name: package-manager-ci
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- - next
8
- - 'v*'
9
- paths-ignore:
10
- - 'docs/**'
11
- - '*.md'
12
- pull_request:
13
- paths-ignore:
14
- - 'docs/**'
15
- - '*.md'
16
-
17
- permissions:
18
- contents: read
19
-
20
- jobs:
21
- test:
22
- permissions:
23
- contents: read
24
- uses: fastify/workflows/.github/workflows/plugins-ci-package-manager.yml@v6
@@ -1,17 +0,0 @@
1
- import uri, { URIComponents, URIComponent, Options, options } from '..'
2
- import { expectDeprecated, expectType } from 'tsd'
3
-
4
- const parsed = uri.parse('foo')
5
- expectType<URIComponents>(parsed)
6
- const parsed2 = uri.parse('foo', {
7
- domainHost: true,
8
- scheme: 'https',
9
- unicodeSupport: false
10
- })
11
- expectType<URIComponents>(parsed2)
12
-
13
- expectType<URIComponent>({} as URIComponents)
14
- expectDeprecated({} as URIComponents)
15
-
16
- expectType<Options>({} as options)
17
- expectDeprecated({} as options)