/** * The OCI registry as a server extension: the platform passes the factory * to core's `extensions` option and core mounts the registry under /v2/ on * its own fetch, and the images listing the account page reads under * /account/api. The registry reads through the server and pushes straight * to the ports, the way createRegistry is built by hand. */ import { cborDecode, toDisplayValue } from '@pdsjs/core/repo'; import { createRegistry } from './http.js'; import { OCI_REPO_COLLECTION } from './lexicon.js'; /** * @typedef {Object} RepoSummary * @property {string} name * @property {string} uri * @property {Array<{tag: string, digest: string}>} tags * @property {number} manifests * @property {number} layers * @property {number} size - every manifest and layer, in bytes * @property {string} pullRef - the reference a client pulls by * @property {string|null} createdAt * @property {string|null} updatedAt */ /** * One record summarized for the images page. The registry stores sizes on * the blob references, so the summary reads the record alone. * @param {{uri: string, value: Uint8Array|ArrayBuffer}} record * @param {string} host - the request host, which names the pull reference * @returns {RepoSummary} */ function summarizeRepo(record, host) { const value = /** @type {{name?: unknown, tags?: unknown, manifests?: unknown, layers?: unknown, createdAt?: unknown, updatedAt?: unknown}} */ ( toDisplayValue( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ), ) ); const name = typeof value.name === 'string' ? value.name : record.uri.split('/').pop() || ''; const tags = /** @type {Array<{tag: string, digest: string}>} */ ( /** @type {Array<{tag?: unknown, digest?: unknown}>} */ ( Array.isArray(value.tags) ? value.tags : [] ).filter((t) => typeof t.tag === 'string' && typeof t.digest === 'string') ); const manifests = /** @type {Array<{blob?: {size?: unknown}}>} */ ( Array.isArray(value.manifests) ? value.manifests : [] ); const layers = /** @type {Array<{blob?: {size?: unknown}}>} */ ( Array.isArray(value.layers) ? value.layers : [] ); let size = 0; for (const entry of [...manifests, ...layers]) { if (typeof entry.blob?.size === 'number') size += entry.blob.size; } return { name, uri: record.uri, tags, manifests: manifests.length, layers: layers.length, size, pullRef: `${host}/${name}`, createdAt: typeof value.createdAt === 'string' ? value.createdAt : null, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : null, }; } /** * @param {Object} [options] * @param {number} [options.maxBlobSize] - largest accepted layer, in bytes * @param {string} [options.collection] - record collection, for lexicon forks * @param {string} [options.realm] - realm named in the Basic challenge; defaults to the server hostname * @returns {import('@pdsjs/core/ports').ExtensionFactory} */ export function createOciExtension({ maxBlobSize, collection = OCI_REPO_COLLECTION, realm, } = {}) { return (host) => { const registry = createRegistry({ xrpc: host.xrpc, getDid: host.getDid, actorStorage: host.actorStorage, blobs: host.blobs, createCommit: host.createCommit, hasher: host.hasher, maxBlobSize, collection, realm: realm ?? host.hostname ?? 'registry', }); return { name: 'oci', collections: [collection], prefixes: ['/v2'], handle: (request) => registry.handle(request), features: { oci: true }, stats: (count) => ({ ociRepos: count(collection) }), routes: { // The images hosted in this account, for the account page. '/account/api/oci/repos': { method: 'GET', handler: (request, url) => host.accountApi(request, url, async () => { const page = await host.actorStorage.listRecords( collection, null, 100, ); return { enabled: true, host: url.host, repos: page.records.map((record) => summarizeRepo(record, url.host), ), }; }), }, }, }; }; }