Builders

The `PurlBuilder` fluent API — construct a `PackageURL` step by step with per-field setters and per-ecosystem factories.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS

Builders

The PurlBuilder fluent API — construct a PackageURL step by step, with per-field setters and per-ecosystem factories. Read this when you want to build a PURL from computed values rather than handing every argument to a constructor all at once.

Who this is for #

Callers with a runtime shape that doesn't fit a single constructor call — you have a loop, a conditional, or a pipeline where each piece of the PURL lands at a different step. Also contributors adding a new ecosystem factory.

When to use what #

You have… Use
All six pieces in hand at once new PackageURL(type, ns, name, version, qualifiers, subpath) — positional, fastest.
A loop / conditional that sets fields over time PurlBuilder — method chaining, field-by-field.
An existing PackageURL and want to tweak one field PurlBuilder.from(existing).name('new').build() — creates a fresh instance since PackageURL is frozen.
A string from the wire new PackageURL(str) or PackageURL.fromStringResult(str) — see docs/hardening.md.

The builder is not faster than the constructor; it is easier to read when construction is spread across code.

The fluent API at a glance #

import { PurlBuilder } from '@socketregistry/packageurl-js'

const purl = PurlBuilder.create()
  .type('npm')
  .namespace('@scope')
  .name('left-pad')
  .version('1.3.0')
  .qualifier('extension', 'tgz')
  .subpath('lib')
  .build()

purl.toString()
// 'pkg:npm/%40scope/left-pad@1.3.0?extension=tgz#lib'

Every setter returns this so calls chain. build() returns the frozen PackageURL and validates — if a required field is missing or a value fails its component's validator, build() throws.

The setters #

Method Sets Notes
.type(str) The package type (npm, pypi, maven, …). Required. Lowercased. Must match a registered PurlType.
.namespace(str) Namespace / scope / group (e.g. @scope for npm, org.acme for maven). Optional. Normalization depends on type. npm lowercases; maven preserves case.
.name(str) Package name. Required. Same as namespace — normalization per type.
.version(str) Version string. Optional. Free-form; validated for injection chars but not semver-shape (ecosystems disagree).
.qualifier(key, value) One key-value qualifier. Add many by chaining multiple calls. See the known-qualifier list below.
.qualifiers(obj) Set all qualifiers at once from an object. Replaces any previously-set qualifiers.
.subpath(str) Subpath within the package (e.g. lib/utils). Optional. Leading/trailing slashes are stripped.
.build() Finalize. Throws on invalid.

The per-ecosystem factories #

For common ecosystems, the builder has a static shortcut that pre- sets .type():

// These two are equivalent:
PurlBuilder.create().type('npm').name('lodash').version('4.17.21').build()
PurlBuilder.npm().name('lodash').version('4.17.21').build()

Available factories:

Factory Ecosystem Preset type
PurlBuilder.bitbucket() Bitbucket repos bitbucket
PurlBuilder.cargo() Rust crates cargo
PurlBuilder.cocoapods() iOS/macOS pods cocoapods
PurlBuilder.composer() PHP packages composer
PurlBuilder.conan() C/C++ (Conan Center) conan
PurlBuilder.conda() Conda packages conda
PurlBuilder.cran() R packages cran
PurlBuilder.deb() Debian packages deb
PurlBuilder.docker() Docker images docker
PurlBuilder.gem() Ruby gems gem
PurlBuilder.github() GitHub repos github
PurlBuilder.gitlab() GitLab repos gitlab
PurlBuilder.golang() Go modules golang
PurlBuilder.hackage() Haskell packages hackage
PurlBuilder.hex() Elixir/Erlang packages hex
PurlBuilder.huggingface() Hugging Face models huggingface
PurlBuilder.luarocks() Lua packages luarocks
PurlBuilder.maven() Maven Central maven
PurlBuilder.npm() npm packages npm
PurlBuilder.nuget() .NET packages nuget
PurlBuilder.oci() OCI containers oci
PurlBuilder.pub() Dart/Flutter pub
PurlBuilder.pypi() Python packages pypi
PurlBuilder.rpm() RPM packages rpm
PurlBuilder.swift() Swift packages swift

Generic entry points:

Known qualifier keys #

Qualifiers are an open key-value space, but the PURL spec (and downstream tooling) standardizes a few:

Qualifier Meaning
checksum Digest of the artifact (e.g. sha256:abc…).
download_url Direct URL to download the artifact.
file_name Filename of the distributed artifact (e.g. tar.gz, whl).
repository_url URL of the source repository.
vcs_url VCS (git/hg) URL, including commit reference.
vers A VERS range (see docs/vers.md) constraining the version.

The library knows these keys and normalizes their values; custom keys pass through untouched. See src/purl-qualifier-names.ts for the canonical list.

Worked examples #

Build from a package.json entry #

function purlFromPackageJson(name: string, version: string): PackageURL {
  const builder = PurlBuilder.npm().version(version)

  // npm scoped packages: '@scope/pkg' → namespace '@scope', name 'pkg'
  if (name.startsWith('@')) {
    const [scope, pkg] = name.split('/')
    builder.namespace(scope).name(pkg)
  } else {
    builder.name(name)
  }

  return builder.build()
}

purlFromPackageJson('lodash', '4.17.21').toString()
// 'pkg:npm/lodash@4.17.21'

purlFromPackageJson('@scope/pkg', '1.0.0').toString()
// 'pkg:npm/%40scope/pkg@1.0.0'

Build with a download URL qualifier #

const purl = PurlBuilder.pypi()
  .name('requests')
  .version('2.31.0')
  .qualifier('extension', 'tar.gz')
  .qualifier(
    'download_url',
    'https://files.pythonhosted.org/…/requests-2.31.0.tar.gz',
  )
  .build()

purl.toString()
// 'pkg:pypi/requests@2.31.0?download_url=…&extension=tar.gz'

Note that qualifiers are alphabetized in the canonical output.

Tweak one field on an existing PURL #

const original = new PackageURL('npm', undefined, 'lodash', '4.17.20')
const updated = PurlBuilder.from(original).version('4.17.21').build()

original.toString() // 'pkg:npm/lodash@4.17.20' (unchanged; frozen)
updated.toString() // 'pkg:npm/lodash@4.17.21'

PurlBuilder.from() is the only sanctioned way to produce a modified copy. Direct mutation is impossible by design (see docs/hardening.md).

Chain many qualifiers #

PurlBuilder.maven()
  .namespace('org.apache.logging.log4j')
  .name('log4j-core')
  .version('2.17.1')
  .qualifier('classifier', 'sources')
  .qualifier('extension', 'jar')
  .qualifier('type', 'sources')
  .qualifier('repository_url', 'https://repo.maven.apache.org/maven2')
  .build()

Alternative using .qualifiers(obj):

PurlBuilder.maven()
  .namespace('org.apache.logging.log4j')
  .name('log4j-core')
  .version('2.17.1')
  .qualifiers({
    classifier: 'sources',
    extension: 'jar',
    type: 'sources',
    repository_url: 'https://repo.maven.apache.org/maven2',
  })
  .build()

Both produce the same PURL. Use .qualifier() when adding one at a time inside a loop; use .qualifiers() when you have the whole object already.

Validation timing #

The builder does not validate as you set — so:

PurlBuilder.create().type('npm').name('') // empty name — won't error here

Validation runs when you call .build(). That call constructs a new PackageURL, which invokes the per-component validators. A failure at .build() throws with a message pointing at the offending field.

This "fail late" design lets you construct a builder in one place and pass it around (e.g. to helper functions that set more fields) without each mutation being a potential throw site. If you want "fail early," prefer the constructor and check the throw at a single site.

The ESM/CJS instanceof footgun #

PurlBuilder internally imports PackageURL via CommonJS require(). If your code imports PackageURL via ESM import, Node wraps the two imports into different objects, and builtPurl instanceof PackageURL returns false even though the structure is correct.

Workaround:

// Bad:
const ok = purl instanceof PackageURL

// Good:
const ok = purl && purl.constructor.name === 'PackageURL'

// Also good — use a duck-type check on the fields you care about:
const ok = typeof purl === 'object' && typeof purl.toString === 'function'

This limitation is a Node ESM/CJS interop artifact, not a library bug. Affects only instanceof, not any actual functionality.

Adding a new ecosystem factory #

If you implement a new PurlType handler under src/purl-types/<name>.ts, add a matching PurlBuilder.<name>() factory:

static <name>(): PurlBuilder {
  return new PurlBuilder().type('<name>')
}

Conventions: