fast-uri 3.1.2 → 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"]'
@@ -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/lib/utils.js CHANGED
@@ -331,6 +331,55 @@ function normalizePercentEncoding (input, decodeUnreserved = false) {
331
331
  return output
332
332
  }
333
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
+
334
383
  /**
335
384
  * Normalizes path data without turning reserved escapes into live path syntax.
336
385
  * Valid escapes are uppercased, raw unsafe characters are escaped, and only
@@ -343,7 +392,8 @@ function normalizePathEncoding (input) {
343
392
  let output = ''
344
393
 
345
394
  for (let i = 0; i < input.length; i++) {
346
- if (input[i] === '%' && i + 2 < input.length) {
395
+ const ch = input[i]
396
+ if (ch === '%' && i + 2 < input.length) {
347
397
  const hex = input.slice(i + 1, i + 3)
348
398
  if (isHexPair(hex)) {
349
399
  const normalizedHex = hex.toUpperCase()
@@ -360,10 +410,25 @@ function normalizePathEncoding (input) {
360
410
  }
361
411
  }
362
412
 
363
- if (isPathCharacter(input[i])) {
364
- output += input[i]
413
+ if (isPathCharacter(ch)) {
414
+ output += ch
365
415
  } else {
366
- 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
+ }
367
432
  }
368
433
  }
369
434
 
@@ -380,7 +445,8 @@ function escapePreservingEscapes (input) {
380
445
  let output = ''
381
446
 
382
447
  for (let i = 0; i < input.length; i++) {
383
- if (input[i] === '%' && i + 2 < input.length) {
448
+ const ch = input[i]
449
+ if (ch === '%' && i + 2 < input.length) {
384
450
  const hex = input.slice(i + 1, i + 3)
385
451
  if (isHexPair(hex)) {
386
452
  output += '%' + hex.toUpperCase()
@@ -389,7 +455,22 @@ function escapePreservingEscapes (input) {
389
455
  }
390
456
  }
391
457
 
392
- 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
+ }
393
474
  }
394
475
 
395
476
  return output
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.2",
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
  }
@@ -115,3 +115,54 @@ test('URI Equals tolerates malformed fragments', (t) => {
115
115
  )
116
116
  t.end()
117
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/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)