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.
- package/.github/workflows/ci.yml +49 -1
- package/README.md +2 -0
- package/benchmark/encoder.mjs +54 -0
- package/index.js +87 -19
- package/lib/utils.js +109 -7
- package/package.json +4 -4
- package/test/equal.test.js +60 -0
- package/test/parse.test.js +5 -0
- package/test/resolve.test.js +9 -0
- package/test/security-utf8-encoding.test.js +0 -0
- package/test/security.test.js +133 -0
- package/tsconfig.json +8 -7
- package/types/index.d.ts +0 -9
- package/types/index.tst.ts +15 -0
- package/.github/workflows/package-manager-ci.yml +0 -24
- package/types/index.test-d.ts +0 -17
package/.github/workflows/ci.yml
CHANGED
|
@@ -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} */ (
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
344
|
-
output +=
|
|
413
|
+
if (isPathCharacter(ch)) {
|
|
414
|
+
output += ch
|
|
345
415
|
} else {
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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": "
|
|
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
|
|
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": "
|
|
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
|
-
"
|
|
66
|
+
"tstyche": "^7.0.0"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/test/equal.test.js
CHANGED
|
@@ -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
|
+
})
|
package/test/parse.test.js
CHANGED
|
@@ -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')
|
package/test/resolve.test.js
CHANGED
|
@@ -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
|
+
})
|
|
Binary file
|
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
package/types/index.test-d.ts
DELETED
|
@@ -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)
|