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