@interlynk-io/purl-js
v1.0.2
Published
A spec-compliant Package URL (PURL) parser, builder, and validator with full type registry
Maintainers
Readme
@interlynk-io/purl-js
A spec-compliant Package URL (PURL) parser, builder, and validator for JavaScript/TypeScript.
Features
- ECMA-427 compliant — passes all 514 official purl-spec tests
- All 38 registered types — full type-specific normalization and validation
- Zero runtime dependencies — only
typescriptandvitestas dev dependencies - Immutable & type-safe — frozen instances, null-prototype qualifiers, full TypeScript types
- Secure by default — null byte rejection, input length limits, prototype pollution protection
- Fast — 34M ops/sec for typical package name encoding (see Performance)
- 752 tests — 514 spec + 210 ECMA-427 compliance + 28 security
Installation
npm install @interlynk-io/purl-jsQuick Start
import { PackageURL, tryParse, isValid, validateString } from '@interlynk-io/purl-js';
// Parse a PURL string
const purl = PackageURL.parse('pkg:npm/%40angular/[email protected]');
console.log(purl.type); // "npm"
console.log(purl.namespace); // "@angular"
console.log(purl.name); // "core"
console.log(purl.version); // "16.2.0"
// Build a PURL from components
const built = new PackageURL('pypi', null, 'Django', '4.2', null, null);
console.log(built.toString()); // "pkg:pypi/[email protected]"
// Safe parsing (returns null instead of throwing)
const result = tryParse('not-a-purl'); // null
// Validation
isValid('pkg:npm/[email protected]'); // true
isValid('pkg:julia/Flux'); // false (requires uuid qualifier)Use Cases
Parse and inspect SBOMs
const components = sbom.components.map(c => {
const purl = tryParse(c.purl);
return {
ecosystem: purl?.type,
package: purl?.name,
version: purl?.version,
scope: purl?.namespace,
};
});Compare package versions
const current = PackageURL.parse('pkg:npm/[email protected]');
const updated = PackageURL.parse('pkg:npm/[email protected]');
if (current.matchesBase(updated) && !current.equals(updated)) {
console.log(`Update available: ${current.version} -> ${updated.version}`);
}Validate user input
function handlePurlInput(input: string) {
const err = validateString(input);
if (err) {
return { valid: false, errors: err.errors.map(e => e.message) };
}
return { valid: true, purl: PackageURL.parse(input) };
}Build PURLs programmatically
new PackageURL('npm', '@angular', 'core', '16.2.0', null, null).toString();
// "pkg:npm/%40angular/[email protected]"
new PackageURL('maven', 'org.junit', 'junit', '5.0', { classifier: 'sources' }, null).toString();
// "pkg:maven/org.junit/[email protected]?classifier=sources"
new PackageURL('docker', 'library', 'nginx', 'latest', { arch: 'amd64' }, null).toString();
// "pkg:docker/library/nginx@latest?arch=amd64"Type-specific normalization
The library automatically applies normalization rules per type:
// PyPI: underscores become dashes, name lowercased
PackageURL.parse('pkg:pypi/[email protected]').toString();
// "pkg:pypi/[email protected]"
// npm: namespace and name lowercased
PackageURL.parse('pkg:npm/%40Angular/[email protected]').toString();
// "pkg:npm/%40angular/[email protected]"
// Hugging Face: version (commit hash) lowercased
PackageURL.parse('pkg:huggingface/google/bert-base-uncased@CD5EF3A3').toString();
// "pkg:huggingface/google/bert-base-uncased@cd5ef3a3"API Reference
PackageURL Class
All instances are immutable. Properties are readonly.
| Property | Type | Description |
| ------------ | -------------------------------- | -------------------------- |
| type | string | Package type (e.g., npm) |
| namespace | string \| null | Package namespace/scope |
| name | string | Package name |
| version | string \| null | Package version |
| qualifiers | Record<string, string> \| null | Key-value qualifier pairs |
| subpath | string \| null | Subpath within the package |
Parsing & Building
| Method | Description |
| ------ | ----------- |
| PackageURL.parse(purl: string): PackageURL | Parse a PURL string (right-to-left algorithm). Throws on invalid input. |
| new PackageURL(type, namespace, name, version, qualifiers, subpath) | Build from components with type-specific normalization and validation. |
| toString(): string | Return the canonical PURL string. |
Transformations
| Method | Description |
| ------ | ----------- |
| withVersion(version: string): PackageURL | New instance with a different version. |
| withoutVersion(): PackageURL | New instance with version removed. |
| withQualifiers(qualifiers: Record<string, string>): PackageURL | New instance with replaced qualifiers. |
Comparison & Validation
| Method | Description |
| ------ | ----------- |
| equals(other: PackageURL): boolean | Semantic equality after normalization. |
| matchesBase(other: PackageURL): boolean | Same package ignoring version, qualifiers, subpath. |
| getQualifier(key: string): string \| undefined | Get a single qualifier value. |
| validate(): ValidationError \| null | Full spec validation including type-specific rules. |
Utility Functions
| Function | Description |
| -------- | ----------- |
| tryParse(purl: string): PackageURL \| null | Parse without throwing — returns null on failure. |
| isValid(purl: string): boolean | Check if a string is a valid PURL. |
| validateString(purl: string): ValidationError \| null | Parse + validate in one step. Returns error details or null. |
Type Registry
| Function | Description |
| -------- | ----------- |
| lookupType(type: string): TypeDefinition \| undefined | Look up a registered type definition (returns frozen object). |
| registeredTypes(): string[] | All registered type identifiers, sorted alphabetically. |
| registerType(def: TypeDefinition): void | Register a custom type. Cannot override the 38 built-in spec types. |
import { lookupType, registeredTypes, registerType } from '@interlynk-io/purl-js';
const pypi = lookupType('pypi');
console.log(pypi?.namespace.requirement); // "prohibited"
console.log(pypi?.name.caseSensitive); // false
console.log(registeredTypes());
// ["alpm", "apk", "bazel", "bitbucket", "bitnami", "cargo", ...]
registerType({
type: 'custom',
typeName: 'Custom',
description: 'My custom package type',
repository: { useRepository: false },
namespace: { requirement: 'optional', caseSensitive: true },
name: { requirement: 'required', caseSensitive: true },
version: { requirement: 'optional', caseSensitive: true },
examples: ['pkg:custom/[email protected]'],
});Supported Types
| Type | Description | Namespace |
| ---- | ----------- | --------- |
| alpm | Arch Linux packages | required |
| apk | APK-based packages | required |
| bazel | Bazel modules | prohibited |
| bitbucket | Bitbucket repos | required |
| bitnami | Bitnami packages | prohibited |
| cargo | Rust Cargo crates | prohibited |
| cocoapods | CocoaPods | optional |
| composer | PHP Composer | required |
| conan | C/C++ Conan | optional |
| conda | Conda packages | optional |
| cpan | Perl CPAN | required |
| cran | R packages | prohibited |
| deb | Debian packages | required |
| docker | Docker images | optional |
| gem | Ruby gems | optional |
| generic | Generic packages | optional |
| github | GitHub repos | required |
| golang | Go packages | required |
| hackage | Haskell packages | optional |
| hex | Hex packages | optional |
| huggingface | Hugging Face models | required |
| julia | Julia packages | prohibited |
| luarocks | Lua packages | optional |
| maven | Maven artifacts | required |
| mlflow | MLflow models | prohibited |
| npm | Node npm packages | optional |
| nuget | .NET NuGet | prohibited |
| oci | OCI images | prohibited |
| opam | OCaml packages | optional |
| otp | Erlang/OTP | prohibited |
| pub | Dart/Flutter pub | optional |
| pypi | Python packages | prohibited |
| qpkg | QNX packages | optional |
| rpm | RPM packages | required |
| swid | ISO SWID tags | optional |
| swift | Swift packages | required |
| vscode-extension | VS Code extensions | required |
| yocto | Yocto recipes | required |
Spec Compliance
Fully compliant with ECMA-427 / TC54. The test suite includes all 514 official spec tests sourced from the upstream package-url/purl-spec repository, plus a dedicated 210-test ECMA-427 compliance suite.
Verified:
- Right-to-left parsing algorithm
- Percent-encoding (unreserved set
A-Za-z0-9.-_~:, uppercase hex) - Checksum qualifier
%2Cencoding - Subpath
./..discard and%2Frejection - Qualifier key validation and duplicate rejection
- Namespace
%2Fsegment boundary protection - Scheme validation
- Type-specific normalization (PyPI
_→-, npm lowercase, Hugging Face version lowercase, MLflow conditional case) - Namespace required/prohibited enforcement
- Required qualifier enforcement (Julia
uuid, SWIDtag_id) - CPAN
::rejection - npm
@scopehandling
Security
Designed for untrusted input. Key protections:
- Input limits —
parse()enforces 64 KB max input, 128-qualifier limit, 4 KB per-component limit - Injection prevention — null bytes (
\0,%00) rejected in all fields and during percent-decoding - Immutability — qualifier objects use
Object.create(null)(no prototype pollution) andObject.freeze - Internal bypass protection — constructor skip-normalization gated by module-private
Symbol - Registry protection — built-in types cannot be overridden via
registerType() - Defense-in-depth —
toString()re-filters..from subpath; error messages truncate attacker-controlled strings;validate()catches invalid states from manual object construction
// For untrusted input, prefer parse() or tryParse()
const purl = tryParse(userInput);
if (!purl) { /* handle invalid input */ }
// If using the constructor with user-supplied components, validate afterward
const built = new PackageURL(type, ns, name, ver, quals, sub);
const err = built.validate();
if (err) { /* handle validation errors */ }Performance
Optimized for the typical PURL workload: short ASCII strings with occasional percent-encoding.
Run with npx vitest bench. Results on Apple M-series (Node.js 24):
| Function | Input | ops/sec | Latency |
|----------|-------|---------|---------|
| percentEncode | Short ASCII (7 chars) | 34M | 29 ns |
| | Mixed (46 chars) | 2.7M | 370 ns |
| | Unicode (12 chars) | 2.9M | 340 ns |
| | Long (1550 chars) | 150K | 6.6 μs |
| percentDecode | Short ASCII | 38M | 26 ns |
| | Mixed | 4.9M | 206 ns |
| | Unicode | 4M | 252 ns |
| | Long | 583K | 1.7 μs |
Optimizations:
- Fast-path short-circuit for all-ASCII strings
- Pre-computed 256-entry byte→encoded lookup table
- Shared
TextEncoderinstance - Fast-path decode when no
%present - O(1) slash stripping via index scanning
Development
Setup
npm installTests
npm test # Run all 752 tests
npm run test:watch # Watch mode
npm run test:coverage # With coverageBenchmarks
npx vitest benchBuild
npm run build # Outputs to ./distPlayground
Interactive browser-based playground for exploring PURL parsing, building, and validation. No build step required — Vite serves TypeScript directly.
npx vite --open playground.html --port 5555Four tabs:
- Parse — enter a PURL string, see parsed components and canonical form. Quick-try buttons for npm, maven, pypi, docker, golang, and oci.
- Build — select a type, fill in components, add qualifiers dynamically. Namespace auto-disables for types that prohibit it.
- Validate — green/red validation with detailed error cards (field, code, message). Quick-try buttons for common failures.
- Type Registry — browse all 38 types with namespace/name/version rules, case sensitivity, qualifiers, and clickable examples.
License
Apache-2.0
