Guide

Custom Registries

Extend the base class, map the registry's API onto the shared types, register the ecosystem, and every helper picks it up.

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

hex.ts
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 the User-Agent. An adapter that calls fetch itself loses all of that and bypasses whatever the caller configured.
  • Throw typed errors. A 404 becomes NotFoundError; everything else stays the HTTPError the client threw. A plain Error cannot be caught by type.
  • Normalize at the boundary. Licenses through normalizeLicense or combineLicenses, repository URLs through normalizeRepositoryURL, dates to Date. Raw payload fields go in metadata, 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.

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