Custom 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;
}
All six methods are required. A registry that has no concept of maintainers returns an empty array; one that does not date versions returns publishedAt: null. Nothing is optional at the type level, so a consumer never has to check whether a method exists.
Extend the base class
import {
Registry,
register,
buildPURL,
normalizeLicense,
normalizeRepositoryURL,
HTTPError,
NotFoundError,
type Client,
type Dependency,
type Maintainer,
type Package,
type URLBuilder,
type Version,
} from "@agntn/registries";
interface HexPackage {
name: string;
meta: { description?: string; licenses?: string[]; links?: Record<string, string> };
releases: Array<{ version: string; inserted_at: string; retired?: unknown }>;
owners?: Array<{ username: string; email?: string }>;
}
export class HexRegistry extends Registry {
constructor(
readonly baseURL: string,
readonly client: Client,
) {
super();
}
ecosystem(): string {
return "hex";
}
async fetchPackage(name: string, signal?: AbortSignal): Promise<Package> {
const data = await this.get(name, signal);
const links = data.meta.links ?? {};
return {
name: data.name,
description: data.meta.description ?? "",
homepage: links["Homepage"] ?? "",
documentation: `https://hexdocs.pm/${name}`,
repository: normalizeRepositoryURL(links["GitHub"] ?? ""),
licenses: (data.meta.licenses ?? []).map(normalizeLicense).join(" OR "),
keywords: [],
namespace: "",
latestVersion: data.releases[0]?.version ?? "",
metadata: {},
};
}
async fetchVersions(name: string, signal?: AbortSignal): Promise<Version[]> {
const data = await this.get(name, signal);
return data.releases.map((release) => ({
number: release.version,
publishedAt: new Date(release.inserted_at),
licenses: "",
integrity: "",
status: release.retired ? "retracted" : "",
metadata: {},
}));
}
async fetchDependencies(): Promise<Dependency[]> {
return []; // hex.pm lists requirements per release on a separate endpoint
}
async fetchMaintainers(name: string, signal?: AbortSignal): Promise<Maintainer[]> {
const data = await this.get(name, signal);
return (data.owners ?? []).map((owner) => ({
uuid: "",
login: owner.username,
name: owner.username,
email: owner.email ?? "",
url: `https://hex.pm/users/${owner.username}`,
role: "owner",
}));
}
urls(): URLBuilder {
return {
registry: (name) => `https://hex.pm/packages/${name}`,
download: (name, version) => `https://repo.hex.pm/tarballs/${name}-${version}.tar`,
documentation: (name, version) => `https://hexdocs.pm/${name}${version ? `/${version}` : ""}`,
readme: (name) => `https://hexdocs.pm/${name}/readme.html`,
purl: (name, version) => buildPURL({ type: "hex", name, version }),
};
}
private async get(name: string, signal?: AbortSignal): Promise<HexPackage> {
try {
return await this.client.getJSON<HexPackage>(`${this.baseURL}/api/packages/${name}`, signal);
} catch (error) {
if (error instanceof HTTPError && error.isNotFound()) {
throw new NotFoundError("hex", name);
}
throw error;
}
}
}
register("hex", "https://hex.pm", HexRegistry);
Four rules the built-in adapters follow, and a review would ask of yours:
- Go through
Client. It owns retries, backoff, timeouts, the rate limiter and theUser-Agent. An adapter that callsfetchitself loses all of that and bypasses whatever the caller configured. - Throw typed errors. A 404 becomes
NotFoundError; everything else stays theHTTPErrorthe client threw. A plainErrorcannot be caught by type. - Normalize at the boundary. Licenses through
normalizeLicenseorcombineLicenses, repository URLs throughnormalizeRepositoryURL, dates toDate. Raw payload fields go inmetadata, never in the shared fields. - No imports between adapters. Shared logic belongs in core; an adapter that imports another adapter creates a dependency the registry system cannot see.
Register and use
import { create, fetchPackageFromPURL } from "@agntn/registries";
import "./hex"; // the register() call runs on import
await fetchPackageFromPURL("pkg:hex/phoenix"); // resolved through the registry you added
const hex = create("hex"); // or by key
register(ecosystem, defaultURL, RegistryClass) is the whole registration. create(ecosystem, baseURL?, client?) instantiates the class with the default URL and the default client unless told otherwise; ecosystems() lists what is registered; has(ecosystem) checks one. The CLI, the helpers, the cache and the AI tool all resolve through create, so one call makes the ecosystem available everywhere.
Where the built-ins live
src/registries/ holds one file per registry. npm.ts is the largest and the best template; alpm.ts shows an adapter that routes to two APIs by namespace. src/registries/index.ts imports every adapter for its registration side effect and re-exports the classes. src/core/ holds the contract, the PURL parser, the client, the errors and the license and repository normalizers.