🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

fast-uri

Package Overview
Dependencies
Maintainers
11
Versions
29
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

fast-uri - npm Package Compare versions

Comparing version
3.1.2
to
4.0.0
+54
benchmark/encoder.mjs
import { Bench } from 'tinybench'
import { fastUri } from '../index.js'
const { serialize, normalize, parse } = fastUri
const asciiPath = '/users/profile/settings/account'
const asciiPathWithSpaces = '/users/john doe/profile page'
const cjkPath = '/ユーザー/プロフィール/設定'
const mixedPath = '/users/日本/profile-📱-emoji/設定'
const emojiPath = '/🚀/🌍/🎉/end'
const longAsciiPath = '/' + 'a/b/c/d/e/f/g/h/'.repeat(20)
const longUtf8Path = '/' + '日本/中文/한국/'.repeat(20)
const bench = new Bench({ name: 'encoder bench', time: 1000 })
bench.add('serialize ascii path', () => {
serialize({ scheme: 'http', host: 'example.com', path: asciiPath })
})
bench.add('serialize ascii path with spaces', () => {
serialize({ scheme: 'http', host: 'example.com', path: asciiPathWithSpaces })
})
bench.add('serialize cjk path', () => {
serialize({ scheme: 'http', host: 'example.com', path: cjkPath })
})
bench.add('serialize mixed path', () => {
serialize({ scheme: 'http', host: 'example.com', path: mixedPath })
})
bench.add('serialize emoji path', () => {
serialize({ scheme: 'http', host: 'example.com', path: emojiPath })
})
bench.add('serialize long ascii path', () => {
serialize({ scheme: 'http', host: 'example.com', path: longAsciiPath })
})
bench.add('serialize long utf8 path', () => {
serialize({ scheme: 'http', host: 'example.com', path: longUtf8Path })
})
bench.add('normalize utf8 uri', () => {
normalize('http://example.com/日本/profile')
})
bench.add('parse utf8 uri', () => {
parse('http://example.com/日本/profile')
})
await bench.run()
console.log(bench.name)
console.table(bench.table())
'use strict'
const test = require('tape')
const fastURI = require('..')
const {
normalizePathEncoding,
escapePreservingEscapes
} = require('../lib/utils')
// Helper: any %uXXXX in output is a bug. RFC 3986 only allows %XX.
const PERCENT_U = /%u[\da-fA-F]{4}/
test('normalizePathEncoding emits UTF-8 %XX for BMP chars', (t) => {
const cases = [
['/日本', '/%E6%97%A5%E6%9C%AC'],
['/ä', '/%C3%A4'],
['/€', '/%E2%82%AC'],
['/中文/path', '/%E4%B8%AD%E6%96%87/path']
]
t.plan(cases.length * 2)
cases.forEach(([input, expected]) => {
const out = normalizePathEncoding(input)
t.equal(out, expected, input)
t.notOk(PERCENT_U.test(out), 'no %uXXXX in ' + out)
})
})
test('normalizePathEncoding emits 4-byte UTF-8 for supplementary plane (emoji)', (t) => {
const cases = [
['/😀', '/%F0%9F%98%80'],
['/🚀', '/%F0%9F%9A%80'],
['/a🚀b', '/a%F0%9F%9A%80b']
]
t.plan(cases.length * 2)
cases.forEach(([input, expected]) => {
const out = normalizePathEncoding(input)
t.equal(out, expected, input)
t.notOk(PERCENT_U.test(out), 'no %uXXXX in ' + out)
})
})
test('normalizePathEncoding encodes control chars as %XX', (t) => {
const cases = [
['', '%00'],
['', '%07'],
['', '%1F'],
[' ', '%20'],
['', '%7F']
]
t.plan(cases.length)
cases.forEach(([input, expected]) => {
t.equal(normalizePathEncoding(input), expected, JSON.stringify(input))
})
})
test('normalizePathEncoding preserves valid path characters', (t) => {
const passthrough = "abcXYZ0/9-._~!$&'()*+,;=:@"
t.plan(1)
t.equal(normalizePathEncoding(passthrough), passthrough)
})
test('normalizePathEncoding replaces lone surrogates with U+FFFD bytes', (t) => {
const fffd = '%EF%BF%BD'
const cases = [
['\uD800', fffd],
['\uDC00', fffd],
['a\uD800b', 'a' + fffd + 'b']
]
t.plan(cases.length * 2)
cases.forEach(([input, expected]) => {
const out = normalizePathEncoding(input)
t.equal(out, expected)
t.notOk(PERCENT_U.test(out))
})
})
test('escapePreservingEscapes emits UTF-8 %XX for non-ASCII', (t) => {
const cases = [
['日', '%E6%97%A5'],
['ä', '%C3%A4'],
['😀', '%F0%9F%98%80'],
['/€/', '/%E2%82%AC/']
]
t.plan(cases.length * 2)
cases.forEach(([input, expected]) => {
const out = escapePreservingEscapes(input)
t.equal(out, expected, input)
t.notOk(PERCENT_U.test(out), 'no %uXXXX in ' + out)
})
})
test('escapePreservingEscapes preserves existing valid %XX sequences', (t) => {
const cases = [
['%2F', '%2F'],
['%2f', '%2F'],
['a%E6%97%A5b', 'a%E6%97%A5b']
]
t.plan(cases.length)
cases.forEach(([input, expected]) => {
t.equal(escapePreservingEscapes(input), expected, input)
})
})
test('escapePreservingEscapes preserves escape() ASCII safe-set', (t) => {
const passthrough = 'abcXYZ012*+-./@_'
t.plan(1)
t.equal(escapePreservingEscapes(passthrough), passthrough)
})
test('serialize produces valid UTF-8 percent-encoding for non-ASCII paths', (t) => {
const cases = [
[{ path: '/日本' }, '/%E6%97%A5%E6%9C%AC'],
[{ scheme: 'http', host: 'example.com', path: '/café' }, 'http://example.com/caf%C3%A9'],
[{ scheme: 'https', host: 'example.com', path: '/emoji/🚀' }, 'https://example.com/emoji/%F0%9F%9A%80']
]
t.plan(cases.length * 2)
cases.forEach(([component, expected]) => {
const out = fastURI.serialize(component)
t.equal(out, expected, JSON.stringify(component))
t.notOk(PERCENT_U.test(out), 'no %uXXXX in ' + out)
})
})
test('serialize output round-trips via decodeURIComponent', (t) => {
const inputs = [
'/日本/path',
'/café',
'/emoji/🚀/end',
'/a b c'
]
t.plan(inputs.length)
inputs.forEach((path) => {
const out = fastURI.serialize({ path })
t.equal(decodeURIComponent(out), path, path)
})
})
test('normalize produces valid UTF-8 for non-ASCII URIs', (t) => {
const cases = [
['http://example.com/日本', 'http://example.com/%E6%97%A5%E6%9C%AC'],
['http://example.com/café', 'http://example.com/caf%C3%A9']
]
t.plan(cases.length * 2)
cases.forEach(([input, expected]) => {
const out = fastURI.normalize(input)
t.equal(out, expected, input)
t.notOk(PERCENT_U.test(out))
})
})
test('encoded UTF-8 is idempotent under repeated normalize', (t) => {
const inputs = [
'http://example.com/日本',
'http://example.com/café',
'http://example.com/emoji/🚀'
]
t.plan(inputs.length)
inputs.forEach((input) => {
const once = fastURI.normalize(input)
const twice = fastURI.normalize(once)
t.equal(twice, once, input)
})
})
import uri, {
type URIComponent,
} from '..'
import { expect } from 'tstyche'
const parsed = uri.parse('foo')
expect(parsed).type.toBe<URIComponent>()
const parsed2 = uri.parse('foo', {
domainHost: true,
scheme: 'https',
unicodeSupport: false
})
expect(parsed2).type.toBe<URIComponent>()
+49
-1

@@ -26,2 +26,50 @@ name: CI

jobs:
type-tests:
name: Type Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['20', '22', '24', '26']
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: package.json
check-latest: true
- name: Install dependencies
run: npm install --ignore-scripts
- name: Run type tests
run: npm run test:typescript
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['16', '18', '20', '22', '24', '26']
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: package.json
check-latest: true
- name: Install dependencies
run: npm install --ignore-scripts
- name: Run unit tests
run: npm test
test-regression-check-node10:

@@ -107,2 +155,2 @@ name: Test compatibility with Node.js 10

lint: true
node-versions: '["16", "18", "20", "22", "24"]'
node-versions: '["16", "18", "20", "22", "24", "26"]'

@@ -334,3 +334,52 @@ 'use strict'

/** Pre-built `%XX` strings for every byte. Avoids per-call hex math/concat. */
const BYTE_HEX = new Array(256)
{
const HEX_DIGITS = '0123456789ABCDEF'
for (let i = 0; i < 256; i++) {
BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
}
}
/**
* Mirrors the pass-through set of the deprecated `escape()` global:
* `A-Z a-z 0-9 * + - . / @ _`. Keeps drop-in semantics for ASCII input.
*
* @param {number} cp
* @returns {boolean}
*/
function isEscapeSafe (cp) {
return (
(cp >= 0x30 && cp <= 0x39) ||
(cp >= 0x41 && cp <= 0x5A) ||
(cp >= 0x61 && cp <= 0x7A) ||
cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
cp === 0x2F || cp === 0x40 || cp === 0x5F
)
}
/**
* RFC 3986 percent-encode a non-ASCII Unicode code point as UTF-8 bytes.
* Caller handles the ASCII branch inline for speed.
*
* @param {number} cp - Unicode code point (>= 0x80, <= 0x10FFFF)
* @returns {string}
*/
function percentEncodeNonAscii (cp) {
if (cp < 0x800) {
return BYTE_HEX[0xC0 | (cp >> 6)] +
BYTE_HEX[0x80 | (cp & 0x3F)]
}
if (cp < 0x10000) {
return BYTE_HEX[0xE0 | (cp >> 12)] +
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
BYTE_HEX[0x80 | (cp & 0x3F)]
}
return BYTE_HEX[0xF0 | (cp >> 18)] +
BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
BYTE_HEX[0x80 | (cp & 0x3F)]
}
/**
* Normalizes path data without turning reserved escapes into live path syntax.

@@ -347,3 +396,4 @@ * Valid escapes are uppercased, raw unsafe characters are escaped, and only

for (let i = 0; i < input.length; i++) {
if (input[i] === '%' && i + 2 < input.length) {
const ch = input[i]
if (ch === '%' && i + 2 < input.length) {
const hex = input.slice(i + 1, i + 3)

@@ -365,6 +415,21 @@ if (isHexPair(hex)) {

if (isPathCharacter(input[i])) {
output += input[i]
if (isPathCharacter(ch)) {
output += ch
} else {
output += escape(input[i])
const code = input.charCodeAt(i)
if (code < 0x80) {
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
} else if (code < 0xD800 || code > 0xDFFF) {
output += percentEncodeNonAscii(code)
} else if (code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
i++
} else {
output += percentEncodeNonAscii(0xFFFD)
}
} else {
output += percentEncodeNonAscii(0xFFFD)
}
}

@@ -386,3 +451,4 @@ }

for (let i = 0; i < input.length; i++) {
if (input[i] === '%' && i + 2 < input.length) {
const ch = input[i]
if (ch === '%' && i + 2 < input.length) {
const hex = input.slice(i + 1, i + 3)

@@ -396,3 +462,18 @@ if (isHexPair(hex)) {

output += escape(input[i])
const code = input.charCodeAt(i)
if (code < 0x80) {
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
} else if (code < 0xD800 || code > 0xDFFF) {
output += percentEncodeNonAscii(code)
} else if (code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
i++
} else {
output += percentEncodeNonAscii(0xFFFD)
}
} else {
output += percentEncodeNonAscii(0xFFFD)
}
}

@@ -399,0 +480,0 @@

+4
-4
{
"name": "fast-uri",
"description": "Dependency-free RFC 3986 URI toolbox",
"version": "3.1.2",
"version": "4.0.0",
"main": "index.js",

@@ -51,3 +51,3 @@ "type": "commonjs",

"lint:fix": "eslint --fix",
"test": "npm run test:unit && npm run test:typescript",
"test": "npm run test:unit",
"test:browser:chromium": "playwright-test ./test/* --runner tape --browser=chromium",

@@ -59,3 +59,3 @@ "test:browser:firefox": "playwright-test ./test/* --runner tape --browser=firefox",

"test:unit:dev": "npm run test:unit -- --coverage-report=html",
"test:typescript": "tsd"
"test:typescript": "tstyche"
},

@@ -68,4 +68,4 @@ "devDependencies": {

"tape": "^5.8.1",
"tsd": "^0.33.0"
"tstyche": "^7.0.0"
}
}

@@ -118,1 +118,52 @@ 'use strict'

})
test('URI Equals — raw UTF-8 char equals its percent-encoded UTF-8 form', (t) => {
const suite = [
// BMP characters
{ pair: ['http://example.com/日本', 'http://example.com/%E6%97%A5%E6%9C%AC'], result: true },
{ pair: ['http://example.com/café', 'http://example.com/caf%C3%A9'], result: true },
{ pair: ['http://example.com/€', 'http://example.com/%E2%82%AC'], result: true },
{ pair: ['http://example.com/中文', 'http://example.com/%E4%B8%AD%E6%96%87'], result: true },
// supplementary plane (emoji, surrogate pairs)
{ pair: ['http://example.com/🚀', 'http://example.com/%F0%9F%9A%80'], result: true },
{ pair: ['http://example.com/😀', 'http://example.com/%F0%9F%98%80'], result: true },
// mixed encoded + raw in same path
{ pair: ['http://example.com/users/日本/profile', 'http://example.com/users/%E6%97%A5%E6%9C%AC/profile'], result: true },
{ pair: ['http://example.com/a/€/b', 'http://example.com/a/%E2%82%AC/b'], result: true }
]
runTest(t, suite)
t.end()
})
test('URI Equals — percent-encoded UTF-8 is case-insensitive in hex digits', (t) => {
const suite = [
{ pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%c3%a9'], result: true },
{ pair: ['http://example.com/%E6%97%A5', 'http://example.com/%e6%97%a5'], result: true },
{ pair: ['http://example.com/%F0%9F%9A%80', 'http://example.com/%f0%9f%9a%80'], result: true }
]
runTest(t, suite)
t.end()
})
test('URI Equals — different Unicode code points are not equal', (t) => {
const suite = [
{ pair: ['http://example.com/日本', 'http://example.com/中文'], result: false },
{ pair: ['http://example.com/café', 'http://example.com/cafe'], result: false },
{ pair: ['http://example.com/🚀', 'http://example.com/🎉'], result: false }
]
runTest(t, suite)
t.end()
})
test('URI Equals — Latin-1 byte is not equal to UTF-8 multi-byte encoding', (t) => {
// Parser-differential guard: %C3%A9 (UTF-8 for "é") must NOT equal %E9
// (raw Latin-1 byte). The old escape() emitted single Latin-1 bytes for
// non-ASCII, which would have made these incorrectly compare equal in a
// round-tripped URI.
const suite = [
{ pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%E9'], result: false },
{ pair: ['http://example.com/%E6%97%A5', 'http://example.com/%E6'], result: false }
]
runTest(t, suite)
t.end()
})
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"strict": true,
"noImplicitAny": true,
"target": "es2015"
}
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"strict": true,
"noImplicitAny": true,
"target": "es2022",
"noEmit": true
}
}

@@ -31,11 +31,2 @@ type FastUri = typeof fastUri

/**
* @deprecated Use Options instead
*/
export type options = Options
/**
* @deprecated Use URIComponent instead
*/
export type URIComponents = URIComponent
export function normalize (uri: string, opts?: Options): string

@@ -42,0 +33,0 @@ export function normalize (uri: URIComponent, opts?: Options): URIComponent

name: package-manager-ci
on:
push:
branches:
- main
- next
- 'v*'
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
permissions:
contents: read
jobs:
test:
permissions:
contents: read
uses: fastify/workflows/.github/workflows/plugins-ci-package-manager.yml@v6
import uri, { URIComponents, URIComponent, Options, options } from '..'
import { expectDeprecated, expectType } from 'tsd'
const parsed = uri.parse('foo')
expectType<URIComponents>(parsed)
const parsed2 = uri.parse('foo', {
domainHost: true,
scheme: 'https',
unicodeSupport: false
})
expectType<URIComponents>(parsed2)
expectType<URIComponent>({} as URIComponents)
expectDeprecated({} as URIComponents)
expectType<Options>({} as options)
expectDeprecated({} as options)