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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
130 lines · 4.3 KB Raw History
/**
 * 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),
                ),
              };
            }),
        },
      },
    };
  };
}