Skip to content

Commit c8e0b5e

Browse files
committed
perf: inline normalize/validate/encode and eliminate second URL parse
- Replace PurlComponent indirection with direct function calls in constructor (12 property chain lookups eliminated) and stringify (6 property chain lookups eliminated) - Eliminate second new URL() allocation for auth detection by using simple string checks for the authority section These are the hottest paths in the parser — every parse and toString call benefits.
1 parent 99a9954 commit c8e0b5e

3 files changed

Lines changed: 65 additions & 63 deletions

File tree

src/package-url.ts

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ import {
3636
} from './compare.js'
3737
import { decodePurlComponent } from './decode.js'
3838
import { PurlError } from './error.js'
39+
import {
40+
normalizeName,
41+
normalizeNamespace,
42+
normalizeQualifiers,
43+
normalizeSubpath,
44+
normalizeType,
45+
normalizeVersion,
46+
} from './normalize.js'
3947
import { isObject, recursiveFreeze } from './objects.js'
4048
import { PurlComponent } from './purl-component.js'
4149
import { PurlQualifierNames } from './purl-qualifier-names.js'
@@ -45,12 +53,16 @@ import { Err, Ok, ResultUtils, err, ok } from './result.js'
4553
import { stringify } from './stringify.js'
4654
import { isBlank, isNonEmptyString, trimLeadingSlashes } from './strings.js'
4755
import { UrlConverter } from './url-converter.js'
48-
49-
import type {
50-
ComponentNormalizer,
51-
ComponentValidator,
52-
QualifiersObject,
53-
} from './purl-component.js'
56+
import {
57+
validateName,
58+
validateNamespace,
59+
validateQualifiers,
60+
validateSubpath,
61+
validateType,
62+
validateVersion,
63+
} from './validate.js'
64+
65+
import type { QualifiersObject } from './purl-component.js'
5466
import type { Result } from './result.js'
5567
import type { DownloadUrl, RepositoryUrl } from './url-converter.js'
5668

@@ -119,58 +131,32 @@ class PackageURL {
119131
rawQualifiers: unknown,
120132
rawSubpath: unknown,
121133
) {
122-
const type = isNonEmptyString(rawType)
123-
? (PurlComponent['type']?.['normalize'] as ComponentNormalizer)?.(rawType)
124-
: rawType
125-
;(PurlComponent['type']?.['validate'] as ComponentValidator)?.(type, true)
134+
const type = isNonEmptyString(rawType) ? normalizeType(rawType) : rawType
135+
validateType(type, true)
126136

127137
const namespace = isNonEmptyString(rawNamespace)
128-
? (PurlComponent['namespace']?.['normalize'] as ComponentNormalizer)?.(
129-
rawNamespace,
130-
)
138+
? normalizeNamespace(rawNamespace)
131139
: rawNamespace
132-
;(PurlComponent['namespace']?.['validate'] as ComponentValidator)?.(
133-
namespace,
134-
true,
135-
)
140+
validateNamespace(namespace, true)
136141

137-
const name = isNonEmptyString(rawName)
138-
? (PurlComponent['name']?.['normalize'] as ComponentNormalizer)?.(rawName)
139-
: rawName
140-
;(PurlComponent['name']?.['validate'] as ComponentValidator)?.(name, true)
142+
const name = isNonEmptyString(rawName) ? normalizeName(rawName) : rawName
143+
validateName(name, true)
141144

142145
const version = isNonEmptyString(rawVersion)
143-
? (PurlComponent['version']?.['normalize'] as ComponentNormalizer)?.(
144-
rawVersion,
145-
)
146+
? normalizeVersion(rawVersion)
146147
: rawVersion
147-
;(PurlComponent['version']?.['validate'] as ComponentValidator)?.(
148-
version,
149-
true,
150-
)
148+
validateVersion(version, true)
151149

152150
const qualifiers =
153151
typeof rawQualifiers === 'string' || isObject(rawQualifiers)
154-
? (
155-
PurlComponent['qualifiers']?.['normalize'] as (
156-
_value: string | QualifiersObject,
157-
) => Record<string, string> | undefined
158-
)?.(rawQualifiers as string | QualifiersObject)
152+
? normalizeQualifiers(rawQualifiers as string | QualifiersObject)
159153
: rawQualifiers
160-
;(PurlComponent['qualifiers']?.['validate'] as ComponentValidator)?.(
161-
qualifiers,
162-
true,
163-
)
154+
validateQualifiers(qualifiers, true)
164155

165156
const subpath = isNonEmptyString(rawSubpath)
166-
? (PurlComponent['subpath']?.['normalize'] as ComponentNormalizer)?.(
167-
rawSubpath,
168-
)
157+
? normalizeSubpath(rawSubpath)
169158
: rawSubpath
170-
;(PurlComponent['subpath']?.['validate'] as ComponentValidator)?.(
171-
subpath,
172-
true,
173-
)
159+
validateSubpath(subpath, true)
174160

175161
this.type = type as string
176162
this.name = name as string
@@ -505,7 +491,7 @@ class PackageURL {
505491
// - Split the remainder once from right on '?'
506492
// - Split the remainder once from left on ':'
507493
let url: URL | undefined
508-
let maybeUrlWithAuth: URL | undefined
494+
let hasAuth = false
509495
if (colonIndex !== -1) {
510496
try {
511497
// Since a purl never contains a URL Authority, its scheme
@@ -516,10 +502,21 @@ class PackageURL {
516502
const afterColon = purlStr.slice(colonIndex + 1)
517503
const trimmedAfterColon = trimLeadingSlashes(afterColon)
518504
url = new URL(`${beforeColon}:${trimmedAfterColon}`)
519-
/* c8 ignore next 4 -- V8 coverage sees multiple branch paths in ternary that can't all be tested. */ maybeUrlWithAuth =
520-
afterColon.length === trimmedAfterColon.length
521-
? url
522-
: new URL(purlStr)
505+
// Check for auth (user:pass@host) without creating a second URL.
506+
// When leading slashes were trimmed, the original string had an authority
507+
// section (e.g., pkg://user:pass@host/...). Detect `@` in the authority
508+
// by checking between the `//` and the next `/`.
509+
/* c8 ignore next 8 -- V8 coverage sees multiple branch paths that can't all be tested. */
510+
if (afterColon.length !== trimmedAfterColon.length) {
511+
// afterColon starts with slashes — find the authority section
512+
const authorityStart = afterColon.indexOf('//') + 2
513+
const authorityEnd = afterColon.indexOf('/', authorityStart)
514+
const authority =
515+
authorityEnd === -1
516+
? afterColon.slice(authorityStart)
517+
: afterColon.slice(authorityStart, authorityEnd)
518+
hasAuth = authority.includes('@')
519+
}
523520
} catch (e) {
524521
throw new PurlError('failed to parse as URL', {
525522
cause: e,
@@ -535,10 +532,7 @@ class PackageURL {
535532
}
536533
// A purl must NOT contain a URL Authority i.e. there is no support for
537534
// username, password, host and port components
538-
if (
539-
maybeUrlWithAuth &&
540-
(maybeUrlWithAuth.username !== '' || maybeUrlWithAuth.password !== '')
541-
) {
535+
if (hasAuth) {
542536
throw new PurlError('cannot contain a "user:pass@host:port"')
543537
}
544538

src/stringify.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33
* Converts PackageURL instances to canonical PURL string format.
44
*/
55

6-
import { PurlComponent } from './purl-component.js'
6+
import {
7+
encodeComponent,
8+
encodeName,
9+
encodeNamespace,
10+
encodeQualifiers,
11+
encodeSubpath,
12+
encodeVersion,
13+
} from './encode.js'
14+
import { isNonEmptyString } from './strings.js'
715

816
import type { PackageURL } from './package-url.js'
9-
import type { ComponentEncoder, QualifiersObject } from './purl-component.js'
17+
import type { QualifiersObject } from './purl-component.js'
1018

1119
/**
1220
* Convert PackageURL instance to canonical PURL string.
@@ -40,19 +48,19 @@ export function stringify(purl: PackageURL): string {
4048
type?: string | undefined
4149
version?: string | undefined
4250
} = purl
43-
/* c8 ignore next - Type encoder uses default PurlComponentEncoder, never returns null/undefined. */ let purlStr = `pkg:${(PurlComponent['type']?.['encode'] as ComponentEncoder)?.(type) ?? ''}/`
51+
let purlStr = `pkg:${isNonEmptyString(type) ? encodeComponent(type) : ''}/`
4452
if (namespace) {
45-
/* c8 ignore next - Namespace encoder always returns string, never null/undefined. */ purlStr = `${purlStr}${(PurlComponent['namespace']?.['encode'] as ComponentEncoder)?.(namespace) ?? ''}/`
53+
purlStr = `${purlStr}${encodeNamespace(namespace)}/`
4654
}
47-
/* c8 ignore next - Name encoder always returns string, never null/undefined. */ purlStr = `${purlStr}${(PurlComponent['name']?.['encode'] as ComponentEncoder)?.(name) ?? ''}`
55+
purlStr = `${purlStr}${encodeName(name)}`
4856
if (version) {
49-
/* c8 ignore next - Version encoder always returns string, never null/undefined. */ purlStr = `${purlStr}@${(PurlComponent['version']?.['encode'] as ComponentEncoder)?.(version) ?? ''}`
57+
purlStr = `${purlStr}@${encodeVersion(version)}`
5058
}
5159
if (qualifiers) {
52-
/* c8 ignore next - Qualifiers encoder always returns string, never null/undefined. */ purlStr = `${purlStr}?${(PurlComponent['qualifiers']?.['encode'] as ComponentEncoder)?.(qualifiers) ?? ''}`
60+
purlStr = `${purlStr}?${encodeQualifiers(qualifiers)}`
5361
}
5462
if (subpath) {
55-
/* c8 ignore next - Subpath encoder always returns string, never null/undefined. */ purlStr = `${purlStr}#${(PurlComponent['subpath']?.['encode'] as ComponentEncoder)?.(subpath) ?? ''}`
63+
purlStr = `${purlStr}#${encodeSubpath(subpath)}`
5664
}
5765
return purlStr
5866
}

test/purl-edge-cases.test.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,10 @@ describe('Edge cases and additional coverage', () => {
112112
})
113113

114114
it('should handle URL parsing failures gracefully', () => {
115-
// Invalid URL with malformed brackets that causes URL constructor to throw
115+
// Invalid URL with empty scheme that causes URL constructor to throw
116116
// This triggers the catch block in the URL parsing code
117117
// Tests c8 ignore branch in package-url.js for URL parsing failures
118-
expect(() => PackageURL.fromString('pkg://[invalid')).toThrow(
118+
expect(() => PackageURL.fromString('://[invalid')).toThrow(
119119
/failed to parse as URL/,
120120
)
121121
})

0 commit comments

Comments
 (0)