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