// 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 ` 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} */ 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} */ 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} */ 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 []; } }