chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Deploy the repository browser to the account's own site.
//
// The build is one self-contained document, so the site record names one
// file and a visitor holding cached HTML can never ask for an asset the
// record no longer carries.
//
// Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… npm run deploy --workspace @pdsjs/git-ui
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { cidToString, createBlobCid } from '@pdsjs/core/repo';
import { SCOPE } from '../src/lib/scope.js';
const PDS = process.env.PDS_URL;
const DID = process.env.PDS_DID;
if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.');
const SITE = process.env.GIT_SITE || 'git';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';
const REPOS = 'dev.pdsjs.git.repo';
const IDENTS = 'dev.pdsjs.git.identity';
const QUERY = 'git-repos';
const SORT = { field: 'updatedAt', direction: 'desc' };
async function xrpc(path, init = {}) {
const response = await fetch(`${PDS}/xrpc/${path}`, init);
if (!response.ok) {
throw new Error(
`${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`,
);
}
return response.json();
}
const password = process.env.PDS_APP_PASSWORD;
if (!password) throw new Error('Set PDS_APP_PASSWORD.');
const session = await xrpc('com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: DID, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };
/** The fields a rebuild moves on its own, which say nothing about the app. */
const TIMESTAMPS = ['createdAt', 'updatedAt'];
/**
* A record as one string, with the keys in one order and the timestamps
* blanked. The PDS returns a record decoded from CBOR, so its key order is
* its own and never the order this file writes.
* @param {unknown} value
* @returns {string}
*/
function comparable(value) {
if (Array.isArray(value)) return `[${value.map(comparable).join(',')}]`;
if (value === null || typeof value !== 'object') return JSON.stringify(value);
const entries = Object.entries(value)
.filter(([key]) => !TIMESTAMPS.includes(key))
.sort(([a], [b]) => (a < b ? -1 : 1));
return `{${entries.map(([k, v]) => `${k}:${comparable(v)}`).join(',')}}`;
}
/** @typedef {{mimeType: string, bytes: Uint8Array}} PendingBlob */
/** Blobs the next record names, held until that record is known to be new. */
const pending = /** @type {PendingBlob[]} */ ([]);
/**
* The blob object a record names, hashed here rather than by the server. A
* deploy that changes nothing sends none of these bytes, and the PDS already
* holds every one of them under the record it is about to keep.
* @param {string} mimeType
* @param {string} content
* @returns {Promise<Record<string, unknown>>}
*/
async function blobFor(mimeType, content) {
const bytes = new TextEncoder().encode(content);
pending.push({ mimeType, bytes });
return {
$type: 'blob',
ref: { $link: cidToString(await createBlobCid(bytes)) },
mimeType,
size: bytes.length,
};
}
/**
* Write the record, unless the one already there differs only in its
* timestamps. An install pins the manifest by CID and offers an update when
* that CID moves, so a rebuild that changes nothing has to write nothing.
* @param {string} collection @param {string} rkey @param {Record<string, unknown>} record
*/
async function put(collection, rkey, record) {
const value = { $type: collection, ...record };
const current = await fetch(
`${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${collection}&rkey=${rkey}`,
);
if (current.ok) {
const existing = (await current.json()).value;
if (comparable(existing) === comparable(value)) {
pending.length = 0;
console.error(`keep ${collection}/${rkey}`);
return;
}
}
// The PDS rejects a record naming a blob it does not hold, so the queue
// drains first.
for (const upload of pending.splice(0)) {
const res = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { ...auth, 'Content-Type': upload.mimeType },
body: upload.bytes,
});
if (!res.ok) throw new Error(`uploadBlob ${res.status}`);
}
await xrpc('com.atproto.repo.putRecord', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: DID, collection, rkey, record: value }),
});
console.error(`put ${collection}/${rkey}`);
}
// ---- the query the app reads ----
await put(DEFS, QUERY, {
collection: REPOS,
sort: SORT,
createdAt: new Date().toISOString(),
});
// ---- the account's own git identities ----
// GIT_IDENTS is a comma-separated list of commit author addresses, or bare
// names for a history written without one. Append `=did:plc:...` to say the
// author is somebody other than this account, which is how a project account
// attributes commits to the people who wrote them.
//
// GIT_IDENTS='me@example.com,them@example.com=did:plc:...'
//
// The record is the account's, not the site's: it holds for every repository
// the account serves. Leaving GIT_IDENTS unset leaves whatever the account
// already claims untouched.
const idents = (process.env.GIT_IDENTS || '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [subject, did] = entry.split('=').map((part) => part.trim());
const claim = subject.includes('@')
? { email: subject }
: { name: subject };
return did ? { ...claim, did } : claim;
});
if (idents.length > 0) {
const claimed = await fetch(
`${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${IDENTS}&rkey=self`,
);
const createdAt = claimed.ok
? ((await claimed.json()).value.createdAt ?? new Date().toISOString())
: new Date().toISOString();
await put(IDENTS, 'self', {
idents,
createdAt,
updatedAt: new Date().toISOString(),
});
console.error(
`claimed ${idents.length} git ${idents.length === 1 ? 'ident' : 'idents'}.`,
);
}
// ---- the built document ----
const distIndex = fileURLToPath(new URL('../dist/index.html', import.meta.url));
let html;
try {
html = readFileSync(distIndex, 'utf8');
} catch {
throw new Error('No build found. Run `npm run build` first.');
}
// ---- the OAuth registration ----
// A visitor signs in to review, and their authorization server reads this
// app's registration from the site itself. A public client: no secret to
// keep, so PKCE and DPoP carry the whole burden of proving that the browser
// redeeming a code is the browser that asked for one.
//
// The site's origin is the account's own arrangement, and the authorization
// server refuses a registration that names anywhere else. The deploy cannot
// know it, so the registration names a token the server fills in with the
// origin each request arrives on. One deploy then signs in on any origin the
// site is served from, and an install that copies these files carries a
// registration that is right for wherever it lands.
const ORIGIN_TOKEN = '__PDSJS_ORIGIN__';
const clientMetadata = {
client_id: `${ORIGIN_TOKEN}/client-metadata.json`,
client_name: 'Repositories',
client_uri: ORIGIN_TOKEN,
redirect_uris: [`${ORIGIN_TOKEN}/callback`],
scope: SCOPE,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'web',
token_endpoint_auth_method: 'none',
dpop_bound_access_tokens: true,
};
// ---- deploy ----
const blob = await blobFor('text/html; charset=utf-8', html);
const files = [
{
path: 'index.html',
blob,
contentType: 'text/html; charset=utf-8',
cacheControl: 'public, max-age=300',
},
];
files.push({
path: 'client-metadata.json',
blob: await blobFor(
'application/json',
`${JSON.stringify(clientMetadata, null, 2)}\n`,
),
contentType: 'application/json',
// The server fills the origin token in per request, so the bytes differ
// by origin; a short cache holds, keyed by the origin's own URL.
cacheControl: 'public, max-age=60',
originTemplate: true,
});
await put(SITES, SITE, {
files,
fallback: 'index.html',
createdAt: new Date().toISOString(),
});
console.error(
`${SITE}: ${Math.round(Number(blob.size) / 1024)}KB, ${files.length} file${files.length === 1 ? '' : 's'}.`,
);
// ---- publish it as an installable app ----
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0a0a0c"/>
<path d="M22 16v20a8 8 0 0 0 8 8h6" fill="none" stroke="#6d6df0" stroke-width="5" stroke-linecap="round"/>
<circle cx="22" cy="14" r="6" fill="#6d6df0"/>
<circle cx="42" cy="44" r="6" fill="#6d6df0"/>
<circle cx="22" cy="50" r="6" fill="#6d6df0"/>
</svg>`;
const icon = await blobFor('image/svg+xml', ICON);
const deployed = await xrpc(
`com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'git', {
name: 'Git',
description:
'A public forge for the git repositories in your account. Visitors browse trees, files, history and commit diffs with no sign-in, because the page reads the repository records through a query and opens the bundle blobs itself.',
icon,
site: { uri: deployed.uri, cid: deployed.cid },
suggestedSite: 'git',
primitives: ['serve', 'query'],
external: false,
queries: [{ name: QUERY, collection: REPOS, sort: SORT }],
// The page asks the account's own server what it serves. Both settings
// override that answer, for a deployment behind a proxy that withholds the
// open origin, or an owner who wants one of the two links hidden.
settings: [
{
key: 'httpClone',
label: 'Clone over https',
type: 'select',
options: ['auto', 'on', 'off'],
default: 'auto',
},
{
key: 'rawFiles',
label: 'Link to raw file paths',
type: 'select',
options: ['auto', 'on', 'off'],
default: 'auto',
},
],
createdAt: new Date().toISOString(),
});
console.error(`Installable at at://${DID}/dev.pdsjs.app.manifest/git`);