chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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 [];
}
}