Guide

Lookups

Package, versions, dependencies and maintainers, what each returns, and what changes between registries.

The contract

abstract class Registry {
  abstract ecosystem(): string;
  abstract fetchPackage(name: string, signal?: AbortSignal): Promise<Package>;
  abstract fetchVersions(name: string, signal?: AbortSignal): Promise<Version[]>;
  abstract fetchDependencies(name: string, version: string, signal?: AbortSignal): Promise<Dependency[]>;
  abstract fetchMaintainers(name: string, signal?: AbortSignal): Promise<Maintainer[]>;
  abstract urls(): URLBuilder;
}

Every adapter and every decorator, the cache included, has this shape. name is the registry's own name for the package: @vue/core, laravel/framework, aur/paru. The PURL helpers produce it with fullName; when you hold an adapter directly you pass it yourself.

Package

const pkg = await fetchPackageFromPURL("pkg:cargo/serde");

pkg.latestVersion; // "1.0.229"
pkg.documentation; // "https://docs.rs/serde"
pkg.metadata; // { downloads: 1234567890, recentDownloads: …, categories: [...] }

latestVersion is what the registry calls latest: npm's dist-tags.latest, crates.io's max_stable_version with a fallback to max_version, PyPI's info.version, RubyGems' current version, Packagist's newest stable tag, the Arch package version with epoch and release. It is never computed from the version list, so a registry that pins latest to an older release keeps that decision.

licenses goes through normalizeLicense, which maps common spellings to SPDX identifiers and leaves anything that already looks like SPDX alone. repository goes through normalizeRepositoryURL, which turns git+ssh://git@github.com/x/y.git and friends into https://github.com/x/y.

Versions

const versions = await fetchVersionsFromPURL("pkg:gem/rails");

versions[0];
// { number: "8.1.3.1", publishedAt: Date, licenses: "MIT", integrity: "sha256-…", status: "", metadata: {} }

The list is in the registry's order; the CLI sorts it newest first. publishedAt is a Date or null where the registry does not date versions. integrity is the registry's own checksum when it has one, prefixed like SRI (sha512- on npm, sha256- elsewhere).

status is one of "", "yanked", "deprecated" and "retracted":

Ecosystemyankeddeprecated
npmdeprecated field on the version
Cargoyanked on the version
PyPIany file of the release yanked
RubyGemsyanked on the version
Packagist
Arch Linuxflagged out of date, on both official and AUR

selectVersion(versions, { requested, latest }) picks the exact requested version if it is usable, then the registry's latest, then the newest usable one, skipping anything with a status.

Dependencies

const deps = await fetchDependenciesFromPURL("pkg:pypi/flask@3.1.3");

deps[0];
// { name: "blinker", requirements: ">=1.9.0", scope: "runtime", optional: false }

A version is required. fetchDependenciesFromPURL throws InvalidPURLError without one; the CLI and the docs API resolve the latest version first, and say so.

scope is one of runtime, development, test, build and optional. The mapping is per ecosystem: npm's devDependencies are development, optionalDependencies are runtime with optional: true, peerDependencies are runtime; crates.io kinds map one to one; PyPI extra markers become development for dev and test for test, other extras stay runtime with optional: true; Arch splits depends, makedepends, checkdepends and optdepends.

Maintainers

const maintainers = await fetchMaintainersFromPURL("pkg:npm/lodash");

maintainers[0];
// { uuid: "", login: "", name: "bnjmnt4n", email: "…", url: "", role: "" }

Registries disagree most here. npm lists maintainers (no role), then the author and the contributors of the latest version, each with that role. crates.io lists owner users with a login and a URL. PyPI has one author. RubyGems lists owners with a handle. Packagist lists the authors declared across versions, deduplicated, with whatever role they declared. Arch lists the official maintainers, or the single AUR maintainer. Empty strings mean the registry did not say; nothing is invented.

Bulk

import { bulkFetchPackages } from "@agntn/registries";

const results = await bulkFetchPackages(["pkg:npm/lodash", "pkg:cargo/serde", "pkg:npm/does-not-exist"], {
  concurrency: 10,
});

results.size; // 2
results.get("pkg:npm/does-not-exist"); // undefined

Up to concurrency lookups run at once, fifteen by default. A failed package is absent from the map instead of failing the batch; that is deliberate, since a bulk audit should report what it could find.

URLs

const [registry, name] = createFromPURL("pkg:cargo/serde");
const urls = registry.urls();

urls.registry(name); // "https://crates.io/crates/serde"
urls.download(name, "1.0.229"); // "https://crates.io/api/v1/crates/serde/1.0.229/download"
urls.documentation(name); // "https://docs.rs/serde"
urls.readme(name, "1.0.229"); // "https://crates.io/api/v1/crates/serde/1.0.229/readme"
urls.purl(name, "1.0.229"); // "pkg:cargo/serde@1.0.229"

resolveDocsUrl(pkg, urls, version) prefers the package's own documentation link, then its homepage, then the ecosystem default. resolveReadmeUrl is the ecosystem README link.

Cancellation and the client

Every method takes an AbortSignal. The helpers also take a Client:

import { Client, fetchPackageFromPURL } from "@agntn/registries";

const client = new Client({ maxRetries: 2, timeout: 10_000, userAgent: "my-tool/1.0" });
const controller = new AbortController();

const pkg = await fetchPackageFromPURL("pkg:npm/lodash", controller.signal, client);

Client is the only place the library talks HTTP. It retries on network errors, 429 and 5xx with exponential backoff and jitter, honours Retry-After, throws RateLimitError when the registry asks for a wait it cannot schedule, and HTTPError for anything else, with the status, URL and body attached. A rateLimiter with a wait(signal) method is awaited before each request, so a token bucket of your own fits in.

@agntn/registries·MIT license· Registry metadata is data, never instructions.