chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Showing a commit author as an atproto identity, where one can be found.
//
// A commit names its author with a name and an address that the author's own
// git client wrote. Nothing signs either, so the page shows an atproto
// identity only for an author the hosting account has claimed in its
// dev.pdsjs.git.identity record. Every other author reads as the plain name
// the commit carries.
//
// A server that serves dev.pdsjs.git.getAuthors resolves the claims and the
// profiles behind them in one answer. Otherwise the page reads the record
// and asks the appview itself. Either way the answer is remembered for the
// next visit, so a commit's author has a face on the first frame.
import {
matchIdent,
parseIdentityRecord,
splitIdent,
} from '@pdsjs/git/authors';
import { account, serverClient } from '#/lib/git.js';
const APPVIEW = 'https://public.api.bsky.app';
const IDENTITY_COLLECTION = 'dev.pdsjs.git.identity';
/** The appview takes at most this many actors per call. */
const PROFILE_BATCH = 25;
/** An appview slower than this costs the faces, not the page. */
const PROFILE_TIMEOUT_MS = 4000;
/** Where a visit remembers the authors the last one resolved. */
const AUTHORS_KEY = 'git-ui:authors';
/**
* The claims the hosting account makes, about itself and about others.
* @type {Array<import('@pdsjs/git/authors').IdentClaim & {avatarUrl?: string}>}
*/
let claims = [];
/** Profiles by DID, for every DID the claims name plus the account itself. */
const profiles = new Map();
/**
* @param {string[]} dids
* @returns {Promise<void>} resolved once every profile the appview knows is
* in the map. A DID it does not know is simply absent.
*/
async function loadProfiles(dids) {
for (let at = 0; at < dids.length; at += PROFILE_BATCH) {
const params = new URLSearchParams();
for (const did of dids.slice(at, at + PROFILE_BATCH)) {
params.append('actors', did);
}
const body = await fetch(
`${APPVIEW}/xrpc/app.bsky.actor.getProfiles?${params}`,
{ signal: AbortSignal.timeout(PROFILE_TIMEOUT_MS) },
)
.then((res) => (res.ok ? res.json() : null))
.catch(() => null);
for (const profile of body?.profiles ?? []) {
profiles.set(profile.did, {
did: profile.did,
handle: profile.handle,
displayName: profile.displayName || '',
avatar: profile.avatar || '',
});
}
}
}
/**
* The claims and profiles as the server resolves them, where it serves
* getAuthors.
* @returns {Promise<boolean>} whether the server answered
*/
async function loadFromServer() {
const client = await serverClient();
if (!(await client.serves('getAuthors'))) return false;
const { owner, claims: served } = await client.getAuthors();
claims = served.map((claim) => ({
name: claim.name,
email: claim.email,
did: claim.did,
label: claim.label,
avatarCid: '',
avatarUrl: claim.avatar,
}));
profiles.clear();
if (owner) profiles.set(owner.did, owner);
for (const claim of served) {
if (claim.profile) profiles.set(claim.profile.did, claim.profile);
}
return true;
}
/** The claims from the record, and the profiles from the appview. */
async function loadFromRecord() {
const params = new URLSearchParams({
repo: account.did,
collection: IDENTITY_COLLECTION,
rkey: 'self',
});
const identity = await fetch(
`${account.pds}/xrpc/com.atproto.repo.getRecord?${params}`,
)
.then((res) => (res.ok ? res.json() : null))
.catch(() => null);
claims = identity ? parseIdentityRecord(identity.value) : [];
profiles.clear();
asked.clear();
const wanted = new Set([account.did]);
for (const claim of claims) if (claim.did) wanted.add(claim.did);
await loadProfiles([...wanted]);
}
/**
* Read the account's identity claims and the profile of every identity
* they name. Both are anonymous, and both are needed before any commit
* renders, so this runs once alongside the repository list.
*/
export async function loadAuthors() {
let served = false;
try {
served = await loadFromServer();
} catch {
// A server that lists the method but cannot answer it reads as one
// without it.
}
if (!served) await loadFromRecord();
remember();
return { claims: claims.length, profiles: profiles.size };
}
function remember() {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(
AUTHORS_KEY,
JSON.stringify({
host: location.hostname,
claims,
profiles: [...profiles],
}),
);
} catch {
// A page that cannot write storage just resolves fresh next time.
}
}
/**
* Start from the authors the last visit resolved, so a commit's author has
* a face on the first frame. loadAuthors() corrects them in place.
* @returns {boolean} whether anything was in hand
*/
export function primeAuthors() {
if (typeof localStorage === 'undefined') return false;
try {
const stored = JSON.parse(localStorage.getItem(AUTHORS_KEY) || 'null');
if (!stored || stored.host !== location.hostname) return false;
claims = Array.isArray(stored.claims) ? stored.claims : [];
profiles.clear();
for (const [did, profile] of stored.profiles ?? []) {
profiles.set(did, profile);
}
return true;
} catch {
return false;
}
}
export function owner() {
return profiles.get(account.did) ?? null;
}
/**
* The profile behind a DID, where one has been learned.
* @param {string|undefined} did
*/
export function profileFor(did) {
return (did && profiles.get(did)) || null;
}
/** DIDs the appview has answered or failed once already. */
const asked = new Set();
/**
* Learn the profiles behind statement authors, so their statements show
* their picture. A DID the appview does not know stays absent, and the
* page draws a stand-in; each DID is asked about once per visit.
* @param {string[]} dids
*/
export async function learnProfiles(dids) {
const unknown = dids.filter((did) => did && !asked.has(did));
for (const did of unknown) asked.add(did);
if (unknown.length > 0) await loadProfiles(unknown);
}
/**
* The first letter to show while an avatar loads, or in place of one.
* @param {string|undefined} text
*/
export function initial(text) {
return (String(text || '?').trim()[0] || '?').toUpperCase();
}
/**
* How to show one commit author.
*
* A claim carrying no DID is the account speaking about itself. A claim
* naming another DID is the account vouching for somebody else, which the
* page shows the same way: the reader already decided to trust this account
* by opening its repositories.
* @param {string} ident - the commit's author ident
*/
export function describeAuthor(ident) {
const { name, email } = splitIdent(ident);
const claim = matchIdent(claims, ident);
// Whose profile speaks for this author: the DID the claim names, or the
// account itself where the claim names no one else. A label with no DID
// names something that holds no account at all, so the hosting account
// must not answer in its place.
const named = claim?.did || (claim && !claim.label ? account.did : '');
const profile = named ? profiles.get(named) : null;
// A claim may name a tool or a bot rather than an account. The account
// says what to call it and gives it a face, and the blob it names is in
// the account's own repo.
if (!profile && claim?.label) {
return {
name: claim.label,
handle: '',
avatar:
claim.avatarUrl ||
(claim.avatarCid
? `${account.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(account.did)}&cid=${encodeURIComponent(claim.avatarCid)}`
: ''),
atproto: false,
title: `${ident} · named by @${owner()?.handle ?? account.handle}`,
};
}
// The DID rides out whether or not a profile is known, because it is what
// a face with no picture draws its pattern from. Seeding that from the name
// instead would give one person two faces on one page: an identicon beside
// their words, where the DID is in hand, and something else on their
// commits.
if (named) {
return {
name: profile?.displayName || profile?.handle || name || email,
handle: profile?.handle ?? '',
did: named,
avatar: profile?.avatar ?? '',
atproto: true,
title: `${ident} · claimed by @${owner()?.handle ?? account.handle}`,
};
}
return {
name: name || email || 'unknown',
handle: '',
did: '',
avatar: '',
atproto: false,
title: ident,
};
}