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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1097 lines · 36.3 KB Raw History
// Reaching the repositories, from a page that knows only its own origin.
//
// Two layers feed the app. The repository list and each repository's refs
// come from the server's dev.pdsjs.git.listRepos. Trees, file bodies,
// history and diffs come from the packfiles inside the bundle blobs, which
// only a pack reader can open. A server that serves the dev.pdsjs.git reads
// opens them from its own storage and answers each screen in one request;
// the pack reader in this page covers a server that does not, and a
// repository the server will not open whole.

import { createGitBrowser } from '@pdsjs/git/browser';
import { GitClient, XrpcError } from '@pdsjs/git/client';
import { repoNameOf, rkeyOf } from '@pdsjs/git/record';
import { GIT_CONFIG_COLLECTION } from '@pdsjs/git/rules';
import { authedFetch, currentSession } from '#/lib/oauth.js';

/** Filled by discover() before any query runs. */
export const account = { did: '', pds: '', handle: '' };

/**
 * The repository records by name, and by space for the repositories held in
 * one. A private repository and a public one may share a name, so the key is
 * the space URI when there is one.
 */
const records = new Map();

/**
 * The same rows by record key, for the pack reader: it addresses a
 * repository by the key its record is at, where every screen addresses one
 * by name.
 * @type {Map<string, {uri: string, cid: string, value: Record<string, unknown>}>}
 */
const byRkey = new Map();

/** @param {string} name @param {string} [space] */
const keyOf = (name, space) => (space ? `${space}#${name}` : name);

/**
 * One in-page reader per repository. Each holds that repository's parsed
 * object store, so returning to a repository already opened costs nothing.
 * createGitBrowser caches a single repository, which is why this is a map of
 * readers rather than one reader for all of them.
 */
const readers = new Map();

/** Whether the repository rows in hand came from the server this visit. */
let rowsAreFresh = false;

/**
 * The record cid a read names, so the server's answer caches immutably. A
 * row from the last visit's cache may be behind the server, so only a fresh
 * row names one.
 * @param {string} name
 * @param {string} [space]
 * @returns {string|undefined}
 */
const cidOf = (name, space) =>
  rowsAreFresh
    ? /** @type {{cid?: string}} */ (records.get(keyOf(name, space)))?.cid
    : undefined;

/** @type {GitClient|null} */
let client = null;

/**
 * The decision in flight or made: the base the dev.pdsjs.git.* methods are
 * answered from, this page's own origin when something there serves them (a
 * git appview, or the PDS itself when it hosts the page), the account's PDS
 * otherwise. Records and blobs stay on the PDS either way.
 */
let decision = null;

/**
 * Ask each candidate for one cheap anonymous read. The first that answers
 * is the git service for this visit; the decision is made once and shared
 * by every caller, including the ones that arrive while the probes run.
 * @returns {Promise<string>}
 */
function decideGitService() {
  decision ??= (async () => {
    for (const base of [...new Set([location.origin, account.pds])]) {
      try {
        const res = await fetch(`${base}/xrpc/dev.pdsjs.git.listRepos`);
        // A host with a single-page fallback answers any path with the app's
        // own HTML and a 200, so the answer has to be JSON to count.
        if (
          res.ok &&
          (res.headers.get('content-type') ?? '').includes('application/json')
        ) {
          return base;
        }
      } catch {
        // A base that answers nothing is not the git service.
      }
    }
    return account.pds;
  })();
  return decision;
}

/** The account's git service, as a client of its dev.pdsjs.git methods. */
export async function serverClient() {
  const service = await decideGitService();
  if (!client || client.service !== service) {
    client = new GitClient({ service });
  }
  return client;
}

/** The one authenticated client, over the account's own PDS. */
let owner = null;

/**
 * The account's PDS as a client of its dev.pdsjs.git methods, authenticated
 * as the signed-in account. The reads of a repository in a space must go
 * here: the space gate answers a bearer token naming the account, and the
 * PDS alone holds the space's storage.
 * @returns {import('@pdsjs/git/client').GitClient}
 */
export function ownerClient() {
  owner ??= new GitClient({
    service: account.pds,
    fetch: (input, init) => {
      const session = currentSession();
      if (!session) throw new Error('Sign in to read this repository.');
      return authedFetch(session, String(input), init);
    },
  });
  return owner;
}

/** Repositories the server refused to open whole. This page reads them. */
const localOnly = new Set();

/**
 * Where the last read went: the server, or this page. Null before the
 * first read decides. Progress listeners hear a change, since the wait a
 * page shows depends on it.
 * @type {'server'|'page'|null}
 */
let source = null;

export function readSource() {
  return source;
}

/** @param {string} name */
async function viaServer(name) {
  const server =
    !localOnly.has(name) && (await (await serverClient()).servesReads());
  const next = server ? 'server' : 'page';
  if (next !== source) {
    source = next;
    notifyProgress();
  }
  return server;
}

/**
 * Serve the git reader's XRPC calls. Record reads come from the query result
 * already in memory, and blob reads prefer this origin, where the site
 * handler serves any blob in the repo immutably.
 * @param {Request} request
 * @returns {Promise<Response>}
 */
async function xrpc(request) {
  const url = new URL(request.url);

  if (url.pathname === '/xrpc/com.atproto.repo.getRecord') {
    const row = byRkey.get(url.searchParams.get('rkey') ?? '');
    if (!row) return new Response('Not found', { status: 404 });
    // The listing summarizes a record; the pack reader needs the bundle
    // manifest, which only the record carries.
    if (!Array.isArray(row.value?.bundles)) {
      return fetch(`${account.pds}${url.pathname}${url.search}`);
    }
    return Response.json({ uri: row.uri, cid: row.cid, value: row.value });
  }

  if (url.pathname === '/xrpc/com.atproto.sync.getBlob') {
    const range = request.headers.get('range');
    const headers = range ? { range } : undefined;
    const local = await fetch(`/.blobs/${url.searchParams.get('cid')}`, {
      headers,
    });
    // A deployment serving the app from somewhere other than the account's
    // own site has no /.blobs route, so the PDS answers instead.
    if (local.ok) return local;
    try {
      return await fetch(`${account.pds}${url.pathname}${url.search}`, {
        headers,
      });
    } catch (err) {
      // A PDS whose CORS allowance predates the Range header rejects the
      // preflight. Asking again without the header returns the whole blob,
      // which the reader slices itself.
      if (!range) throw err;
      return fetch(`${account.pds}${url.pathname}${url.search}`);
    }
  }

  return fetch(`${account.pds}${url.pathname}${url.search}`);
}

/**
 * The directory this build resolves a did:plc against. A local stack runs a
 * PLC of its own, holding DIDs the public directory has never seen, and
 * `VITE_PLC_URL` at build time is what points the page at it.
 */
const PLC_URL = String(
  import.meta.env?.VITE_PLC_URL || 'https://plc.directory',
).replace(/\/$/, '');

/** @param {string} did */
export function didDocumentUrl(did) {
  if (did.startsWith('did:plc:')) return `${PLC_URL}/${did}`;
  if (did.startsWith('did:web:')) {
    return `https://${did.slice('did:web:'.length)}/.well-known/did.json`;
  }
  throw new Error(`Unsupported DID method: ${did}`);
}

/**
 * Where a visit remembers the account behind this hostname, so the next one
 * skips the directory round trip. The entry carries what an earlier visit
 * read from this hostname's own well-known route and the directory, and
 * every visit re-resolves behind the first paint, so a moved PDS corrects
 * itself.
 */
const DISCOVERY_KEY = 'git-ui:discovery';

/** Resolve the account fresh: well-known, then the DID document. */
async function resolveAccount() {
  const res = await fetch('/.well-known/atproto-did');
  if (!res.ok) throw new Error('This hostname serves no atproto account.');
  const did = (await res.text()).trim();

  const doc = await (await fetch(didDocumentUrl(did))).json();
  const service = (doc.service || []).find(
    (/** @type {{id: string, serviceEndpoint?: string}} */ entry) =>
      entry.id.endsWith('#atproto_pds'),
  );
  if (!service) throw new Error('The DID document names no PDS.');

  const aka = (doc.alsoKnownAs || []).find((/** @type {string} */ name) =>
    name.startsWith('at://'),
  );
  return {
    did,
    pds: String(service.serviceEndpoint).replace(/\/$/, ''),
    handle: aka ? aka.slice('at://'.length) : did,
  };
}

function readDiscoveryCache() {
  try {
    const row = JSON.parse(localStorage.getItem(DISCOVERY_KEY) || 'null');
    if (
      row &&
      row.host === location.hostname &&
      row.did &&
      row.pds &&
      row.handle
    ) {
      return row;
    }
  } catch {
    // Blocked or malformed storage reads as no cache.
  }
  return null;
}

function writeDiscoveryCache() {
  try {
    localStorage.setItem(
      DISCOVERY_KEY,
      JSON.stringify({ host: location.hostname, ...account }),
    );
  } catch {
    // A page that cannot write storage just resolves fresh next time.
  }
}

/** Learn whose repo this is from the hostname, then where its PDS is. */
export async function discover() {
  const cached = readDiscoveryCache();
  if (cached) {
    account.did = cached.did;
    account.pds = cached.pds;
    account.handle = cached.handle;
    // Re-resolve behind the paint. Requests already in flight keep the
    // cached addresses; anything after the update reads the fresh ones.
    resolveAccount()
      .then((fresh) => {
        Object.assign(account, fresh);
        writeDiscoveryCache();
      })
      .catch(() => {});
    return account;
  }
  Object.assign(account, await resolveAccount());
  writeDiscoveryCache();
  return account;
}

// ---- what the PDS serves beyond record reads ----

/**
 * Two things the account's own server may or may not do. Filled by
 * loadCapabilities() before any screen renders.
 */
export const capabilities = { httpClone: false, rawFiles: false };

/**
 * The origin the git HTTP surface answered from. The PDS's own until a
 * probe learns better, which is what loadCapabilities() runs.
 */
let gitHttpBase = '';

/** The install settings, which name a forced answer as 'on' or 'off'. */
const FORCED = { on: true, off: false };

/**
 * Ask for one URL and say whether the answer is the content type that means
 * the feature is on. An unknown repository reads the same as a switched-off
 * endpoint, so the caller passes a repository that exists. A server that
 * withholds the open origin rejects the request, which reads as off.
 * @param {string} url
 * @param {string} type
 * @returns {Promise<boolean>}
 */
async function serves(url, type) {
  try {
    const res = await fetch(url);
    return res.ok && (res.headers.get('content-type') || '').includes(type);
  } catch {
    return false;
  }
}

/**
 * Settings chosen at install, from the public install record naming this
 * site. The developer's own instance has no install record, so the probe
 * stands alone there.
 * @returns {Promise<Record<string, unknown>>}
 */
async function installConfig() {
  try {
    const params = new URLSearchParams({
      repo: account.did,
      collection: 'dev.pdsjs.app.install',
      limit: '100',
    });
    const res = await fetch(
      `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
    );
    if (!res.ok) return {};
    const site = location.hostname.split('.')[0];
    const rows = (await res.json()).records || [];
    const mine = rows.find(
      (/** @type {{value?: {site?: string, config?: unknown}}} */ row) =>
        row.value?.site === site,
    );
    return mine?.value?.config ?? {};
  } catch {
    return {};
  }
}

/**
 * Decide both capabilities: the setting when it forces one, the probe
 * otherwise. Call it after loadRepos(), which is what puts a repository name
 * in hand for the probe.
 *
 * The git HTTP surface may live beside this page (a git appview serving the
 * app from its own origin) or on the PDS (embedded hosting). Whichever
 * answers the advertisement first is the base every clone and raw URL
 * builds on.
 */
export async function loadCapabilities() {
  const config = await installConfig();
  // The probe names a repository for the server to open. A repository in a
  // space is keyed by space and name, and the HTTP surface does not serve
  // one, so a public repository is the one the probe tries.
  const [name] = [...records.keys()].find((key) => !key.includes('#')) ?? [];

  /**
   * @param {string} key - the setting that forces this answer
   * @param {string} path - what to ask the server for
   * @param {string} type - the content type that means the feature is on
   * @param {string[]} bases - the origins to ask, most likely first
   * @returns {Promise<boolean>}
   */
  const decide = async (key, path, type, bases) => {
    const forced =
      FORCED[/** @type {keyof typeof FORCED} */ (String(config[key]))];
    if (forced !== undefined) return forced;
    for (const base of bases) {
      if (
        name &&
        (await serves(`${base}/git/${account.did}/${name}${path}`, type))
      ) {
        gitHttpBase = base;
        return true;
      }
    }
    return false;
  };

  const bases = [...new Set([location.origin, account.pds].filter(Boolean))];

  const [httpClone, rawFiles] = await Promise.all([
    decide(
      'httpClone',
      '/info/refs?service=git-upload-pack',
      'git-upload-pack-advertisement',
      bases,
    ),
    decide('rawFiles', '/raw', 'text/plain', bases),
  ]);
  capabilities.httpClone = httpClone;
  capabilities.rawFiles = rawFiles;
  return capabilities;
}

/** Where a visit remembers the repository rows the last one listed. */
const REPOS_KEY = 'git-ui:repos';

/**
 * @param {Array<{uri: string, cid: string, value: Record<string, unknown>}>} rows
 * @param {boolean} fresh - whether the rows came from the server this visit
 */
function fillRecords(rows, fresh) {
  records.clear();
  byRkey.clear();
  for (const row of rows) {
    const rkey = rkeyOf(row.uri);
    records.set(
      keyOf(repoNameOf(row.value, rkey), String(row.value?.space ?? '')),
      row,
    );
    byRkey.set(rkey, row);
  }
  rowsAreFresh = fresh;
}

/** @param {Array<{uri: string, value: Record<string, unknown>}>} rows */
const toList = (rows) =>
  rows.map((row) => ({
    name: repoNameOf(row.value, rkeyOf(row.uri)),
    ...row.value,
  }));

/**
 * Start from what the last visit stored: the account and the repository
 * rows. Screens render from these at once, real content rather than
 * placeholders, while the fresh reads run behind the paint.
 * @returns {Array<Record<string, unknown>>|null} the repository list, or
 *   null when either half is missing
 */
export function primeFromCache() {
  const cached = readDiscoveryCache();
  if (!cached) return null;
  /** @type {{host: string, rows: Array<{uri: string, cid: string, value: Record<string, unknown>}>}|null} */
  let stored = null;
  try {
    stored = JSON.parse(localStorage.getItem(REPOS_KEY) || 'null');
  } catch {
    return null;
  }
  if (
    !stored ||
    stored.host !== location.hostname ||
    !Array.isArray(stored.rows)
  ) {
    return null;
  }
  account.did = cached.did;
  account.pds = cached.pds;
  account.handle = cached.handle;
  fillRecords(stored.rows, false);
  return toList(stored.rows);
}

/**
 * The repository list, newest push first, as the server lists it. A row
 * holds what every screen reads of a repository: its name, refs, default
 * branch, description, size and record version. The signed-in owner's list
 * comes from their own PDS, which adds the repositories in spaces; anyone
 * else sees the public ones, wherever the git service answers.
 */
export async function loadRepos() {
  const mine = currentSession()?.did === account.did;
  /** @type {import('@pdsjs/git/client').RepoView[]} */
  let repos;
  try {
    const client = mine ? ownerClient() : await serverClient();
    ({ repos } = await client.listRepos());
  } catch (err) {
    if (!mine) throw err;
    // An owner whose session has ended reads the public list rather than
    // holding the whole app on the account gate. The session provider clears
    // the session and the list refetches as a guest.
    const { repos: publicRepos } = await (await serverClient()).listRepos();
    repos = publicRepos;
  }
  const rows = repos.map((repo) => ({
    uri: repo.uri,
    cid: repo.cid,
    value: repo,
  }));
  fillRecords(rows, true);
  // The owner's rows name their private repositories, so they are not left
  // for the next visit to read before its own list lands; a guest would see
  // names their session never asked for. The public rows cache for everyone.
  if (!mine) {
    try {
      localStorage.setItem(
        REPOS_KEY,
        JSON.stringify({ host: location.hostname, rows }),
      );
    } catch {
      // A page that cannot write storage just lists fresh next time.
    }
  }
  return toList(rows);
}

/**
 * Each public repository's summary, keyed by name: its open pull request and
 * issue counts, and the commit dates its activity gauge draws. The server
 * reads them, once for the listing rather than by each card. A repository in
 * a space keeps its statements and history there, so it is absent from the
 * map.
 * @returns {Promise<Map<string, {openPulls: number, openIssues: number, activity: number[]}>>}
 */
export async function loadRepoSummary() {
  const { repos } = await (await serverClient()).listRepos({ summary: true });
  const summary = new Map();
  for (const repo of repos) {
    if (repo.space || typeof repo.openPulls !== 'number') continue;
    summary.set(repo.name, {
      openPulls: repo.openPulls,
      openIssues: repo.openIssues ?? 0,
      activity: repo.activity ?? [],
    });
  }
  return summary;
}

/**
 * @param {string} name
 * @param {string} [space] - the space holding a private repository
 */
export function repoRecord(name, space) {
  return records.get(keyOf(name, space || spaceOf(name)))?.value ?? null;
}

/**
 * The space holding a repository of this name, or '' for one in the
 * account's own repo. A name is looked up in the listing rather than
 * carried on the address, so a private repository opens at the same address
 * a public one does. Where both exist under one name the public one wins,
 * which a caller naming the space directly gets past.
 * @param {string} name
 * @returns {string}
 */
export function spaceOf(name) {
  if (records.has(name)) return '';
  const suffix = `#${name}`;
  for (const key of records.keys()) {
    if (key.endsWith(suffix)) return key.slice(0, -suffix.length);
  }
  return '';
}

/**
 * The repository row whole: the record value with its address and version.
 * What a writer needs, where repoRecord serves the screens: a review or an
 * offer names the exact record version it was written against.
 * @param {string} name
 * @returns {{uri: string, cid: string, value: Record<string, unknown>}|null}
 */
export function repoRow(name) {
  return records.get(name) ?? null;
}

/**
 * The record key of the repository one name addresses. A screen addresses a
 * repository by name; the pack reader and the config record address one by
 * the key its record is at.
 * @param {string} name
 * @param {string} [space]
 * @returns {string}
 */
export function repoKeyOf(name, space) {
  const row = records.get(keyOf(name, space || spaceOf(name)));
  return row ? rkeyOf(row.uri) : name;
}

/**
 * The list the records map already holds, from the cache or an earlier
 * load. Empty means nothing is in hand, not an account with no
 * repositories; the live answer tells those apart.
 */
export function repoList() {
  return [...records.entries()].map(([key, row]) => ({
    name: key.split('#').pop(),
    .../** @type {{value: Record<string, unknown>}} */ (row).value,
  }));
}

/**
 * The query key of the repository list. The owner's listing adds the
 * repositories in spaces, so the key changes with the viewer: signing in or
 * out refetches it, rather than holding a list from the other side of the
 * session.
 * @returns {string}
 */
export function reposKey() {
  return `repos-${currentSession()?.did === account.did ? 'owner' : 'guest'}`;
}

/**
 * One config record per repository, kept once it is read. Two screens want
 * it, the checks badge and the package list, and it changes only when its
 * owner edits the settings.
 * @type {Map<string, Promise<unknown|null>>}
 */
const configs = new Map();

/**
 * The dev.pdsjs.git.config record beside a repository, or null where the
 * repository has none. A repository with no config has no rules, no runner
 * and no packages, which is the same answer an unreachable PDS gives.
 * @param {string} name - what the repository is called
 * @returns {Promise<unknown|null>}
 */
export function repoConfig(name) {
  let pending = configs.get(name);
  if (!pending) {
    pending = (async () => {
      try {
        const params = new URLSearchParams({
          repo: account.did,
          collection: GIT_CONFIG_COLLECTION,
          // The config sits at the repository's own key, beside the record.
          rkey: repoKeyOf(name),
        });
        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
        );
      } catch {
        return null;
      }
    })();
    configs.set(name, pending);
  }
  return pending;
}

/**
 * Drop the config record in hand, so the next read asks the server. The
 * settings write changes it, and the tabs read it to decide which sections
 * a repository has.
 * @param {string} name - what the repository is called
 */
export function forgetConfig(name) {
  configs.delete(name);
}

/**
 * The commit that last changed one path. The server walks the bundle chain
 * from its own storage and answers in one request; the same walk in this
 * page reads the repository's commits and trees over the network, which on
 * a repository of a few thousand commits is megabytes.
 * @param {string} repo
 * @param {string} refName
 * @param {string} path
 * @param {string} [space]
 */
export function lastCommitOf(repo, refName, path, space) {
  return readerFor(repo, undefined, space).lastCommit(
    account.did,
    repo,
    refName,
    path,
  );
}

/**
 * @typedef {Object} CommitLabel
 * @property {string} subject - the message's first line
 * @property {string} author - the commit's author ident
 */

/**
 * What to call each commit reachable from the named refs, by sha. A run names
 * the commit it checked by sha alone, and a sha names nothing to a reader.
 *
 * This opens the repository, which the checks page otherwise never needs. The
 * caller runs it behind the paint and labels its rows when it answers.
 * @param {string} name - what the repository is called
 * @param {string[]} refs - full ref names, e.g. refs/heads/main
 * @param {number} [limit] - commits to walk per ref
 * @param {string} [space]
 * @returns {Promise<Map<string, CommitLabel>>}
 */
export async function commitLabels(name, refs, limit = 100, space) {
  const reader = readerFor(name, undefined, space);
  /** @type {Map<string, CommitLabel>} */
  const labels = new Map();
  for (const ref of refs) {
    try {
      const listing = await reader.listCommits(account.did, name, ref, limit);
      for (const commit of listing?.commits ?? []) {
        if (labels.has(commit.sha)) continue;
        labels.set(commit.sha, {
          subject: String(commit.message).split('\n')[0],
          author: commit.author,
        });
      }
    } catch {
      // A ref the repository no longer holds labels nothing, and the rows
      // that wanted it read as their sha.
    }
  }
  return labels;
}

/**
 * How much of the repository being opened has arrived. One repository opens
 * at a time, so a single value carries it.
 */
let progress = { loaded: 0, total: 0 };
/** @type {Set<() => void>} */
const progressListeners = new Set();

function notifyProgress() {
  for (const listener of progressListeners) listener();
}

export function repoProgress() {
  return progress;
}

/** @param {() => void} listener */
export function onRepoProgress(listener) {
  progressListeners.add(listener);
  return () => progressListeners.delete(listener);
}

/** @param {string} name */
function localReader(name) {
  let reader = readers.get(name);
  if (!reader) {
    reader = createGitBrowser({
      xrpc,
      onProgress(next) {
        progress = next;
        notifyProgress();
      },
    });
    readers.set(name, reader);
  }
  return reader;
}

/**
 * One read, on the server where it serves the reads, in this page
 * otherwise. A server that refuses the repository as too large to open
 * whole sends this repository's reads here from then on. A server that no
 * longer holds the record version the read named is asked once more
 * without it. A repository in a space is read only from the account's own
 * PDS, signed in as the owner.
 * @template T
 * @param {string} name - what the repository is called
 * @param {string|undefined} space
 * @param {(client: GitClient, cid: string|undefined) => Promise<T>} remote
 * @param {(reader: ReturnType<typeof createGitBrowser>) => Promise<T>} local
 * @returns {Promise<T>}
 */
async function read(name, space, remote, local) {
  if (space) return remote(ownerClient(), undefined);
  if (await viaServer(name)) {
    const cid = cidOf(name);
    try {
      return await remote(await serverClient(), cid);
    } catch (err) {
      if (!(err instanceof XrpcError)) throw err;
      if (err.error === 'RepoNotFound' && cid) {
        rowsAreFresh = false;
        return remote(await serverClient(), undefined);
      }
      if (err.error !== 'RepositoryTooLarge') throw err;
      localOnly.add(name);
    }
  }
  return local(localReader(name));
}

/**
 * The bytes of one file at a ref, from the server.
 * @param {GitClient} client
 * @param {{repo: string, ref?: string, path: string, cid?: string}} params
 */
async function rawFrom(client, params) {
  const res = await client.getRaw(params);
  const bytes = new Uint8Array(await res.arrayBuffer());
  return {
    ref: params.ref ?? '',
    path: params.path,
    size: bytes.length,
    mediaType: res.headers.get('content-type') ?? 'application/octet-stream',
    bytes,
  };
}

/**
 * A reader of one repository with the in-page reader's methods, each
 * answered by the server or by that reader.
 *
 * With `from`, the reader is of another account's copy of the repository,
 * and every call goes to this server, which reads the copy from the server
 * that holds it. Nothing of a copy is in this page. With `space`, the
 * repository is private, and every call goes to the account's own PDS as
 * the owner.
 * @param {string} name - what the repository is called
 * @param {string} [from] - the DID whose copy to read
 * @param {string} [space] - the space holding a private repository
 */
export function readerFor(name, from, space) {
  /**
   * A read, the way `read` makes one, or on the server alone for a copy or
   * a repository in a space.
   * @template T
   * @param {(client: GitClient, cid: string|undefined) => Promise<T>} remote
   * @param {(reader: ReturnType<typeof createGitBrowser>) => Promise<T>} local
   */
  const via = async (remote, local) => {
    if (space) return remote(ownerClient(), undefined);
    if (from) return remote(await serverClient(), undefined);
    return read(name, space, remote, local);
  };
  /**
   * The parameters of a call, naming the copy where one is read.
   * @template {object} P
   * @param {P} params
   * @returns {P & {from?: string, space?: string}}
   */
  const p = (params) => ({
    ...params,
    ...(from ? { from } : {}),
    ...(space ? { space } : {}),
  });
  // The methods take a name, because that is what a screen holds. The
  // in-page reader reads the record itself, so it takes the key.
  const key = repoKeyOf(name, space);

  /**
   * One commit or one range as the server answers it, with the bytes of an
   * image on either side, which the server names and this fetches.
   * @param {GitClient} client
   * @param {string|undefined} cid
   * @param {string} repo
   * @param {string} sha - one commit, or base..tip
   */
  const commitFrom = async (client, cid, repo, sha) => {
    const commit = await client.getCommit(p({ repo, sha, cid }));
    // The server's diff names an image on either side; the bytes come
    // from the commit and its first parent.
    const files = await Promise.all(
      commit.files.map(async (file) => {
        if (!file.mediaType) return file;
        const side = (/** @type {string|undefined} */ at) =>
          at
            ? rawFrom(client, p({ repo, ref: at, path: file.path, cid }))
                .then((image) => image.bytes)
                .catch(() => null)
            : Promise.resolve(null);
        const [oldImage, newImage] = await Promise.all([
          side(file.status === 'added' ? undefined : commit.parents[0]),
          side(file.status === 'deleted' ? undefined : commit.sha),
        ]);
        return { ...file, oldImage, newImage };
      }),
    );
    return { ...commit, files };
  };

  return {
    /**
     * @param {string} did @param {string} repo @param {string} [ref]
     * @param {string} [path]
     */
    listTree: (did, repo, ref, path) =>
      via(
        (client, cid) =>
          client.getTree(p({ repo, ref, path: path ?? '', cid })),
        (reader) => reader.listTree(did, key, ref, path),
      ),
    /**
     * @param {string} did @param {string} repo @param {string} [ref]
     * @param {number} [limit]
     * @param {string} [path] - a path, for the commits that changed it
     */
    listCommits: (did, repo, ref, limit, path) =>
      via(
        (client, cid) =>
          client.listCommits(
            p({ repo, ref, limit, cid, ...(path ? { path } : {}) }),
          ),
        (reader) => reader.listCommits(did, key, ref, limit, undefined, path),
      ),
    /**
     * The commits the tip reaches that the base does not.
     * @param {string} did @param {string} repo @param {string|null} base
     * @param {string} tip @param {number} [limit]
     */
    listRange: (did, repo, base, tip, limit) =>
      via(
        (client, cid) =>
          client.listCommits(
            p({ repo, range: `${base ?? ''}..${tip}`, limit, cid }),
          ),
        (reader) => reader.listRange(did, key, base, tip, limit),
      ),
    /** @param {string} did @param {string} repo @param {string} sha */
    readCommit: (did, repo, sha) =>
      via(
        (client, cid) => commitFrom(client, cid, repo, sha),
        (reader) => reader.readCommit(did, key, sha),
      ),
    /**
     * The changes across a range as one diff.
     * @param {string} did @param {string} repo @param {string} base
     * @param {string} tip
     */
    readRange: (did, repo, base, tip) =>
      via(
        (client, cid) => commitFrom(client, cid, repo, `${base}..${tip}`),
        // A range has a base and no parents; the page reads either shape.
        (reader) =>
          /** @type {Promise<Awaited<ReturnType<typeof commitFrom>>|null>} */ (
            /** @type {unknown} */ (reader.readRange(did, key, base, tip))
          ),
      ),
    /**
     * @param {string} did @param {string} repo @param {string} [ref]
     * @param {string} [path]
     */
    readFile: (did, repo, ref, path) =>
      via(
        (client, cid) =>
          client.getFile(p({ repo, ref, path: path ?? '', cid })),
        (reader) => reader.readFile(did, key, ref, path),
      ),
    /**
     * @param {string} did @param {string} repo @param {string} [ref]
     * @param {string} [path]
     */
    readImage: (did, repo, ref, path) =>
      via(
        (client, cid) =>
          rawFrom(client, p({ repo, ref, path: path ?? '', cid })),
        (reader) => reader.readImage(did, key, ref, path),
      ),
    /** @param {string} did @param {string} repo @param {string} [ref] */
    listFiles: (did, repo, ref) =>
      via(
        (client, cid) => client.listFiles(p({ repo, ref, cid })),
        (reader) => reader.listFiles(did, key, ref),
      ),
    /**
     * @param {string} did @param {string} repo @param {string} [ref]
     * @param {string} [path]
     */
    lastCommit: (did, repo, ref, path) =>
      via(
        async (client, cid) =>
          (await client.getLastCommit(p({ repo, ref, path, cid }))).commit,
        (reader) => reader.lastCommit(did, key, ref, path),
      ),
  };
}

/**
 * One commit, or as base..tip the changes across a range, from whichever
 * copy holds it: this account's, or another's read through this server.
 * What a commit page shows, and what its route warms.
 * @param {string} repo - what the repository is called
 * @param {string} sha - one commit, or base..tip
 * @param {string} [from] - the DID whose copy holds it, empty for this account
 * @param {string} [space] - the space holding a private repository
 */
export function readChange(repo, sha, from, space) {
  const copy = from && from !== account.did ? from : undefined;
  const reader = readerFor(repo, copy, space);
  const did = copy ?? account.did;
  const [base, tip] = sha.includes('..') ? sha.split('..') : ['', sha];
  return base
    ? reader.readRange(did, repo, base, tip)
    : reader.readCommit(did, repo, tip);
}

// ---- reading the record ----

/**
 * The repository record, as every screen reads it. The fields are optional
 * because a record may be read before the account's own list has landed.
 * @typedef {Object} RepoRecord
 * @property {string} [name]
 * @property {string} [description]
 * @property {number} [size] - every bundle part's bytes, as the listing sums them
 * @property {string} [defaultBranch]
 * @property {{name: string, sha?: string}[]} [refs]
 * @property {{parts?: {size?: number}[]}[]} [bundles]
 */

/** @param {string} name */
export const shortRef = (name) => name.replace(/^refs\/(heads|tags)\//, '');

/** @param {RepoRecord|null|undefined} repo */
export const branchRefs = (repo) =>
  (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/heads/'));

/** @param {RepoRecord|null|undefined} repo */
export const tagRefs = (repo) =>
  (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/tags/'));

/** @param {RepoRecord|null|undefined} repo */
export const defaultRef = (repo) => repo?.defaultBranch || 'main';

/** @param {RepoRecord|null|undefined} repo */
export const isEmpty = (repo) => (repo?.refs || []).length === 0;

/**
 * Every bundle chunk is a blob ref, so the size needs no download.
 * @param {RepoRecord|null|undefined} repo
 */
export function repoSize(repo) {
  if (typeof repo?.size === 'number') return repo.size;
  let total = 0;
  for (const bundle of Array.isArray(repo?.bundles) ? repo.bundles : []) {
    for (const part of bundle.parts || []) total += part.size || 0;
  }
  return total;
}

/**
 * Split a URL tail into its ref and its path. Both may hold slashes, so the
 * only reliable split is against the repository's own ref names, longest
 * first. The refs are in hand from the query, which is what makes this work
 * without a round trip.
 * @param {RepoRecord|null|undefined} repo
 * @param {string} tail
 */
export function splitRefPath(repo, tail) {
  const names = (repo?.refs || [])
    .map((ref) => shortRef(ref.name))
    .sort((a, b) => b.length - a.length);
  for (const name of names) {
    if (tail === name) return { ref: name, path: '' };
    if (tail.startsWith(`${name}/`)) {
      return { ref: name, path: tail.slice(name.length + 1) };
    }
  }
  return { ref: defaultRef(repo), path: tail };
}

/**
 * The URL that serves a file's bytes, for a reader that wants the source
 * rather than the page around it. The PDS answers this route; the site does
 * too where the two share a worker, but the PDS host is the one that always
 * holds.
 * @param {string} repo
 * @param {string} ref
 * @param {string} path
 */
/**
 * The URL that serves a file's bytes, for a reader that wants the source
 * rather than the page around it. The git HTTP surface is whichever origin
 * answered the capability probe: the appview that serves this page, or the
 * PDS.
 * @param {string} repo
 * @param {string} ref
 * @param {string} path
 */
export function rawUrl(repo, ref, path) {
  const at = `${encodeURIComponent(account.handle)}/${encodeURIComponent(repo)}`;
  const base = gitHttpBase || account.pds;
  return `${base}/git/${at}/raw/${encodeURI(ref)}/${encodeURI(path)}`;
}