~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
189 lines · 6.5 KB Raw History
// The packages a repository builds, from the registries in the same account.
//
// A dev.pdsjs.npm.package or dev.pdsjs.oci.repo record names no repository,
// and atproto has no reverse index, so the link is the `packages` list in the
// repository's dev.pdsjs.git.config record and nothing else. Its owner sets
// that list in the account page's repository settings.
//
// Every failure reads as "no packages": a repository that links none, a
// linked record that was deleted and an unreachable PDS all render the same
// absent section, and none of them is an error worth showing.

import { linkedPackagesOf } from '@pdsjs/git/rules';
import {
  NPM_PACKAGE_COLLECTION,
  nameToRkey as npmRkey,
} from '@pdsjs/npm/lexicon';
import { OCI_REPO_COLLECTION, nameToRkey as ociRkey } from '@pdsjs/oci/lexicon';
import { account, repoConfig } from './git.js';

/**
 * One release of a package: a version number for npm, a tag for an image.
 * @typedef {Object} PackageRelease
 * @property {string} name
 * @property {number} size
 * @property {string} [publishedAt]
 * @property {string} [digest] - the manifest digest, for an image
 */

/**
 * One linked package, in the shape every registry reads the same way.
 * @typedef {Object} LinkedPackage
 * @property {string} registry - 'npm' or 'oci'
 * @property {string} name
 * @property {string} label - what this registry calls what it holds
 * @property {string} install - the command that installs it
 * @property {string} latest - the release that command installs
 * @property {PackageRelease[]} releases - newest first
 * @property {number} size - every blob this package stores
 * @property {string} [updatedAt]
 */

/** Where npm reads this account's registry from. */
export const npmRegistryUrl = () => `${account.pds}/npm/`;

/**
 * The image name a container runtime pulls. The registry answers on the PDS
 * host, and a pull reference names that host without a scheme.
 * @param {string} name
 * @returns {string}
 */
export const ociPullRef = (name) => `${new URL(account.pds).host}/${name}`;

/**
 * @param {unknown} value - a dev.pdsjs.npm.package record value
 * @param {string} name
 * @returns {LinkedPackage}
 */
export function npmPackage(value, name) {
  const record =
    /** @type {{versions?: unknown, distTags?: unknown, updatedAt?: unknown}} */ (
      value ?? {}
    );
  const versions =
    /** @type {Array<{version?: string, publishedAt?: string, tarball?: {size?: number}, manifest?: {size?: number}}>} */ (
      Array.isArray(record.versions) ? record.versions : []
    );
  const distTags = /** @type {Array<{tag?: string, version?: string}>} */ (
    Array.isArray(record.distTags) ? record.distTags : []
  );

  let size = 0;
  for (const entry of versions) {
    size += (entry.tarball?.size ?? 0) + (entry.manifest?.size ?? 0);
  }
  // The version `npm install <name>` resolves to, which is the latest
  // dist-tag. The command names no version, so a record carrying no such tag
  // reads the same and npm picks the highest version it finds.
  const latest = distTags.find((tag) => tag.tag === 'latest')?.version;

  return {
    registry: 'npm',
    name,
    label: 'npm package',
    install: `npm install ${name} --registry ${npmRegistryUrl()}`,
    latest: latest ?? '',
    releases: versions
      .map((entry) => ({
        name: entry.version ?? '',
        size: entry.tarball?.size ?? 0,
        publishedAt: entry.publishedAt,
      }))
      .reverse(),
    size,
    updatedAt:
      typeof record.updatedAt === 'string' ? record.updatedAt : undefined,
  };
}

/**
 * @param {unknown} value - a dev.pdsjs.oci.repo record value
 * @param {string} name
 * @returns {LinkedPackage}
 */
export function ociImage(value, name) {
  const record =
    /** @type {{tags?: unknown, manifests?: unknown, layers?: unknown, updatedAt?: unknown}} */ (
      value ?? {}
    );
  const tags = /** @type {Array<{tag?: string, digest?: string}>} */ (
    Array.isArray(record.tags) ? record.tags : []
  );
  const manifests =
    /** @type {Array<{digest?: string, blob?: {size?: number}}>} */ (
      Array.isArray(record.manifests) ? record.manifests : []
    );
  const layers = /** @type {Array<{blob?: {size?: number}}>} */ (
    Array.isArray(record.layers) ? record.layers : []
  );

  // Two tags on one manifest share its layers, so the size is what the
  // account stores rather than the sum of the tags.
  let size = 0;
  for (const entry of [...manifests, ...layers]) size += entry.blob?.size ?? 0;
  const latest = tags.find((tag) => tag.tag === 'latest')?.tag ?? tags[0]?.tag;

  return {
    registry: 'oci',
    name,
    label: 'container image',
    install: `docker pull ${ociPullRef(name)}${latest ? `:${latest}` : ''}`,
    latest: latest ?? '',
    // An image carries no version number, so its tags take that place. The
    // size beside one is its manifest's, since the layers are shared.
    releases: tags.map((tag) => ({
      name: tag.tag ?? '',
      size:
        manifests.find((manifest) => manifest.digest === tag.digest)?.blob
          ?.size ?? 0,
      digest: tag.digest,
    })),
    size,
    updatedAt:
      typeof record.updatedAt === 'string' ? record.updatedAt : undefined,
  };
}

/**
 * One record value from this account's PDS, or null where there is none.
 * @param {string} collection
 * @param {string} rkey
 * @returns {Promise<unknown|null>}
 */
async function readRecord(collection, rkey) {
  const params = new URLSearchParams({ repo: account.did, collection, rkey });
  const res = await fetch(
    `${account.pds}/xrpc/com.atproto.repo.getRecord?${params}`,
  );
  if (!res.ok) return null;
  return /** @type {{value?: unknown}} */ (await res.json()).value ?? null;
}

/**
 * @param {{registry: string, name: string}} entry
 * @returns {Promise<LinkedPackage|null>}
 */
async function readPackage(entry) {
  if (entry.registry === 'npm') {
    const value = await readRecord(NPM_PACKAGE_COLLECTION, npmRkey(entry.name));
    return value ? npmPackage(value, entry.name) : null;
  }
  const value = await readRecord(OCI_REPO_COLLECTION, ociRkey(entry.name));
  return value ? ociImage(value, entry.name) : null;
}

/**
 * Every package a repository links, in the order the config names them. An
 * entry whose record is gone is dropped rather than shown empty.
 * @param {string} repo - what the repository is called
 * @returns {Promise<LinkedPackage[]>}
 */
export async function loadPackages(repo) {
  try {
    const linked = linkedPackagesOf(await repoConfig(repo));
    const found = await Promise.all(linked.map(readPackage));
    return /** @type {LinkedPackage[]} */ (found.filter(Boolean));
  } catch {
    return [];
  }
}