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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
409 lines · 13.6 KB Raw History
#!/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 manifest
becomes the dev.pdsjs.site.deploy/<name> record. The app password is read from
the PDS_APP_PASSWORD environment variable, never from arguments.

install copies an app published as a dev.pdsjs.app.manifest record onto your
own PDS: the site files come over as blobs (verified by CID), the app's query
definitions are written, and a dev.pdsjs.app.install record pins the manifest
version. --set chooses declared settings; --dev-pds overrides the developer
PDS lookup for local testing.

--cache-control sets the Cache-Control header files are served with. Without
a glob it applies to every file; with one ("assets/**:...") it applies to the
matched 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);
});