Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410#!/usr/bin/env node// @pdsjs/sites - the pdsjs-site deploy CLI//// The only file in this package that touches the filesystem; everything// decision-shaped lives in deploy.js where it is unit-testable. A deploy is// stock atproto traffic — createSession, uploadBlob per changed file, one// putRecord — so the server needs nothing beyond an ordinary PDS.
import { readdirSync, readFileSync, statSync } from 'node:fs';import { join, relative, sep } from 'node:path';import { checkUploadedRef, planDeploy } from './deploy.js';import { planInstall } from './install.js';import { SITE_COLLECTION } from './lexicons.js';
function usage() { console.error(`Usage: pdsjs-site deploy <dir> --site <name> --pds <url> --handle <handle-or-did> [--fallback <path>] [--cache-control "[glob:]value"]... pdsjs-site install <at-uri> --pds <url> --handle <handle-or-did> [--site <name>] [--set key=value]... [--dev-pds <url>]
Deploys a directory as a static site: files become blobs, the manifestbecomes the dev.pdsjs.site.deploy/<name> record. The app password is read fromthe PDS_APP_PASSWORD environment variable, never from arguments.
install copies an app published as a dev.pdsjs.app.manifest record onto yourown PDS: the site files come over as blobs (verified by CID), the app's querydefinitions are written, and a dev.pdsjs.app.install record pins the manifestversion. --set chooses declared settings; --dev-pds overrides the developerPDS lookup for local testing.
--cache-control sets the Cache-Control header files are served with. Withouta glob it applies to every file; with one ("assets/**:...") it applies to thematched paths, and the last matching flag wins. Content is addressed by hash,so fingerprinted assets can safely take "public, max-age=31536000, immutable".Files no flag matches keep the value already on the record.`); process.exit(2);}
/** * Walk a directory into forward-slash relative paths, skipping dotfiles. * @param {string} root * @returns {Array<{path: string, bytes: Uint8Array}>} */function walk(root) { /** @type {Array<{path: string, bytes: Uint8Array}>} */ const files = []; /** @param {string} dir */ const visit = (dir) => { for (const name of readdirSync(dir)) { if (name.startsWith('.')) continue; const full = join(dir, name); const stat = statSync(full); if (stat.isDirectory()) visit(full); else if (stat.isFile()) { files.push({ path: relative(root, full).split(sep).join('/'), bytes: new Uint8Array(readFileSync(full)), }); } } }; visit(root); return files;}
/** * @param {string} base * @param {string} path * @param {RequestInit & {expectOk?: boolean}} [init] */async function xrpc(base, path, init = {}) { const response = await fetch(`${base.replace(/\/$/, '')}/xrpc/${path}`, init); const body = await response.json().catch(() => ({})); if (init.expectOk !== false && !response.ok) { throw new Error( `${path} failed (${response.status}): ${body.message || body.error || 'unknown error'}`, ); } return { status: response.status, body };}
/** * Resolve an at:// URI's authority to a DID, via the public resolver when it * is a handle. * @param {string} authority * @returns {Promise<string>} */async function resolveDid(authority) { if (authority.startsWith('did:')) return authority; const response = await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(authority)}`, ); if (!response.ok) throw new Error(`Could not resolve handle ${authority}.`); return (await response.json()).did;}
/** * The PDS endpoint from a DID's document. * @param {string} did * @returns {Promise<string>} */async function pdsEndpoint(did) { const response = await fetch(`https://plc.directory/${did}`); if (!response.ok) throw new Error(`Could not resolve ${did}.`); const doc = await response.json(); const service = (doc.service || []).find( (/** @type {{id?: string, type?: string}} */ s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer', ); if (!service?.serviceEndpoint) { throw new Error(`${did} names no PDS in its document.`); } return service.serviceEndpoint;}
/** @param {string[]} args */async function install(args) { /** @type {Record<string, string>} */ const flags = {}; /** @type {Record<string, unknown>} */ const overrides = {}; /** @type {string[]} */ const positional = []; for (let i = 1; i < args.length; i++) { if (args[i] === '--set') { const raw = args[++i] || ''; const eq = raw.indexOf('='); if (eq === -1) usage(); const value = raw.slice(eq + 1); overrides[raw.slice(0, eq)] = value === 'true' ? true : value === 'false' ? false : value; } else if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] || ''; else positional.push(args[i]); } const uri = positional[0]; const { pds, handle } = flags; if (!uri || !pds || !handle) usage(); const match = uri.match(/^at:\/\/([^/]+)\/dev\.pdsjs\.app\.manifest\/(.+)$/); if (!match) { console.error( 'Give the manifest as at://<did-or-handle>/dev.pdsjs.app.manifest/<rkey>', ); process.exit(2); }
const password = process.env.PDS_APP_PASSWORD; if (!password) { console.error('Set PDS_APP_PASSWORD to an app password for your account.'); process.exit(2); }
const devDid = await resolveDid(match[1]); const devPds = flags['dev-pds'] || (await pdsEndpoint(devDid)); console.error(`developer: ${devDid} at ${devPds}`);
/** @param {string} path */ const devGet = async (path) => { const response = await fetch(`${devPds.replace(/\/$/, '')}/xrpc/${path}`); if (!response.ok) { throw new Error(`${path.split('?')[0]} failed (${response.status})`); } return response; };
const manifest = await ( await devGet( `com.atproto.repo.getRecord?repo=${encodeURIComponent(devDid)}&collection=dev.pdsjs.app.manifest&rkey=${encodeURIComponent(match[2])}`, ) ).json(); const siteRef = manifest.value?.site; if (!siteRef?.uri) throw new Error('The manifest names no site.'); const siteMatch = siteRef.uri.match(/\/([^/]+)$/); const site = await ( await devGet( `com.atproto.repo.getRecord?repo=${encodeURIComponent(devDid)}&collection=${SITE_COLLECTION}&rkey=${encodeURIComponent(siteMatch[1])}`, ) ).json();
const session = await xrpc(pds, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: handle, password }), }); const { accessJwt, did } = session.body; const auth = { Authorization: `Bearer ${accessJwt}` };
const installs = await xrpc( pds, `com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=dev.pdsjs.app.install&limit=100`, { expectOk: false }, ); const existing = (installs.body.records || []).find( ( /** @type {{uri: string, value?: {manifest?: {uri?: string}, config?: Record<string, unknown>}}} */ r, ) => r.value?.manifest?.uri === manifest.uri, );
const plan = planInstall({ manifest, site, localName: flags.site, overrides, existingConfig: existing?.value?.config, }); console.error( `installing "${manifest.value.name}" as site "${plan.localName}": ${plan.copies.length} files, ${plan.queryDefs.length} queries`, );
for (const copy of plan.copies) { const bytes = new Uint8Array( await ( await devGet( `com.atproto.sync.getBlob?did=${encodeURIComponent(devDid)}&cid=${copy.cid}`, ) ).arrayBuffer(), ); const uploaded = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': copy.contentType }, body: /** @type {BodyInit} */ (bytes), }); const gotCid = uploaded.body.blob?.ref?.$link; // Content addressing is the integrity check: identical bytes must // produce the CID the developer's record named. if (gotCid !== copy.cid) { throw new Error( `Copy of ${copy.path} came back as ${gotCid}, expected ${copy.cid}. Not installing.`, ); } console.error(`copied ${copy.path} (${bytes.length} bytes)`); }
if (plan.icon) { const bytes = new Uint8Array( await ( await devGet( `com.atproto.sync.getBlob?did=${encodeURIComponent(devDid)}&cid=${plan.icon.cid}`, ) ).arrayBuffer(), ); const uploaded = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': plan.icon.contentType }, body: /** @type {BodyInit} */ (bytes), }); if (uploaded.body.blob?.ref?.$link !== plan.icon.cid) { throw new Error('The icon copy did not verify. Not installing.'); } console.error(`copied icon (${bytes.length} bytes)`); }
await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: SITE_COLLECTION, rkey: plan.localName, record: plan.siteRecord, }), }); for (const def of plan.queryDefs) { await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.query.def', rkey: def.rkey, record: def.record, }), }); }
// One install record per manifest: a reinstall updates it in place. if (existing) { await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.app.install', rkey: existing.uri.split('/').pop(), record: plan.installRecord, }), }); } else { await xrpc(pds, 'com.atproto.repo.createRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.app.install', record: plan.installRecord, }), }); } console.error( `Installed "${manifest.value.name}". The site serves as "${plan.localName}" on your site domain.`, );}
async function main() { const args = process.argv.slice(2); if (args[0] === 'install') return install(args); if (args[0] !== 'deploy') usage(); /** @type {Record<string, string>} */ const flags = {}; /** @type {import('./deploy.js').CacheRule[]} */ const cacheRules = []; /** @type {string[]} */ const positional = []; for (let i = 1; i < args.length; i++) { if (args[i] === '--cache-control') { // A colon separates an optional glob from the value; Cache-Control // directives never contain one, so a colon always means a glob. const raw = args[++i] || ''; const colon = raw.indexOf(':'); cacheRules.push( colon === -1 ? { value: raw.trim() } : { glob: raw.slice(0, colon).trim(), value: raw.slice(colon + 1).trim(), }, ); } else if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] || ''; else positional.push(args[i]); } const dir = positional[0]; const { site, pds, handle, fallback } = flags; if (!dir || !site || !pds || !handle) usage(); if (cacheRules.some((rule) => !rule.value)) { console.error('Each --cache-control needs a header value.'); process.exit(2); }
const password = process.env.PDS_APP_PASSWORD; if (!password) { console.error('Set PDS_APP_PASSWORD to an app password for the account.'); process.exit(2); }
const files = walk(dir); if (files.length === 0) { console.error(`No files under ${dir}.`); process.exit(1); }
const session = await xrpc(pds, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: handle, password }), }); const { accessJwt, did } = session.body; const auth = { Authorization: `Bearer ${accessJwt}` };
const existing = await xrpc( pds, `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${SITE_COLLECTION}&rkey=${encodeURIComponent(site)}`, { expectOk: false }, );
const plan = await planDeploy({ site, files, existing: existing.status === 200 ? existing.body : null, fallback, cacheRules, });
for (const upload of plan.uploads) { const response = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': upload.contentType }, body: /** @type {BodyInit} */ (upload.bytes), }); const ref = checkUploadedRef(upload, response.body.blob); const entry = /** @type {Array<{path: string, blob: unknown}>} */ ( plan.record.files ).find((file) => file.path === upload.path); if (entry) entry.blob = ref; console.error(`uploaded ${upload.path} (${upload.bytes.length} bytes)`); }
await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: SITE_COLLECTION, rkey: site, record: plan.record, }), });
console.error( `Deployed ${site}: ${plan.uploads.length} uploaded, ${plan.reused.length} unchanged, ${plan.removed.length} removed.`, ); console.error( 'Removed files orphan their blobs; the server reaps them within a day.', );}
main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1);});