Architecture
A map of how @socketregistry/packageurl-js is organized, which
module does what, and how data flows through a typical parse or
build. Read this before touching anything under src/ for the first
time.
Who this is for #
New contributors who want to understand the library's internals before making a change. No prior knowledge of the PURL spec required; this doc introduces concepts as it goes.
What a PURL is #
PURL (Package URL) is a specification for identifying any software package uniquely and deterministically across ecosystems.
The canonical form:
pkg:type/namespace/name@version?qualifier=value#subpath
Each piece is optional except type and name. Two concrete
examples:
pkg:npm/left-pad@1.3.0
pkg:pypi/requests@2.31.0?extension=tar.gz#src/urllib3
The spec lives at package-url/purl-spec and is on its way to becoming ECMA-427. The library implements the latest published spec + all accepted amendments.
The module map #
src/
├── index.ts ← public API surface (re-exports)
│
├── package-url.ts ← PackageURL class (main entry point)
├── package-url-builder.ts ← PurlBuilder (fluent alternative)
│
├── decode.ts ← string → fields
├── encode.ts ← fields → string (serialization)
├── stringify.ts ← canonical pkg:... rebuild
├── normalize.ts ← lowercase, trim, nudge to canonical
├── validate.ts ← shape + format checks
│
├── purl-component.ts ← PurlComponent: normalize/encode/validate
├── purl-type.ts ← PurlType: per-ecosystem rule loader
├── purl-qualifier-names.ts ← known qualifier keys (checksum, vcs_url, etc.)
│
├── result.ts ← Result<T, E> + Ok/Err helpers
├── error.ts ← PurlError + PurlInjectionError
│
├── url-converter.ts ← URL ↔ PURL conversion (~25 ecosystems)
├── compare.ts ← equal, matches (wildcard, ReDoS-safe)
├── exists.ts / purl-exists.ts ← registry existence checks (npmExists, etc.)
├── vers.ts ← version-range specifiers (pre-standard)
│
├── constants.ts ← KNOWN_TYPES, KNOWN_COMPONENTS, etc.
├── helpers.ts ← small internal utilities
├── lang.ts ← Error message strings (i18n-ready)
├── objects.ts ← safe-freeze, null-prototype utilities
├── strings.ts ← injection-character detection
├── primordials.ts ← cached JS built-ins (Map, Set, Array.from)
│
└── purl-types/ ← 41 ecosystem handlers
├── alpm.ts apk.ts bazel.ts bitbucket.ts
├── bitnami.ts cargo.ts cocoapods.ts composer.ts
├── conan.ts conda.ts cpan.ts cran.ts
├── deb.ts docker.ts gem.ts generic.ts
├── github.ts gitlab.ts golang.ts hackage.ts
├── hex.ts huggingface.ts julia.ts luarocks.ts
├── maven.ts mlflow.ts npm.ts nuget.ts
├── oci.ts opam.ts otp.ts pub.ts
├── pypi.ts qpkg.ts rpm.ts socket.ts
├── swid.ts swift.ts unknown.ts vscode-extension.ts
└── yocto.ts
Data flow — parsing a PURL string #
flowchart TD
A["pkg:npm/@scope/left-pad<br/>@1.3.0?extension=tgz#lib"] --> B["PackageURL.fromString()<br/>(decode.ts)"]
B --> C["Lexer: split on / : @ ? #<br/>type = npm<br/>namespace = @scope<br/>name = left-pad<br/>version = 1.3.0<br/>qualifiers = { extension: tgz }<br/>subpath = lib"]
C --> D["Per-component processing<br/>(purl-component.ts)<br/>1. decode (%xx → char)<br/>2. normalize<br/>3. validate"]
D --> E["Per-type rules<br/>(purl-type.ts + purl-types/*.ts)<br/>lowercase name<br/>namespace = scope<br/>validate npm rules"]
E --> F["Frozen PackageURL<br/>instance returned"]Parsing throws PurlError on malformed input (missing required
pieces, invalid percent-encoding, rule violations). Valid PURLs
never throw; the result is a frozen instance with every field
normalized to its canonical form.
Data flow — building a PURL from scratch #
Two entry points:
Constructor (all-at-once):
new PackageURL(
'npm',
'@scope',
'left-pad',
'1.3.0',
{ extension: 'tgz' },
'lib',
)
Builder (fluent):
PackageURL.builder()
.type('npm')
.namespace('@scope')
.name('left-pad')
.version('1.3.0')
.qualifier('extension', 'tgz')
.subpath('lib')
.build()
Both paths converge:
flowchart TD
A["(type, namespace, name, version, qualifiers, subpath)"] --> B["per-component<br/>normalize + validate"]
B --> C["per-type rule load<br/>+ any type-specific adjustment<br/>(e.g. lower name for npm,<br/>leave for maven)"]
C --> D["Frozen PackageURL"]Core abstractions #
PackageURL — the main class #
Instances are immutable. You cannot mutate .name after
construction; you build a new instance (builder or constructor) to
represent a change.
Immutability prevents a whole class of bugs: once a PURL is in a
data structure (like a dependency graph), consumers can trust it
will not change under them. Freezing is enforced via
Object.freeze in the constructor.
Methods on an instance:
.toString()— canonical serialization (callsstringify.ts).toJSON()— plain object forJSON.stringify().toObject()— alias fortoJSON
There is no .clone() method because instances are already
immutable — aliasing is safe.
PurlBuilder — the fluent alternative #
Use when you are constructing a PURL from computed values and want to validate each piece as you go:
const builder = PackageURL.builder().type('npm')
for (const dep of manifest.dependencies) {
if (dep.startsWith('@')) {
builder.namespace(dep.split('/')[0]).name(dep.split('/')[1])
} else {
builder.name(dep)
}
}
const purl = builder.build()
See docs/builders.md for the full API.
PurlComponent — per-field policy #
A PurlComponent is the triple (normalize, encode, validate)
applied to a field like name or namespace. Lives in
src/purl-component.ts. Example: the name component's normalize
step lowercases for some types, preserves case for others.
PurlType — per-ecosystem rule bundle #
A PurlType is the rule-set for one ecosystem (npm, maven,
pypi, …). Each file under src/purl-types/ exports a type object
with fields like:
export const npm: PurlType = {
normalize: { name: lowercaseName, namespace: lowercaseNamespace },
validate: { ... },
rules: { ... },
// metadata
defaultRegistry: 'https://registry.npmjs.org',
}
See docs/converters.md for how each ecosystem handles URL
conversion, and src/purl-types/README.md (TODO) for a rule
template.
Result<T, E> — functional error handling #
The library supports two error-handling styles:
- Throwing — the default on
new PackageURL(str). Convenient for code that can afford to catch at a boundary. - Result —
PackageURL.fromStringResult(str)returns aResult<PackageURL, PurlError>that isOk(purl)on success orErr(err)on failure. Convenient for validation pipelines where you want every failure aggregated, not the first one.
Result lives in src/result.ts with Ok, Err, ResultUtils
helpers:
const results = candidates.map(c => PackageURL.fromStringResult(c))
const valid = results.filter(r => r.ok).map(r => r.value)
const errors = results.filter(r => !r.ok).map(r => r.error)
PurlError + PurlInjectionError #
Two error classes:
PurlError— spec-level rule violation. Message shape is lowercase, no trailing period, follows{type} "{component}" component {violation}.PurlInjectionError— the input contained a dangerous control character that could desync a downstream consumer (URL encoder, shell interpolation, SQL). Thrown before parse — we refuse to even interpret input that could smuggle injection payloads.
See docs/safety.md for the threat model.
Dependency direction #
flowchart TD
A["index.ts (public API)"] --> B[PackageURL]
B --> C[decode]
B --> D[PurlBuilder]
C --> E[validate]
C --> F[purl-type]
D --> E
D --> F
F --> G["purl-types/*.ts"]
E --> H[encode]
F --> I[normalize]
H --> J["strings (injection)"]
I --> K["objects (freeze)"]No cycles. index.ts only depends on top-level symbols;
purl-types/*.ts depends on the abstractions (PurlComponent,
PurlType) but not on package-url.ts itself. This lets you
add a new ecosystem without touching any existing file.
How ecosystems plug in #
Each file under src/purl-types/ exports a single PurlType
object. Adding a new ecosystem:
- Create
src/purl-types/<name>.tsand export the rule object. - Register it in
src/purl-type.ts'sknownTypesmap. - Write functional tests at
test/purl-types/<name>.test.mts. - Run
pnpm testto validate. - Commit.
All existing ecosystem handlers are small (50–200 lines each).
They share a template — see src/purl-types/npm.ts as a baseline
for registry-style ecosystems or src/purl-types/docker.ts for
container-style.
Compilation model #
- Source: TypeScript under
src/, strict mode. - Target: CommonJS (Node 18+), one output file per module
under
dist/. - Type declarations: emitted as
.d.tsbeside the JS. - Build tool: esbuild transform (see
scripts/build.mts).
We target CommonJS because the consumers in the Socket fleet are
still a mix of ESM and CJS. ESM-only consumers can import a
.js file without issue since Node auto-detects, but the dual
mode means we don't need .mjs/.cjs exports proliferation.
Testing model #
Vitest with two configs:
.config/vitest.config.mts(threads, shared memory) — ~95% of tests. Fast, parallel..config/vitest.config.isolated.mts(forks, full isolation) — tests that mock globals (viavi.doMock) or modifyprocess.env/process.chdir. File naming:*.isolated.test.mts.
Test style in this repo: functional, not source-scanning. Tests assert behavior (inputs → outputs) via the public API. Tests never read source files and assert on contents.
Coverage: 100% required, enforced by CI.
See docs/contributing.md for the full workflow.
Where to look #
- How do I parse a string? →
src/decode.ts - How do I serialize to canonical form? →
src/stringify.ts→src/encode.ts - Where does ecosystem X's rule live? →
src/purl-types/<x>.ts - How does URL-to-PURL conversion work? →
src/url-converter.ts(seedocs/converters.md) - How does the
Resultpattern thread through? →src/result.ts - Where are error messages defined? →
src/lang.ts - What makes a PURL "injection-safe"? →
src/strings.ts(seedocs/safety.md)